Compare commits

...

2 Commits

Author SHA1 Message Date
Jiang Bohan
6f07e2d7f2 fix(create-issue): forward squad picks across manual→agent switch
Manual mode → agent mode previously only carried `agent_id`, so picking
a squad and then flipping to agent silently fell back to the persisted
actor / first visible agent and lost the user's choice. Carry `squad_id`
on the same branch so the agent panel honors the squad pick.

Adds a sibling test alongside the existing project-carry case.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:26:42 +08:00
Jiang Bohan
74e3d8b18c feat(quick-create): searchable actor picker + squad support (MUL-2163)
- Replaces the flat agent dropdown in the "Create with agent" modal with a
  searchable PropertyPicker that lists Agents and Squads in separate
  sections, so users can filter by name and pick a squad as the creator.
- Persists the selection as (lastActorType, lastActorId), removing the
  agent-only lastAgentId field on the quick-create store.
- Adds squad_id to the quick-create API request and stamps it onto the
  task's QuickCreateContext. The handler resolves the squad to its leader
  agent (re-using validateAssigneePair) and the daemon claim path injects
  the squad-leader briefing when the task carries a squad hint, matching
  the behavior of issue-bound squad tasks.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-13 22:16:53 +08:00
13 changed files with 557 additions and 139 deletions

View File

@@ -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),

View File

@@ -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();
});
});

View File

@@ -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: "",

View File

@@ -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\"",

View File

@@ -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 项目里收件箱加载慢的问题\"",

View File

@@ -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.

View File

@@ -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 } : {}),
});
};

View File

@@ -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

View File

@@ -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>
);
}

View File

@@ -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{},
)

View File

@@ -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,
)
}
}
}
}
}

View File

@@ -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")

View File

@@ -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,