Compare commits

...

5 Commits

Author SHA1 Message Date
Jiang Bohan
7d4ed135ab test(quick-create): pass new isExpanded props in stale-project tests
Main got an expand button on AgentCreatePanel via #2320 while this branch
was open, adding `isExpanded` / `setIsExpanded` to the panel's required
props. The two new stale-project tests still passed `{ onClose }` only,
which CI's typecheck (run on the main+branch merge) caught while my
local run did not.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 16:04:18 +08:00
Jiang Bohan
53e6a81e2a Merge remote-tracking branch 'origin/main' into agent/j/0c90e95a 2026-05-09 16:02:56 +08:00
Jiang Bohan
a0172ec6c2 fix(quick-create): clear stale persisted project + carry across mode switch
Two review-blocking bugs in PR #2321:

1. The stale-id sweep in AgentCreatePanel only fired when projects.length > 0
   and only cleared local state, leaving lastProjectId pointing at a deleted
   project. The next open re-seeded the dead UUID and submit hit the server's
   `project not found` rejection. Gate on the query's `isSuccess` so we can
   tell "loading" apart from "loaded as empty", and clear both local state
   and the persisted preference when the selection isn't in the resolved list.

2. ManualCreatePanel's switchToAgent dropped the picked project from the carry
   payload, so flipping manual → agent silently fell back to the agent panel's
   own lastProjectId — potentially routing the issue to a different project
   than the one shown in manual mode. Forward project_id alongside prompt /
   agent_id, and add a regression test.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 15:54:37 +08:00
Jiang Bohan
4c8d20221b fix(quick-create): move project picker into property pill row
Reviewer feedback: the picker felt out of place wedged next to the agent
header. Move it into a property toolbar row above the footer, reusing the
shared `ProjectPicker` + `PillButton` so its placement and styling line up
exactly with the manual create panel.

This also drops the bespoke dropdown / aria / label strings that were only
needed while the picker rendered inline beside "Created by".

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 15:45:42 +08:00
Jiang Bohan
1a7c0c33a8 feat(quick-create): add project picker that remembers last pick
Quick-create users targeting one project repeatedly had to restate "in
project X" in every prompt. The modal now exposes a project picker beside
the agent picker, persists the selection per-workspace, and pins the
agent's `multica issue create` invocation to that project so the prompt
text doesn't have to.

The picked project also flows to the daemon as ProjectID/ProjectTitle and
its github_repo resources override the workspace repo fallback — same
treatment issue-bound tasks already get.

Co-authored-by: multica-agent <github@multica.ai>
2026-05-09 15:40:42 +08:00
13 changed files with 324 additions and 21 deletions

View File

@@ -443,7 +443,7 @@ export class ApiClient {
});
}
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
async quickCreateIssue(data: { agent_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

@@ -3,6 +3,7 @@ import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastAgentId: null,
lastProjectId: null,
prompt: "",
keepOpen: false,
};
@@ -23,4 +24,14 @@ describe("quick create store", () => {
clearPrompt();
expect(useQuickCreateStore.getState().prompt).toBe("");
});
it("remembers the last project picked so frequent users skip the picker", () => {
const { setLastProjectId } = useQuickCreateStore.getState();
setLastProjectId("proj-1");
expect(useQuickCreateStore.getState().lastProjectId).toBe("proj-1");
setLastProjectId(null);
expect(useQuickCreateStore.getState().lastProjectId).toBeNull();
});
});

View File

@@ -5,16 +5,19 @@ 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 the user picked in the Quick Create
// modal. Defaulted to that agent on next open so frequent users skip the
// picker entirely. 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.
// 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.
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
lastProjectId: string | null;
setLastProjectId: (id: string | null) => void;
prompt: string;
setPrompt: (prompt: string) => void;
clearPrompt: () => void;
@@ -27,6 +30,8 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
lastProjectId: null,
setLastProjectId: (id) => set({ lastProjectId: id }),
prompt: "",
setPrompt: (prompt) => set({ prompt }),
clearPrompt: () => set({ prompt: "" }),

