mirror of
https://github.com/multica-ai/multica.git
synced 2026-07-04 21:09:56 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/j/4f
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6f07e2d7f2 | ||
|
|
74e3d8b18c |
@@ -500,7 +500,12 @@ export class ApiClient {
|
||||
});
|
||||
}
|
||||
|
||||
async quickCreateIssue(data: { agent_id: string; prompt: string; project_id?: string | null }): Promise<{ task_id: string }> {
|
||||
async quickCreateIssue(data: {
|
||||
agent_id?: string;
|
||||
squad_id?: string;
|
||||
prompt: string;
|
||||
project_id?: string | null;
|
||||
}): Promise<{ task_id: string }> {
|
||||
return this.fetch("/api/issues/quick-create", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(data),
|
||||
|
||||
@@ -2,7 +2,8 @@ import { beforeEach, describe, expect, it } from "vitest";
|
||||
import { useQuickCreateStore } from "./quick-create-store";
|
||||
|
||||
const RESET_STATE = {
|
||||
lastAgentId: null,
|
||||
lastActorType: null,
|
||||
lastActorId: null,
|
||||
lastProjectId: null,
|
||||
prompt: "",
|
||||
keepOpen: false,
|
||||
@@ -34,4 +35,20 @@ describe("quick create store", () => {
|
||||
setLastProjectId(null);
|
||||
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
|
||||
});
|
||||
|
||||
it("remembers the last actor (agent or squad) and clears both fields together", () => {
|
||||
const { setLastActor } = useQuickCreateStore.getState();
|
||||
|
||||
setLastActor("agent", "agent-1");
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBe("agent");
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBe("agent-1");
|
||||
|
||||
setLastActor("squad", "squad-1");
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBe("squad");
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBe("squad-1");
|
||||
|
||||
setLastActor(null, null);
|
||||
expect(useQuickCreateStore.getState().lastActorType).toBeNull();
|
||||
expect(useQuickCreateStore.getState().lastActorId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5,17 +5,26 @@ import { createJSONStorage, persist } from "zustand/middleware";
|
||||
import { createWorkspaceAwareStorage, registerForWorkspaceRehydration } from "../../platform/workspace-storage";
|
||||
import { defaultStorage } from "../../platform/storage";
|
||||
|
||||
// Per-workspace memory of the last agent and project the user picked in the
|
||||
// Quick Create modal. Defaulted to those values on next open so frequent
|
||||
// users skip the pickers entirely — without this, anyone targeting a single
|
||||
// project ends up retyping "in project A" on every prompt. Persisted with
|
||||
// the workspace-aware StateStorage so switching workspaces shows the right
|
||||
// default automatically. Per-user scoping comes for free from localStorage
|
||||
// being browser-profile-local — matches how draft-store /
|
||||
// issues-scope-store / comment-collapse-store already namespace themselves.
|
||||
export type QuickCreateActorType = "agent" | "squad";
|
||||
|
||||
// Per-workspace memory of the last actor (agent or squad) and project the
|
||||
// user picked in the Quick Create modal. Defaulted to those values on next
|
||||
// open so frequent users skip the pickers entirely — without this, anyone
|
||||
// targeting a single project ends up retyping "in project A" on every
|
||||
// prompt. Persisted with the workspace-aware StateStorage so switching
|
||||
// workspaces shows the right default automatically. Per-user scoping comes
|
||||
// for free from localStorage being browser-profile-local — matches how
|
||||
// draft-store / issues-scope-store / comment-collapse-store already
|
||||
// namespace themselves.
|
||||
//
|
||||
// lastActorType + lastActorId replace the prior `lastAgentId` field once
|
||||
// squads became selectable. Users who had a persisted agent preference
|
||||
// land back on whatever the picker shows first; a one-time re-pick is
|
||||
// preferable to the type-tag ambiguity of overloading a single UUID.
|
||||
interface QuickCreateState {
|
||||
lastAgentId: string | null;
|
||||
setLastAgentId: (id: string | null) => void;
|
||||
lastActorType: QuickCreateActorType | null;
|
||||
lastActorId: string | null;
|
||||
setLastActor: (type: QuickCreateActorType | null, id: string | null) => void;
|
||||
lastProjectId: string | null;
|
||||
setLastProjectId: (id: string | null) => void;
|
||||
prompt: string;
|
||||
@@ -28,8 +37,9 @@ interface QuickCreateState {
|
||||
export const useQuickCreateStore = create<QuickCreateState>()(
|
||||
persist(
|
||||
(set) => ({
|
||||
lastAgentId: null,
|
||||
setLastAgentId: (id) => set({ lastAgentId: id }),
|
||||
lastActorType: null,
|
||||
lastActorId: null,
|
||||
setLastActor: (type, id) => set({ lastActorType: type, lastActorId: id }),
|
||||
lastProjectId: null,
|
||||
setLastProjectId: (id) => set({ lastProjectId: id }),
|
||||
prompt: "",
|
||||
|
||||
@@ -134,7 +134,10 @@
|
||||
"created_by": "Created by",
|
||||
"select_agent_aria": "Select agent",
|
||||
"pick_an_agent": "Pick an agent…",
|
||||
"no_agents": "No agents available.",
|
||||
"no_agents": "No agents or squads available.",
|
||||
"search_placeholder": "Search agents and squads…",
|
||||
"agents_group": "Agents",
|
||||
"squads_group": "Squads",
|
||||
"version_missing": "This agent's daemon doesn't report a CLI version. Create with agent needs multica CLI ≥ {{min}}. Upgrade the daemon and reconnect, or switch to manual create.",
|
||||
"version_below": "This agent's daemon CLI is {{current}} — Create with agent needs ≥ {{min}}. Upgrade the daemon, or switch to manual create.",
|
||||
"prompt_placeholder": "Tell the agent what to do, e.g. \"let Bohan fix the inbox loading slowness in the Web project\"",
|
||||
|
||||
@@ -134,7 +134,10 @@
|
||||
"created_by": "创建者",
|
||||
"select_agent_aria": "选择智能体",
|
||||
"pick_an_agent": "选一个智能体...",
|
||||
"no_agents": "暂无可用智能体。",
|
||||
"no_agents": "暂无可用智能体或小队。",
|
||||
"search_placeholder": "搜索智能体或小队...",
|
||||
"agents_group": "智能体",
|
||||
"squads_group": "小队",
|
||||
"version_missing": "该智能体的守护进程没有报告 CLI 版本。通过智能体创建需要 multica CLI ≥ {{min}}。请升级守护进程并重连,或切换到手动创建。",
|
||||
"version_below": "该智能体的守护进程 CLI 是 {{current}}——通过智能体创建需要 ≥ {{min}}。请升级守护进程,或切换到手动创建。",
|
||||
"prompt_placeholder": "告诉智能体要做什么,例如:\"让 Bohan 修一下 Web 项目里收件箱加载慢的问题\"",
|
||||
|
||||
@@ -35,8 +35,8 @@ const mockDraftStore = {
|
||||
description: "",
|
||||
status: "todo" as const,
|
||||
priority: "none" as const,
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
assigneeType: undefined as "agent" | "squad" | "member" | undefined,
|
||||
assigneeId: undefined as string | undefined,
|
||||
dueDate: null,
|
||||
},
|
||||
lastAssigneeType: undefined,
|
||||
@@ -265,6 +265,10 @@ describe("CreateIssueModal", () => {
|
||||
mockSetKeepOpen.mockImplementation((v: boolean) => {
|
||||
mockQuickCreateStore.keepOpen = v;
|
||||
});
|
||||
// Reset the shared draft mock so per-test assignee seeding (squad / agent)
|
||||
// doesn't leak into the next test in the suite.
|
||||
mockDraftStore.draft.assigneeType = undefined;
|
||||
mockDraftStore.draft.assigneeId = undefined;
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
@@ -357,6 +361,37 @@ describe("CreateIssueModal", () => {
|
||||
});
|
||||
});
|
||||
|
||||
// Manual → agent must also forward the picked squad. Without this branch
|
||||
// the agent panel silently falls back to the persisted actor / first
|
||||
// visible agent and the user loses the squad they just chose in manual.
|
||||
it("forwards the picked squad when switching to agent mode", async () => {
|
||||
mockDraftStore.draft.assigneeType = "squad";
|
||||
mockDraftStore.draft.assigneeId = "squad-1";
|
||||
const user = userEvent.setup();
|
||||
const onSwitchMode = vi.fn();
|
||||
|
||||
renderModal(
|
||||
<ManualCreatePanel
|
||||
onClose={vi.fn()}
|
||||
onSwitchMode={onSwitchMode}
|
||||
isExpanded={false}
|
||||
setIsExpanded={vi.fn()}
|
||||
backlogHintIssueId={null}
|
||||
setBacklogHintIssueId={vi.fn()}
|
||||
/>,
|
||||
);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Issue title"), "Refactor auth");
|
||||
await user.click(screen.getByRole("button", { name: /Switch to Agent/i }));
|
||||
|
||||
expect(onSwitchMode).toHaveBeenCalledTimes(1);
|
||||
const carry = onSwitchMode.mock.calls[0]?.[0];
|
||||
expect(carry).toEqual(
|
||||
expect.objectContaining({ prompt: "Refactor auth", squad_id: "squad-1" }),
|
||||
);
|
||||
expect(carry).not.toHaveProperty("agent_id");
|
||||
});
|
||||
|
||||
// Manual → agent must forward the picked project so the new modal pins to
|
||||
// the same target. Without this the agent panel re-seeds from its own
|
||||
// persisted `lastProjectId` and silently routes the issue to a stale one.
|
||||
|
||||
@@ -265,15 +265,20 @@ export function ManualCreatePanel({
|
||||
// Also forward the picked project so the agent panel pins the new issue
|
||||
// to it; without this the agent panel would fall back to its persisted
|
||||
// `lastProjectId`, silently routing the issue to the wrong project.
|
||||
// Forward squad picks alongside agent picks so the agent panel honors
|
||||
// the actor the user already chose — otherwise a squad selection silently
|
||||
// falls back to the persisted actor / first visible agent on flip.
|
||||
const switchToAgent = () => {
|
||||
const desc = descEditorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
const prompt = [title.trim(), desc].filter(Boolean).join("\n\n");
|
||||
setLastMode("agent");
|
||||
onSwitchMode?.({
|
||||
prompt,
|
||||
...(assigneeType === "agent" && assigneeId
|
||||
...(assigneeId && assigneeType === "agent"
|
||||
? { agent_id: assigneeId }
|
||||
: {}),
|
||||
: assigneeId && assigneeType === "squad"
|
||||
? { squad_id: assigneeId }
|
||||
: {}),
|
||||
...(projectId ? { project_id: projectId } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
@@ -4,7 +4,7 @@ import { render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
|
||||
const mockQuickCreateIssue = vi.hoisted(() => vi.fn());
|
||||
const mockSetLastAgentId = vi.hoisted(() => vi.fn());
|
||||
const mockSetLastActor = vi.hoisted(() => vi.fn());
|
||||
const mockSetLastProjectId = vi.hoisted(() => vi.fn());
|
||||
const mockSetPrompt = vi.hoisted(() => vi.fn());
|
||||
const mockClearPrompt = vi.hoisted(() => vi.fn());
|
||||
@@ -13,8 +13,9 @@ const mockSetLastMode = vi.hoisted(() => vi.fn());
|
||||
const mockToastSuccess = vi.hoisted(() => vi.fn());
|
||||
|
||||
const mockQuickCreateStore = {
|
||||
lastAgentId: null as string | null,
|
||||
setLastAgentId: mockSetLastAgentId,
|
||||
lastActorType: null as "agent" | "squad" | null,
|
||||
lastActorId: null as string | null,
|
||||
setLastActor: mockSetLastActor,
|
||||
lastProjectId: null as string | null,
|
||||
setLastProjectId: mockSetLastProjectId,
|
||||
prompt: "Persisted draft prompt",
|
||||
@@ -32,8 +33,20 @@ const mockProjectsQuery = vi.hoisted(() => ({
|
||||
isSuccess: true,
|
||||
}));
|
||||
|
||||
// Per-test override for the squads list so we can flip between "squads
|
||||
// exist and one's leader is reachable" and "no squads" cases without
|
||||
// re-mocking the whole module.
|
||||
const mockSquadsData = vi.hoisted(
|
||||
() => ({ list: [] as Array<{ id: string; name: string; leader_id: string; archived_at: string | null }> }),
|
||||
);
|
||||
|
||||
vi.mock("@tanstack/react-query", () => ({
|
||||
useQuery: ({ queryKey }: { queryKey: string[] }) => {
|
||||
// Workspace-scoped query keys carry the wsId as `queryKey[1]`; the
|
||||
// discriminator is at `queryKey[2]` (e.g. ["workspaces", wsId, "squads"]).
|
||||
if (queryKey[0] === "workspaces" && queryKey[2] === "squads") {
|
||||
return { data: mockSquadsData.list };
|
||||
}
|
||||
switch (queryKey[0]) {
|
||||
case "members":
|
||||
return { data: [{ user_id: "user-1", role: "admin" }] };
|
||||
@@ -71,6 +84,9 @@ vi.mock("@multica/core/paths", () => ({
|
||||
vi.mock("@multica/core/workspace/queries", () => ({
|
||||
agentListOptions: () => ({ queryKey: ["agents"] }),
|
||||
memberListOptions: () => ({ queryKey: ["members"] }),
|
||||
squadListOptions: (wsId: string) => ({
|
||||
queryKey: ["workspaces", wsId, "squads"],
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/projects/queries", () => ({
|
||||
@@ -171,13 +187,48 @@ vi.mock("@multica/ui/components/ui/dialog", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
|
||||
DropdownMenu: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuTrigger: ({ render }: { render: ReactNode }) => <>{render}</>,
|
||||
DropdownMenuContent: ({ children }: { children: ReactNode }) => <>{children}</>,
|
||||
DropdownMenuItem: ({ children, onClick }: { children: ReactNode; onClick?: () => void }) => (
|
||||
<button type="button" onClick={onClick}>{children}</button>
|
||||
vi.mock("../issues/components/pickers/property-picker", () => ({
|
||||
PropertyPicker: ({
|
||||
trigger,
|
||||
children,
|
||||
searchPlaceholder,
|
||||
onSearchChange,
|
||||
}: {
|
||||
trigger: ReactNode;
|
||||
children: ReactNode;
|
||||
searchPlaceholder?: string;
|
||||
onSearchChange?: (v: string) => void;
|
||||
}) => (
|
||||
<>
|
||||
{trigger}
|
||||
<input
|
||||
aria-label="actor-search"
|
||||
placeholder={searchPlaceholder}
|
||||
onChange={(e) => onSearchChange?.(e.target.value)}
|
||||
/>
|
||||
{children}
|
||||
</>
|
||||
),
|
||||
PickerItem: ({
|
||||
children,
|
||||
onClick,
|
||||
selected,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
onClick: () => void;
|
||||
selected?: boolean;
|
||||
}) => (
|
||||
<button type="button" onClick={onClick} data-selected={selected ? "true" : "false"}>
|
||||
{children}
|
||||
</button>
|
||||
),
|
||||
PickerSection: ({ label, children }: { label: string; children: ReactNode }) => (
|
||||
<div>
|
||||
<div data-testid="picker-section-label">{label}</div>
|
||||
{children}
|
||||
</div>
|
||||
),
|
||||
PickerEmpty: () => <div data-testid="picker-empty" />,
|
||||
}));
|
||||
|
||||
vi.mock("@multica/ui/components/ui/button", () => ({
|
||||
@@ -227,12 +278,14 @@ function renderPanel(props: React.ComponentProps<typeof AgentCreatePanel>) {
|
||||
describe("AgentCreatePanel", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockQuickCreateStore.lastAgentId = null;
|
||||
mockQuickCreateStore.lastActorType = null;
|
||||
mockQuickCreateStore.lastActorId = null;
|
||||
mockQuickCreateStore.lastProjectId = null;
|
||||
mockQuickCreateStore.prompt = "Persisted draft prompt";
|
||||
mockQuickCreateStore.keepOpen = false;
|
||||
mockProjectsQuery.data = [];
|
||||
mockProjectsQuery.isSuccess = true;
|
||||
mockSquadsData.list = [];
|
||||
mockQuickCreateIssue.mockResolvedValue(undefined);
|
||||
mockSetKeepOpen.mockImplementation((value: boolean) => {
|
||||
mockQuickCreateStore.keepOpen = value;
|
||||
@@ -273,7 +326,7 @@ describe("AgentCreatePanel", () => {
|
||||
});
|
||||
});
|
||||
|
||||
expect(mockSetLastAgentId).toHaveBeenCalledWith("agent-1");
|
||||
expect(mockSetLastActor).toHaveBeenCalledWith("agent", "agent-1");
|
||||
// No project picked → persisted project preference is cleared so the
|
||||
// store stays in sync with the actual outgoing request.
|
||||
expect(mockSetLastProjectId).toHaveBeenCalledWith(null);
|
||||
@@ -282,6 +335,54 @@ describe("AgentCreatePanel", () => {
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Picking a squad routes the submission through `squad_id` (not
|
||||
// `agent_id`) so the backend can resolve the squad's leader agent and
|
||||
// inject the squad-leader briefing on dispatch. The persisted preference
|
||||
// remembers the actor type so the next open defaults back to the squad.
|
||||
it("submits squad_id when the user picks a squad in the actor picker", async () => {
|
||||
mockSquadsData.list = [
|
||||
{ id: "squad-1", name: "Frontend Squad", leader_id: "agent-1", archived_at: null },
|
||||
];
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
|
||||
renderPanel({ onClose, isExpanded: false, setIsExpanded: vi.fn() });
|
||||
|
||||
// The picker mock renders both sections inline as buttons; click the
|
||||
// squad row directly.
|
||||
await user.click(screen.getByRole("button", { name: /Frontend Squad/ }));
|
||||
|
||||
const editor = screen.getByPlaceholderText(
|
||||
'Tell the agent what to do, e.g. "let Bohan fix the inbox loading slowness in the Web project"',
|
||||
);
|
||||
await user.clear(editor);
|
||||
await user.type(editor, "Investigate the regression");
|
||||
|
||||
await user.click(screen.getByRole("button", { name: /^Create \(/i }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockQuickCreateIssue).toHaveBeenCalledWith({
|
||||
squad_id: "squad-1",
|
||||
prompt: "Investigate the regression",
|
||||
project_id: undefined,
|
||||
});
|
||||
});
|
||||
expect(mockSetLastActor).toHaveBeenCalledWith("squad", "squad-1");
|
||||
});
|
||||
|
||||
// Squads whose leader agent isn't visible (archived, private, etc.) must
|
||||
// not appear in the picker — the backend would reject the pick on
|
||||
// validateAssigneePair, and showing them invites a confusing dead path.
|
||||
it("hides squads whose leader agent is not in the visible-agents list", () => {
|
||||
mockSquadsData.list = [
|
||||
{ id: "squad-orphan", name: "Orphan Squad", leader_id: "agent-missing", archived_at: null },
|
||||
];
|
||||
|
||||
renderPanel({ onClose: vi.fn(), isExpanded: false, setIsExpanded: vi.fn() });
|
||||
|
||||
expect(screen.queryByRole("button", { name: /Orphan Squad/ })).toBeNull();
|
||||
});
|
||||
|
||||
// If the user's persisted `lastProjectId` points at a project that has
|
||||
// been deleted (or moved to another workspace), the modal must not keep
|
||||
// submitting that dead UUID. Once the projects query resolves and the id
|
||||
|
||||
@@ -5,20 +5,17 @@ import { ArrowLeftRight, Check, ChevronRight, Maximize2, Minimize2, X as XIcon }
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import { toast } from "sonner";
|
||||
import { DialogTitle } from "@multica/ui/components/ui/dialog";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { api, ApiError } from "@multica/core/api";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useCurrentWorkspace } from "@multica/core/paths";
|
||||
import { agentListOptions } from "@multica/core/workspace/queries";
|
||||
import { agentListOptions, squadListOptions } from "@multica/core/workspace/queries";
|
||||
import { projectListOptions } from "@multica/core/projects/queries";
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import {
|
||||
useQuickCreateStore,
|
||||
type QuickCreateActorType,
|
||||
} from "@multica/core/issues/stores/quick-create-store";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
||||
import {
|
||||
@@ -29,11 +26,17 @@ import {
|
||||
} from "@multica/core/runtimes";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
import { formatShortcut, modKey, enterKey } from "@multica/core/platform";
|
||||
import type { Agent } from "@multica/core/types";
|
||||
import type { Agent, Squad } from "@multica/core/types";
|
||||
import { ActorAvatar } from "../common/actor-avatar";
|
||||
import { PillButton } from "../common/pill-button";
|
||||
import { ProjectPicker } from "../projects/components/project-picker";
|
||||
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
|
||||
import {
|
||||
PropertyPicker,
|
||||
PickerItem,
|
||||
PickerSection,
|
||||
PickerEmpty,
|
||||
} from "../issues/components/pickers/property-picker";
|
||||
import { useAuthStore } from "@multica/core/auth";
|
||||
import { memberListOptions } from "@multica/core/workspace/queries";
|
||||
import {
|
||||
@@ -45,6 +48,10 @@ import {
|
||||
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
|
||||
import { useT } from "../i18n";
|
||||
|
||||
type ActorSelection =
|
||||
| { type: "agent"; id: string }
|
||||
| { type: "squad"; id: string };
|
||||
|
||||
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
|
||||
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
|
||||
// (Portal + Overlay + Popup) are owned by CreateIssueDialog so mode-switching
|
||||
@@ -79,6 +86,7 @@ export function AgentCreatePanel({
|
||||
const userId = useAuthStore((s) => s.user?.id);
|
||||
const { data: members = [] } = useQuery(memberListOptions(wsId));
|
||||
const { data: agents = [] } = useQuery(agentListOptions(wsId));
|
||||
const { data: squads = [] } = useQuery(squadListOptions(wsId));
|
||||
// Pull `isSuccess` so the stale-id sweep below can distinguish "still
|
||||
// loading" from "loaded as empty". Reading length alone treats both as
|
||||
// empty and incorrectly clears a valid persisted preference on every open.
|
||||
@@ -91,7 +99,10 @@ export function AgentCreatePanel({
|
||||
[members, userId],
|
||||
);
|
||||
|
||||
// Visible = not archived AND assignable by this user.
|
||||
// Visible = not archived AND assignable by this user. Squads inherit
|
||||
// their leader agent's reachability: the backend always routes a squad
|
||||
// pick to the leader, so hiding squads whose leader isn't visible keeps
|
||||
// the picker honest with what the server would actually accept.
|
||||
const visibleAgents = useMemo(
|
||||
() =>
|
||||
agents.filter(
|
||||
@@ -99,9 +110,21 @@ export function AgentCreatePanel({
|
||||
),
|
||||
[agents, userId, memberRole],
|
||||
);
|
||||
const visibleAgentIds = useMemo(
|
||||
() => new Set(visibleAgents.map((a) => a.id)),
|
||||
[visibleAgents],
|
||||
);
|
||||
const visibleSquads = useMemo(
|
||||
() =>
|
||||
squads.filter(
|
||||
(s) => !s.archived_at && visibleAgentIds.has(s.leader_id),
|
||||
),
|
||||
[squads, visibleAgentIds],
|
||||
);
|
||||
|
||||
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
|
||||
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
|
||||
const lastActorType = useQuickCreateStore((s) => s.lastActorType);
|
||||
const lastActorId = useQuickCreateStore((s) => s.lastActorId);
|
||||
const setLastActor = useQuickCreateStore((s) => s.setLastActor);
|
||||
const lastProjectId = useQuickCreateStore((s) => s.lastProjectId);
|
||||
const setLastProjectId = useQuickCreateStore((s) => s.setLastProjectId);
|
||||
const promptDraft = useQuickCreateStore((s) => s.prompt);
|
||||
@@ -111,29 +134,63 @@ export function AgentCreatePanel({
|
||||
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
|
||||
const [agentId, setAgentId] = useState<string | undefined>(() => {
|
||||
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
||||
if (seed && visibleAgents.some((a) => a.id === seed)) return seed;
|
||||
return visibleAgents[0]?.id;
|
||||
});
|
||||
|
||||
// Re-seed once visible list resolves (queries may be empty on first render).
|
||||
useEffect(() => {
|
||||
if (agentId && visibleAgents.some((a) => a.id === agentId)) return;
|
||||
const seed = (data?.agent_id as string) || lastAgentId || undefined;
|
||||
if (seed && visibleAgents.some((a) => a.id === seed)) {
|
||||
setAgentId(seed);
|
||||
return;
|
||||
}
|
||||
const first = visibleAgents[0];
|
||||
if (first) setAgentId(first.id);
|
||||
}, [visibleAgents, agentId, data?.agent_id, lastAgentId]);
|
||||
|
||||
const selectedAgent = useMemo(
|
||||
() => visibleAgents.find((a) => a.id === agentId),
|
||||
[visibleAgents, agentId],
|
||||
// Resolve a candidate actor against the currently-visible agents / squads.
|
||||
// Returns null when the candidate doesn't exist in this workspace right
|
||||
// now (deleted, archived, permission revoked, etc.) so callers can fall
|
||||
// through to the next seed in the chain.
|
||||
const resolveActor = useCallback(
|
||||
(
|
||||
type: QuickCreateActorType | "agent" | "squad" | null | undefined,
|
||||
id: string | null | undefined,
|
||||
): ActorSelection | null => {
|
||||
if (!type || !id) return null;
|
||||
if (type === "squad" && visibleSquads.some((s) => s.id === id)) {
|
||||
return { type: "squad", id };
|
||||
}
|
||||
if (type === "agent" && visibleAgentIds.has(id)) {
|
||||
return { type: "agent", id };
|
||||
}
|
||||
return null;
|
||||
},
|
||||
[visibleSquads, visibleAgentIds],
|
||||
);
|
||||
|
||||
const seedActor = useCallback((): ActorSelection | null => {
|
||||
// Caller-provided seed wins (e.g. shell pre-seeds with `agent_id` /
|
||||
// `squad_id`), then persisted preference, then first visible agent.
|
||||
const dataAgent = data?.agent_id as string | undefined;
|
||||
const dataSquad = data?.squad_id as string | undefined;
|
||||
return (
|
||||
resolveActor("agent", dataAgent) ||
|
||||
resolveActor("squad", dataSquad) ||
|
||||
resolveActor(lastActorType, lastActorId) ||
|
||||
(visibleAgents[0]
|
||||
? ({ type: "agent", id: visibleAgents[0].id } as const)
|
||||
: null)
|
||||
);
|
||||
}, [resolveActor, data?.agent_id, data?.squad_id, lastActorType, lastActorId, visibleAgents]);
|
||||
|
||||
const [actor, setActor] = useState<ActorSelection | null>(() => seedActor());
|
||||
|
||||
// Re-seed once visible lists resolve (queries may be empty on first render).
|
||||
useEffect(() => {
|
||||
if (actor && resolveActor(actor.type, actor.id)) return;
|
||||
setActor(seedActor());
|
||||
}, [actor, resolveActor, seedActor]);
|
||||
|
||||
const selectedAgent = useMemo<Agent | undefined>(() => {
|
||||
if (!actor) return undefined;
|
||||
if (actor.type === "agent") return visibleAgents.find((a) => a.id === actor.id);
|
||||
const squad = visibleSquads.find((s) => s.id === actor.id);
|
||||
if (!squad) return undefined;
|
||||
return visibleAgents.find((a) => a.id === squad.leader_id);
|
||||
}, [actor, visibleAgents, visibleSquads]);
|
||||
|
||||
const selectedSquad = useMemo<Squad | undefined>(() => {
|
||||
if (actor?.type !== "squad") return undefined;
|
||||
return visibleSquads.find((s) => s.id === actor.id);
|
||||
}, [actor, visibleSquads]);
|
||||
|
||||
// Project selection — defaults to the last project the user picked in this
|
||||
// workspace. `data?.project_id` lets the modal opener seed a one-shot
|
||||
// override (e.g. a future "+ Issue" button on a project page); it does NOT
|
||||
@@ -212,16 +269,18 @@ export function AgentCreatePanel({
|
||||
|
||||
const submit = async () => {
|
||||
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
|
||||
if (!md || !agentId || submitting || versionBlocked || uploading) return;
|
||||
if (!md || !actor || submitting || versionBlocked || uploading) return;
|
||||
setSubmitting(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.quickCreateIssue({
|
||||
agent_id: agentId,
|
||||
...(actor.type === "agent"
|
||||
? { agent_id: actor.id }
|
||||
: { squad_id: actor.id }),
|
||||
prompt: md,
|
||||
project_id: projectId ?? undefined,
|
||||
});
|
||||
setLastAgentId(agentId);
|
||||
setLastActor(actor.type, actor.id);
|
||||
setLastProjectId(projectId);
|
||||
clearPrompt();
|
||||
setLastMode("agent");
|
||||
@@ -281,16 +340,16 @@ export function AgentCreatePanel({
|
||||
|
||||
// Switch to the manual form, carrying what the user typed over as the
|
||||
// description (markdown, including any pasted images) so they don't lose
|
||||
// their work. The picked agent becomes the default assignee candidate
|
||||
// (still editable). We seed the shared issue-draft store directly because
|
||||
// the manual panel reads its initial values from there. Persist the mode
|
||||
// flip so the next `c` lands in manual.
|
||||
// their work. The picked actor (agent or squad) becomes the default
|
||||
// assignee candidate (still editable). We seed the shared issue-draft
|
||||
// store directly because the manual panel reads its initial values from
|
||||
// there. Persist the mode flip so the next `c` lands in manual.
|
||||
const switchToManual = () => {
|
||||
const md = editorRef.current?.getMarkdown() ?? "";
|
||||
useIssueDraftStore.getState().setDraft({
|
||||
description: md,
|
||||
...(agentId
|
||||
? { assigneeType: "agent" as const, assigneeId: agentId }
|
||||
...(actor
|
||||
? { assigneeType: actor.type, assigneeId: actor.id }
|
||||
: {}),
|
||||
});
|
||||
setLastMode("manual");
|
||||
@@ -338,61 +397,23 @@ export function AgentCreatePanel({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Agent picker */}
|
||||
{/* Actor picker — agents and squads in one searchable list. Squads
|
||||
route to their leader agent on the backend; the leader runs the
|
||||
quick-create flow with the squad's Operating Protocol layered
|
||||
on top, so a squad pick is "ask this squad to file the issue". */}
|
||||
<div className="px-5 pt-1 pb-2 shrink-0">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t(($) => $.create_issue.agent.select_agent_aria)}
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60"
|
||||
>
|
||||
<span>{t(($) => $.create_issue.agent.created_by)}</span>
|
||||
{selectedAgent ? (
|
||||
<span className="flex items-center gap-1.5 text-foreground">
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={selectedAgent.id}
|
||||
size={16}
|
||||
/>
|
||||
{selectedAgent.name}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t(($) => $.create_issue.agent.pick_an_agent)}</span>
|
||||
)}
|
||||
</button>
|
||||
}
|
||||
/>
|
||||
<DropdownMenuContent align="start" className="w-64 max-h-72 overflow-y-auto">
|
||||
{visibleAgents.length === 0 ? (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.create_issue.agent.no_agents)}
|
||||
</div>
|
||||
) : (
|
||||
visibleAgents.map((a: Agent) => (
|
||||
<DropdownMenuItem
|
||||
key={a.id}
|
||||
onClick={() => {
|
||||
setAgentId(a.id);
|
||||
setError(null);
|
||||
}}
|
||||
className="flex items-center gap-2"
|
||||
>
|
||||
<ActorAvatar
|
||||
actorType="agent"
|
||||
actorId={a.id}
|
||||
size={16}
|
||||
/>
|
||||
<span className="flex-1 truncate">{a.name}</span>
|
||||
{agentId === a.id && (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<ActorPicker
|
||||
actor={actor}
|
||||
visibleAgents={visibleAgents}
|
||||
visibleSquads={visibleSquads}
|
||||
selectedAgent={selectedAgent}
|
||||
selectedSquad={selectedSquad}
|
||||
onPick={(next) => {
|
||||
setActor(next);
|
||||
setError(null);
|
||||
}}
|
||||
t={t}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{selectedAgent && versionBlocked && (
|
||||
@@ -486,7 +507,7 @@ export function AgentCreatePanel({
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={submit}
|
||||
disabled={!hasContent || !agentId || submitting || versionBlocked || uploading}
|
||||
disabled={!hasContent || !actor || submitting || versionBlocked || uploading}
|
||||
title={
|
||||
versionBlocked
|
||||
? t(($) => $.create_issue.agent.version_blocked_tooltip, { min: versionCheck.min })
|
||||
@@ -503,3 +524,125 @@ export function AgentCreatePanel({
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
// ActorPicker — the "Created by" trigger + searchable popover listing
|
||||
// agents and squads. Lives in this file (not under issues/components/pickers)
|
||||
// because it composes the generic PropertyPicker with a quick-create-shaped
|
||||
// trigger styled to match the modal header row — promoting it would invite
|
||||
// reuse pressure on a UI that's deliberately tuned for this one surface.
|
||||
function ActorPicker({
|
||||
actor,
|
||||
visibleAgents,
|
||||
visibleSquads,
|
||||
selectedAgent,
|
||||
selectedSquad,
|
||||
onPick,
|
||||
t,
|
||||
}: {
|
||||
actor: ActorSelection | null;
|
||||
visibleAgents: Agent[];
|
||||
visibleSquads: Squad[];
|
||||
selectedAgent: Agent | undefined;
|
||||
selectedSquad: Squad | undefined;
|
||||
onPick: (next: ActorSelection) => void;
|
||||
t: ReturnType<typeof useT<"modals">>["t"];
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [filter, setFilter] = useState("");
|
||||
const query = filter.trim().toLowerCase();
|
||||
|
||||
const filteredAgents = useMemo(
|
||||
() => visibleAgents.filter((a) => a.name.toLowerCase().includes(query)),
|
||||
[visibleAgents, query],
|
||||
);
|
||||
const filteredSquads = useMemo(
|
||||
() => visibleSquads.filter((s) => s.name.toLowerCase().includes(query)),
|
||||
[visibleSquads, query],
|
||||
);
|
||||
|
||||
const displayLabel = selectedSquad?.name ?? selectedAgent?.name;
|
||||
const displayActor: ActorSelection | null = selectedSquad
|
||||
? { type: "squad", id: selectedSquad.id }
|
||||
: selectedAgent
|
||||
? { type: "agent", id: selectedAgent.id }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<PropertyPicker
|
||||
open={open}
|
||||
onOpenChange={(v: boolean) => {
|
||||
setOpen(v);
|
||||
if (!v) setFilter("");
|
||||
}}
|
||||
width="w-64"
|
||||
align="start"
|
||||
searchable
|
||||
searchPlaceholder={t(($) => $.create_issue.agent.search_placeholder)}
|
||||
onSearchChange={setFilter}
|
||||
trigger={
|
||||
<span className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors">
|
||||
<span>{t(($) => $.create_issue.agent.created_by)}</span>
|
||||
{displayActor && displayLabel ? (
|
||||
<span className="flex items-center gap-1.5 text-foreground">
|
||||
<ActorAvatar
|
||||
actorType={displayActor.type}
|
||||
actorId={displayActor.id}
|
||||
size={16}
|
||||
/>
|
||||
{displayLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span>{t(($) => $.create_issue.agent.pick_an_agent)}</span>
|
||||
)}
|
||||
</span>
|
||||
}
|
||||
>
|
||||
{filteredAgents.length === 0 && filteredSquads.length === 0 ? (
|
||||
query ? (
|
||||
<PickerEmpty />
|
||||
) : (
|
||||
<div className="px-2 py-1.5 text-xs text-muted-foreground">
|
||||
{t(($) => $.create_issue.agent.no_agents)}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<>
|
||||
{filteredAgents.length > 0 && (
|
||||
<PickerSection label={t(($) => $.create_issue.agent.agents_group)}>
|
||||
{filteredAgents.map((a) => (
|
||||
<PickerItem
|
||||
key={a.id}
|
||||
selected={actor?.type === "agent" && actor.id === a.id}
|
||||
onClick={() => {
|
||||
onPick({ type: "agent", id: a.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="agent" actorId={a.id} size={18} />
|
||||
<span className="truncate">{a.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
{filteredSquads.length > 0 && (
|
||||
<PickerSection label={t(($) => $.create_issue.agent.squads_group)}>
|
||||
{filteredSquads.map((s) => (
|
||||
<PickerItem
|
||||
key={s.id}
|
||||
selected={actor?.type === "squad" && actor.id === s.id}
|
||||
onClick={() => {
|
||||
onPick({ type: "squad", id: s.id });
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
<ActorAvatar actorType="squad" actorId={s.id} size={18} />
|
||||
<span className="truncate">{s.name}</span>
|
||||
</PickerItem>
|
||||
))}
|
||||
</PickerSection>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</PropertyPicker>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func TestQuickCreateCompletion_SubscribesRequester(t *testing.T) {
|
||||
parseUUID(testWorkspaceID),
|
||||
parseUUID(testUserID),
|
||||
parseUUID(agentID),
|
||||
pgtype.UUID{},
|
||||
"please file a bug",
|
||||
pgtype.UUID{},
|
||||
)
|
||||
@@ -106,6 +107,7 @@ func TestQuickCreateFailure_DoesNotSubscribeRequester(t *testing.T) {
|
||||
parseUUID(testWorkspaceID),
|
||||
parseUUID(testUserID),
|
||||
parseUUID(agentID),
|
||||
pgtype.UUID{},
|
||||
"another bug",
|
||||
pgtype.UUID{},
|
||||
)
|
||||
|
||||
@@ -1390,6 +1390,35 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
|
||||
resp.Repos = repos
|
||||
}
|
||||
}
|
||||
|
||||
// Squad-leader briefing injection for quick-create tasks. When
|
||||
// the user picked a squad in the modal, the task runs on the
|
||||
// squad's leader agent (resolved by the handler). Surface the
|
||||
// same Operating Protocol + Roster + user Instructions that
|
||||
// issue-bound squad tasks see, so the leader can decide to
|
||||
// delegate before opening the issue.
|
||||
if resp.Agent != nil && qc.SquadID != "" {
|
||||
wsUUID, wsErr := util.ParseUUID(qc.WorkspaceID)
|
||||
squadUUID, sqErr := util.ParseUUID(qc.SquadID)
|
||||
if wsErr == nil && sqErr == nil {
|
||||
if squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
||||
ID: squadUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
}); err == nil && uuidToString(squad.LeaderID) == resp.Agent.ID {
|
||||
briefing := buildSquadLeaderBriefing(r.Context(), h.Queries, squad)
|
||||
if strings.TrimSpace(resp.Agent.Instructions) == "" {
|
||||
resp.Agent.Instructions = briefing
|
||||
} else {
|
||||
resp.Agent.Instructions = resp.Agent.Instructions + "\n\n" + briefing
|
||||
}
|
||||
slog.Debug("injected squad leader briefing for quick-create",
|
||||
"squad_id", uuidToString(squad.ID),
|
||||
"squad_name", squad.Name,
|
||||
"leader_agent_id", resp.Agent.ID,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -852,18 +852,25 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
|
||||
// QuickCreateIssueRequest is the body for POST /api/issues/quick-create. The
|
||||
// user picks an agent in the modal and types one line of natural language;
|
||||
// the server validates the agent's reachability up front, queues a quick-
|
||||
// create task, and returns 202 immediately. The agent translates the prompt
|
||||
// into a `multica issue create` invocation in the background; success and
|
||||
// failure both surface as inbox notifications to the requester.
|
||||
// user picks an actor (agent or squad) in the modal and types one line of
|
||||
// natural language; the server validates the actor's reachability up front,
|
||||
// queues a quick-create task, and returns 202 immediately. The agent
|
||||
// translates the prompt into a `multica issue create` invocation in the
|
||||
// background; success and failure both surface as inbox notifications to
|
||||
// the requester.
|
||||
//
|
||||
// Exactly one of AgentID / SquadID is required. When SquadID is set, the
|
||||
// task is enqueued against the squad's leader agent and the leader receives
|
||||
// the same Operating Protocol briefing it would for an issue assigned to
|
||||
// the squad, so it can choose to delegate to a squad member as usual.
|
||||
//
|
||||
// ProjectID is optional and lets the modal target a specific project so
|
||||
// the agent's `multica issue create` invocation passes `--project <uuid>`
|
||||
// instead of letting it default. The frontend remembers the user's last
|
||||
// pick per workspace, so frequent users skip retyping "in project X".
|
||||
type QuickCreateIssueRequest struct {
|
||||
AgentID string `json:"agent_id"`
|
||||
AgentID string `json:"agent_id,omitempty"`
|
||||
SquadID string `json:"squad_id,omitempty"`
|
||||
Prompt string `json:"prompt"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
}
|
||||
@@ -885,8 +892,11 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
writeError(w, http.StatusBadRequest, "prompt is required")
|
||||
return
|
||||
}
|
||||
agentUUID, ok := parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
||||
if !ok {
|
||||
|
||||
hasAgent := strings.TrimSpace(req.AgentID) != ""
|
||||
hasSquad := strings.TrimSpace(req.SquadID) != ""
|
||||
if hasAgent == hasSquad {
|
||||
writeError(w, http.StatusBadRequest, "exactly one of agent_id or squad_id is required")
|
||||
return
|
||||
}
|
||||
|
||||
@@ -905,10 +915,48 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
|
||||
// Resolve the actor to the agent that will actually run the task. For
|
||||
// agent picks that's the agent itself; for squad picks it's the squad's
|
||||
// leader agent. The leader receives a squad-leader briefing on dispatch
|
||||
// (see daemon.go), matching the behavior of an issue assigned to the
|
||||
// squad — picking a squad here is functionally "ask the squad leader to
|
||||
// create this issue, on behalf of the squad".
|
||||
var agentUUID pgtype.UUID
|
||||
var squadUUID pgtype.UUID
|
||||
if hasSquad {
|
||||
var ok bool
|
||||
squadUUID, ok = parseUUIDOrBadRequest(w, req.SquadID, "squad_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
squad, err := h.Queries.GetSquadInWorkspace(r.Context(), db.GetSquadInWorkspaceParams{
|
||||
ID: squadUUID,
|
||||
WorkspaceID: wsUUID,
|
||||
})
|
||||
if err != nil {
|
||||
writeError(w, http.StatusNotFound, "squad not found")
|
||||
return
|
||||
}
|
||||
if squad.ArchivedAt.Valid {
|
||||
writeError(w, http.StatusBadRequest, "squad is archived")
|
||||
return
|
||||
}
|
||||
agentUUID = squad.LeaderID
|
||||
} else {
|
||||
var ok bool
|
||||
agentUUID, ok = parseUUIDOrBadRequest(w, req.AgentID, "agent_id")
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Reuse the same workspace-membership / archived / private-agent
|
||||
// ownership rules as `validateAssigneePair` so a user can't POST a
|
||||
// private agent_id they shouldn't be able to dispatch (the frontend
|
||||
// filters them out, but the handler is the trust boundary).
|
||||
// filters them out, but the handler is the trust boundary). Squad
|
||||
// picks reach this with the resolved leader agent; the same rules
|
||||
// apply — a private leader behind a squad the user can't reach
|
||||
// should still be rejected.
|
||||
if status, msg := h.validateAssigneePair(
|
||||
r.Context(), r, workspaceID,
|
||||
pgtype.Text{String: "agent", Valid: true},
|
||||
@@ -971,7 +1019,7 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
|
||||
projectUUID = pid
|
||||
}
|
||||
|
||||
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt, projectUUID)
|
||||
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, squadUUID, prompt, projectUUID)
|
||||
if err != nil {
|
||||
slog.Warn("quick-create enqueue failed", append(logger.RequestAttrs(r), "error", err)...)
|
||||
writeError(w, http.StatusInternalServerError, "failed to enqueue quick-create task")
|
||||
|
||||
@@ -464,12 +464,20 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
|
||||
// non-empty the daemon claim handler resolves the project's title +
|
||||
// resources, and the prompt template instructs the agent to pass
|
||||
// `--project <uuid>` so the new issue lands in that project.
|
||||
//
|
||||
// SquadID is non-empty when the user picked a squad (rather than an agent)
|
||||
// in the modal. The task is still enqueued against the squad's leader
|
||||
// agent (Queries.CreateQuickCreateTask is agent-scoped); SquadID is the
|
||||
// hint the daemon claim handler uses to layer the squad-leader briefing
|
||||
// onto the agent's Instructions, matching the behavior of issue-bound
|
||||
// tasks assigned to the squad.
|
||||
type QuickCreateContext struct {
|
||||
Type string `json:"type"`
|
||||
Prompt string `json:"prompt"`
|
||||
RequesterID string `json:"requester_id"`
|
||||
WorkspaceID string `json:"workspace_id"`
|
||||
ProjectID string `json:"project_id,omitempty"`
|
||||
SquadID string `json:"squad_id,omitempty"`
|
||||
}
|
||||
|
||||
// QuickCreateContextType marks a task as a quick-create job.
|
||||
@@ -485,7 +493,12 @@ const QuickCreateContextType = "quick_create"
|
||||
// projectID is optional (zero-valued pgtype.UUID when the user didn't pick
|
||||
// one). The handler is responsible for validating it belongs to the same
|
||||
// workspace before passing it in.
|
||||
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID pgtype.UUID, prompt string, projectID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
//
|
||||
// squadID is non-empty (Valid) when the user picked a squad as the actor.
|
||||
// The handler has already resolved it to the squad's leader agent for
|
||||
// agentID; the squadID hint is stamped into the task context so the daemon
|
||||
// claim handler can inject the squad-leader briefing on dispatch.
|
||||
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID, squadID pgtype.UUID, prompt string, projectID pgtype.UUID) (db.AgentTaskQueue, error) {
|
||||
agent, err := s.Queries.GetAgent(ctx, agentID)
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
|
||||
@@ -506,6 +519,9 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
|
||||
if projectID.Valid {
|
||||
payload.ProjectID = util.UUIDToString(projectID)
|
||||
}
|
||||
if squadID.Valid {
|
||||
payload.SquadID = util.UUIDToString(squadID)
|
||||
}
|
||||
contextJSON, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
return db.AgentTaskQueue{}, fmt.Errorf("marshal quick-create context: %w", err)
|
||||
@@ -524,6 +540,7 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
|
||||
slog.Info("quick-create task enqueued",
|
||||
"task_id", util.UUIDToString(task.ID),
|
||||
"agent_id", util.UUIDToString(agentID),
|
||||
"squad_id", payload.SquadID,
|
||||
"requester_id", util.UUIDToString(requesterID),
|
||||
"workspace_id", util.UUIDToString(workspaceID),
|
||||
"project_id", payload.ProjectID,
|
||||
|
||||
Reference in New Issue
Block a user