mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-30 19:09:27 +02:00
Compare commits
5 Commits
agent/lamb
...
agent/j/0c
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7d4ed135ab | ||
|
|
53e6a81e2a | ||
|
|
a0172ec6c2 | ||
|
|
4c8d20221b | ||
|
|
1a7c0c33a8 |
@@ -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),
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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: "" }),
|
||||
|
||||
@@ -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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 } : {}),
|
||||
});
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user