View File

@@ -245,7 +245,7 @@ vi.mock("sonner", () => ({
},
}));
import { CreateIssueModal } from "./create-issue";
import { CreateIssueModal, ManualCreatePanel } from "./create-issue";
function renderModal(element: React.ReactElement) {
const qc = new QueryClient({
@@ -356,4 +356,36 @@ describe("CreateIssueModal", () => {
dueDate: null,
});
});
// 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.
it("forwards the picked project when switching to agent mode", async () => {
const user = userEvent.setup();
const onSwitchMode = vi.fn();
renderModal(
<ManualCreatePanel
onClose={vi.fn()}
onSwitchMode={onSwitchMode}
data={{ project_id: "proj-1" }}
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);
expect(onSwitchMode.mock.calls[0]?.[0]).toEqual(
expect.objectContaining({
prompt: "Refactor auth",
project_id: "proj-1",
}),
);
});
});

View File

@@ -262,6 +262,9 @@ export function ManualCreatePanel({
// panel reads `data.prompt` on mount. Concatenate title + description so
// nothing the user typed is lost — the agent derives a fresh title from
// the combined text. Persist the mode flip so the next `c` lands in agent.
// 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.
const switchToAgent = () => {
const desc = descEditorRef.current?.getMarkdown()?.trim() ?? "";
const prompt = [title.trim(), desc].filter(Boolean).join("\n\n");
@@ -271,6 +274,7 @@ export function ManualCreatePanel({
...(assigneeType === "agent" && assigneeId
? { agent_id: assigneeId }
: {}),
...(projectId ? { project_id: projectId } : {}),
});
};

View File

@@ -5,6 +5,7 @@ import userEvent from "@testing-library/user-event";
const mockQuickCreateIssue = vi.hoisted(() => vi.fn());
const mockSetLastAgentId = vi.hoisted(() => vi.fn());
const mockSetLastProjectId = vi.hoisted(() => vi.fn());
const mockSetPrompt = vi.hoisted(() => vi.fn());
const mockClearPrompt = vi.hoisted(() => vi.fn());
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
@@ -14,6 +15,8 @@ const mockToastSuccess = vi.hoisted(() => vi.fn());
const mockQuickCreateStore = {
lastAgentId: null as string | null,
setLastAgentId: mockSetLastAgentId,
lastProjectId: null as string | null,
setLastProjectId: mockSetLastProjectId,
prompt: "Persisted draft prompt",
setPrompt: mockSetPrompt,
clearPrompt: mockClearPrompt,
@@ -21,6 +24,14 @@ const mockQuickCreateStore = {
setKeepOpen: mockSetKeepOpen,
};
// Per-test override for the projects query, so tests can swap between
// "loaded as empty" (the deleted-project case) and "still loading" without
// re-mocking the whole module.
const mockProjectsQuery = vi.hoisted(() => ({
data: [] as Array<{ id: string; title: string; icon: string | null }>,
isSuccess: true,
}));
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
switch (queryKey[0]) {
@@ -32,6 +43,8 @@ vi.mock("@tanstack/react-query", () => ({
};
case "runtimes":
return { data: [{ id: "runtime-1", metadata: { cli_version: "1.2.3" } }] };
case "projects":
return mockProjectsQuery;
default:
return { data: [] };
}
@@ -60,6 +73,10 @@ vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({ queryKey: ["members"] }),
}));
vi.mock("@multica/core/projects/queries", () => ({
projectListOptions: () => ({ queryKey: ["projects"] }),
}));
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
@@ -211,8 +228,11 @@ describe("AgentCreatePanel", () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuickCreateStore.lastAgentId = null;
mockQuickCreateStore.lastProjectId = null;
mockQuickCreateStore.prompt = "Persisted draft prompt";
mockQuickCreateStore.keepOpen = false;
mockProjectsQuery.data = [];
mockProjectsQuery.isSuccess = true;
mockQuickCreateIssue.mockResolvedValue(undefined);
mockSetKeepOpen.mockImplementation((value: boolean) => {
mockQuickCreateStore.keepOpen = value;
@@ -249,12 +269,47 @@ describe("AgentCreatePanel", () => {
expect(mockQuickCreateIssue).toHaveBeenCalledWith({
agent_id: "agent-1",
prompt: "New agent prompt",
project_id: undefined,
});
});
expect(mockSetLastAgentId).toHaveBeenCalledWith("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);
expect(mockClearPrompt).toHaveBeenCalled();
expect(mockSetLastMode).toHaveBeenCalledWith("agent");
expect(onClose).toHaveBeenCalled();
});
// 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
// is missing, we clear BOTH local state and the persisted preference;
// dropping only local state would leave the next open re-seeding the same
// dead value and trigger the server's `project not found` rejection.
it("clears a stale persisted project once the projects list resolves without it", async () => {
mockQuickCreateStore.lastProjectId = "deleted-proj";
mockProjectsQuery.data = [];
mockProjectsQuery.isSuccess = true;
renderPanel({ onClose: vi.fn(), isExpanded: false, setIsExpanded: vi.fn() });
await waitFor(() => {
expect(mockSetLastProjectId).toHaveBeenCalledWith(null);
});
});
// Mirror case: while the query is still loading, we must NOT preemptively
// clear the persisted preference — that would wipe a perfectly valid
// selection on every open before the list ever renders.
it("keeps the persisted project while the projects list is still loading", () => {
mockQuickCreateStore.lastProjectId = "proj-1";
mockProjectsQuery.data = [];
mockProjectsQuery.isSuccess = false;
renderPanel({ onClose: vi.fn(), isExpanded: false, setIsExpanded: vi.fn() });
expect(mockSetLastProjectId).not.toHaveBeenCalled();
});
});

View File

@@ -17,6 +17,7 @@ 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 { projectListOptions } from "@multica/core/projects/queries";
import { useQuickCreateStore } 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";
@@ -30,6 +31,8 @@ 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 { 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 { useAuthStore } from "@multica/core/auth";
import { memberListOptions } from "@multica/core/workspace/queries";
@@ -49,8 +52,11 @@ import { useT } from "../i18n";
// animation flash — Base UI replays Popup enter/exit when DialogContent is
// remounted, even inside a still-open Dialog Root.
//
// `onSwitchMode` is wired by the shell — the panel calls it (no payload from
// agent → manual; the shared draft store carries description + agent).
// `onSwitchMode` is wired by the shell — the panel calls it with an optional
// carry payload (currently `project_id`). The shared draft store carries the
// description + agent across the agent→manual flip; project_id rides through
// the same carry channel manual→agent uses, so the manual panel reads it
// from `data?.project_id` without a parallel store.
export function AgentCreatePanel({
onClose,
onSwitchMode,
@@ -59,7 +65,7 @@ export function AgentCreatePanel({
setIsExpanded,
}: {
onClose: () => void;
onSwitchMode?: () => void;
onSwitchMode?: (carry?: Record<string, unknown> | null) => void;
data?: Record<string, unknown> | null;
/** Lifted to the shell so DialogContent's mode-aware className can react —
* same pattern as ManualCreatePanel. Shared across modes so the user's
@@ -73,6 +79,12 @@ export function AgentCreatePanel({
const userId = useAuthStore((s) => s.user?.id);
const { data: members = [] } = useQuery(memberListOptions(wsId));
const { data: agents = [] } = useQuery(agentListOptions(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.
const { data: projects = [], isSuccess: projectsLoaded } = useQuery(
projectListOptions(wsId),
);
const memberRole = useMemo(
() => members.find((m) => m.user_id === userId)?.role,
@@ -90,6 +102,8 @@ export function AgentCreatePanel({
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
const lastProjectId = useQuickCreateStore((s) => s.lastProjectId);
const setLastProjectId = useQuickCreateStore((s) => s.setLastProjectId);
const promptDraft = useQuickCreateStore((s) => s.prompt);
const setPrompt = useQuickCreateStore((s) => s.setPrompt);
const clearPrompt = useQuickCreateStore((s) => s.clearPrompt);
@@ -120,6 +134,28 @@ export function AgentCreatePanel({
[visibleAgents, agentId],
);
// 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
// replace the persisted default.
const [projectId, setProjectId] = useState<string | null>(() => {
const seed = (data?.project_id as string | undefined) ?? lastProjectId;
return seed ?? null;
});
// Stale-id sweep. Once the project list query has actually resolved
// (`isSuccess` — distinct from "data is the empty default during loading"),
// a `projectId` that isn't in the list means the project was deleted in
// another session. Clear BOTH local state and the persisted preference;
// dropping only local state would leave the deleted UUID in `lastProjectId`,
// and the next open would re-seed it and submit the same dead value.
useEffect(() => {
if (!projectsLoaded || projectId === null) return;
if (projects.some((p) => p.id === projectId)) return;
setProjectId(null);
if (lastProjectId === projectId) setLastProjectId(null);
}, [projectsLoaded, projects, projectId, lastProjectId, setLastProjectId]);
// Daemon CLI version gate. The agent-create flow needs the runtime's
// bundled multica CLI to be ≥ MIN_QUICK_CREATE_CLI_VERSION; older
// daemons handle attachments and partial-failure retries incorrectly
@@ -180,8 +216,13 @@ export function AgentCreatePanel({
setSubmitting(true);
setError(null);
try {
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
await api.quickCreateIssue({
agent_id: agentId,
prompt: md,
project_id: projectId ?? undefined,
});
setLastAgentId(agentId);
setLastProjectId(projectId);
clearPrompt();
setLastMode("agent");
toast.success(t(($) => $.create_issue.agent.toast_sent), {
@@ -253,7 +294,11 @@ export function AgentCreatePanel({
: {}),
});
setLastMode("manual");
onSwitchMode?.();
// Hand the picked project to the manual panel through the same `data`
// channel that already carries agent_id / parent_issue_id. The manual
// panel reads `data.project_id` on mount; this preserves the user's
// selection across the mode flip without piping a third store through.
onSwitchMode?.(projectId ? { project_id: projectId } : null);
};
return (
@@ -391,6 +436,21 @@ export function AgentCreatePanel({
<div className="px-5 pb-2 text-xs text-destructive">{error}</div>
)}
{/* Property toolbar — mirrors the manual panel's pill row so the
project pill sits in the same place across both modes. Agent mode
owns only the project (status / priority / assignee / due-date are
inferred from the prompt), so it's a single pill. The pick is
persisted per-workspace via useQuickCreateStore.lastProjectId so
users targeting one project skip retyping "in project X". */}
<div className="flex items-center gap-1.5 px-4 pb-2 shrink-0 flex-wrap">
<ProjectPicker
projectId={projectId}
onUpdate={(u) => setProjectId(u.project_id ?? null)}
triggerRender={<PillButton />}
align="start"
/>
</div>
{/* Footer */}
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
<div className="flex min-h-7 items-center gap-2">

View File

@@ -35,6 +35,7 @@ func TestQuickCreateCompletion_SubscribesRequester(t *testing.T) {
parseUUID(testUserID),
parseUUID(agentID),
"please file a bug",
pgtype.UUID{},
)
if err != nil {
t.Fatalf("EnqueueQuickCreateTask: %v", err)
@@ -106,6 +107,7 @@ func TestQuickCreateFailure_DoesNotSubscribeRequester(t *testing.T) {
parseUUID(testUserID),
parseUUID(agentID),
"another bug",
pgtype.UUID{},
)
if err != nil {
t.Fatalf("EnqueueQuickCreateTask: %v", err)

View File

@@ -76,8 +76,19 @@ func buildQuickCreatePrompt(task Task) string {
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee-id <your agent UUID>` (preferred) or `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
}
// fields to omit
b.WriteString("- **project**: omit. The platform will route the issue to the workspace default.\n")
// project — pinned by the modal when the user picked one, otherwise
// omitted so the platform routes to the workspace default. Always pass
// the UUID (never a name) so the issue lands in the right project even
// when several share a title.
if task.ProjectID != "" {
if task.ProjectTitle != "" {
fmt.Fprintf(&b, "- **project**: required for this run. Pass `--project %q` so the new issue lands in project %q (the user picked it in the quick-create modal). Do not infer a different project from the prompt text — the modal selection is authoritative.\n", task.ProjectID, task.ProjectTitle)
} else {
fmt.Fprintf(&b, "- **project**: required for this run. Pass `--project %q` so the new issue lands in the project the user picked in the quick-create modal. Do not infer a different project from the prompt text — the modal selection is authoritative.\n", task.ProjectID)
}
} else {
b.WriteString("- **project**: omit. The platform will route the issue to the workspace default.\n")
}
b.WriteString("- **status**: omit (defaults to `todo`).\n")
b.WriteString("- **attachments**: do NOT pass `--attachment`. The flag only accepts LOCAL file paths. Any image URL in the user input is already markdown — keep it inline in `--description` instead.\n\n")

View File

@@ -40,3 +40,39 @@ func TestBuildQuickCreatePromptRules(t *testing.T) {
}
}
}
// TestBuildQuickCreatePromptProjectPinning verifies that when the user
// pins a project in the quick-create modal, the prompt instructs the agent
// to pass `--project <uuid>` exactly. Without this, the agent would re-read
// the workspace default and silently drop the user's selection — the same
// "I have to retype 'in project X' every time" failure mode the modal
// addition was meant to fix.
func TestBuildQuickCreatePromptProjectPinning(t *testing.T) {
const projectID = "11111111-2222-3333-4444-555555555555"
out := buildQuickCreatePrompt(Task{
QuickCreatePrompt: "fix the login button color",
ProjectID: projectID,
ProjectTitle: "Web App",
})
mustContain := []string{
"--project \"" + projectID + "\"",
"Web App",
"modal selection is authoritative",
}
for _, s := range mustContain {
if !strings.Contains(out, s) {
t.Errorf("buildQuickCreatePrompt with project missing %q\n--- output ---\n%s", s, out)
}
}
// Without a project, the prompt must keep the legacy "omit" instruction
// so the agent doesn't accidentally start passing --project on plain
// quick-create runs.
plain := buildQuickCreatePrompt(Task{QuickCreatePrompt: "fix the login button color"})
if !strings.Contains(plain, "**project**: omit") {
t.Errorf("buildQuickCreatePrompt without project must keep the omit instruction, got:\n%s", plain)
}
if strings.Contains(plain, "--project") {
t.Errorf("buildQuickCreatePrompt without project must NOT mention --project, got:\n%s", plain)
}
}

View File

@@ -1231,7 +1231,54 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
hasQuickCreate = true
resp.QuickCreatePrompt = qc.Prompt
resp.WorkspaceID = qc.WorkspaceID
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(qc.WorkspaceID)); err == nil && ws.Repos != nil {
// When the user picked a project in the modal, surface its title
// and resources to the daemon so the agent has the same context
// it would for an issue-bound task: the prompt template can name
// the project, and `multica repo checkout` sees the project's
// github_repo resources instead of the workspace fallback.
var projectRepos []RepoData
if qc.ProjectID != "" {
projectUUID, err := util.ParseUUID(qc.ProjectID)
if err == nil {
resp.ProjectID = qc.ProjectID
if proj, err := h.Queries.GetProject(r.Context(), projectUUID); err == nil {
resp.ProjectTitle = proj.Title
}
if rows := h.listProjectResourcesForProject(r.Context(), projectUUID); len(rows) > 0 {
out := make([]ProjectResourceData, 0, len(rows))
for _, row := range rows {
label := ""
if row.Label.Valid {
label = row.Label.String
}
ref := json.RawMessage(row.ResourceRef)
if len(ref) == 0 {
ref = json.RawMessage("{}")
}
out = append(out, ProjectResourceData{
ID: uuidToString(row.ID),
ResourceType: row.ResourceType,
ResourceRef: ref,
Label: label,
})
if row.ResourceType == "github_repo" {
var payload struct {
URL string `json:"url"`
}
if json.Unmarshal(row.ResourceRef, &payload) == nil && payload.URL != "" {
projectRepos = append(projectRepos, RepoData{URL: payload.URL})
}
}
}
resp.ProjectResources = out
}
}
}
if len(projectRepos) > 0 {
resp.Repos = projectRepos
} else if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(qc.WorkspaceID)); err == nil && ws.Repos != nil {
var repos []RepoData
if json.Unmarshal(ws.Repos, &repos) == nil && len(repos) > 0 {
resp.Repos = repos

View File

@@ -857,9 +857,15 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
// 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.
//
// 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"`
Prompt string `json:"prompt"`
AgentID string `json:"agent_id"`
Prompt string `json:"prompt"`
ProjectID string `json:"project_id,omitempty"`
}
// QuickCreateIssueResponse echoes the queued task id so the frontend can
@@ -945,7 +951,27 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt)
// Optional project_id — validate it belongs to the same workspace before
// pinning the task to it. The handler is the trust boundary; the frontend
// already only shows projects from the active workspace, but we re-check
// here so a forged request can't smuggle a foreign project ID through.
var projectUUID pgtype.UUID
if strings.TrimSpace(req.ProjectID) != "" {
pid, ok := parseUUIDOrBadRequest(w, req.ProjectID, "project_id")
if !ok {
return
}
if _, err := h.Queries.GetProjectInWorkspace(r.Context(), db.GetProjectInWorkspaceParams{
ID: pid,
WorkspaceID: wsUUID,
}); err != nil {
writeError(w, http.StatusBadRequest, "project not found")
return
}
projectUUID = pid
}
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, 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

@@ -459,11 +459,17 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
// context column. The daemon detects this variant via Type == "quick_create"
// and switches to the quick-create prompt template; the completion path
// uses RequesterID + WorkspaceID to write the inbox notification.
//
// ProjectID is the optional project the user picked in the modal. When
// 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.
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"`
}
// QuickCreateContextType marks a task as a quick-create job.
@@ -475,7 +481,11 @@ const QuickCreateContextType = "quick_create"
// `multica issue create` call. Pre-validates that the agent is reachable
// (not archived, has a runtime) so the API can reject up-front rather than
// queue a task no one will ever claim.
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID pgtype.UUID, prompt string) (db.AgentTaskQueue, error) {
//
// 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) {
agent, err := s.Queries.GetAgent(ctx, agentID)
if err != nil {
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
@@ -493,6 +503,9 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
RequesterID: util.UUIDToString(requesterID),
WorkspaceID: util.UUIDToString(workspaceID),
}
if projectID.Valid {
payload.ProjectID = util.UUIDToString(projectID)
}
contextJSON, err := json.Marshal(payload)
if err != nil {
return db.AgentTaskQueue{}, fmt.Errorf("marshal quick-create context: %w", err)
@@ -513,6 +526,7 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
"agent_id", util.UUIDToString(agentID),
"requester_id", util.UUIDToString(requesterID),
"workspace_id", util.UUIDToString(workspaceID),
"project_id", payload.ProjectID,
)
// Match every other Enqueue* path: kick the daemon WS so the task
// gets claimed promptly instead of waiting for the next 30 s poll