Compare commits

...

7 Commits

Author SHA1 Message Date
Jiang Bohan
565d45cdcc fix(editor): keep blank-line paste inside the code block
Pasting `line1\n\nline2` while the caret was inside a code block ran the
text through the Markdown parser, which split on the blank line and tore
the code block open, dropping the trailing content into a sibling
paragraph.

Detect the codeBlock parent on `handlePaste` and insert the clipboard
text verbatim instead. Code blocks have `code: true`, so newlines stay
literal — exactly what users expect when pasting code or logs.

Closes #1982
2026-05-04 21:06:10 +08:00
ASDFGHoney
cb078c0f36 fix(core): patch byIssue label cache on WS label change (#2048)
`onIssueLabelsChanged` patched the embedded `labels` field in the
issue list and detail caches but never touched `labelKeys.byIssue`,
the cache backing the issue-detail Properties LabelPicker. Mutations
already covered all three caches; WS-driven changes (agents, other
tabs) left the picker stale until remount, since `staleTime: Infinity`
plus `refetchOnWindowFocus: false` prevent recovery on focus.
2026-05-04 20:51:02 +08:00
ayakabot
e13e5edc8e fix(issues): trimEnd comparison on blur to avoid unnecessary updates (#2054)
Fixed: #2053
2026-05-04 20:50:39 +08:00
Manu
fee393df1f fix(views): show full repo URLs in project creation (#2045) 2026-05-04 20:50:17 +08:00
ayakabot
1ff4e27e77 feat(quick-create): cache agent prompt draft across navigation (#2039)
When creating an issue with agent, the input content was lost when
navigating away (e.g., to view a ticket) and returning. Manual create
already persisted its draft - now agent create does too.

Changes:
- Add prompt field to useQuickCreateStore (persisted with workspace)
- AgentCreatePanel reads initial prompt from draft store if no transient
  data.prompt is provided
- onUpdate now saves prompt to draft store (not just hasContent)
- clearPrompt() called after successful submit

Fixes: #1957
2026-05-04 00:03:27 +02:00
Jiayuan Zhang
fbf9460d5e feat(chat): support fullscreen expand mode (#2043)
* feat(chat): support fullscreen mode similar to Linear

When the expand button is clicked, the chat window now fills the entire
content area (inset-0) instead of scaling to 90% of parent. Resize
handles are hidden in fullscreen mode.

* fix(chat): use stacked card layout for fullscreen mode

Fullscreen chat now uses inset-3 with rounded corners, ring, and shadow
to create a stacked card effect on top of the content area — matching
the Linear design — instead of a flush inset-0 fill.

* feat(chat): add motion.dev spring animations for expand/collapse

- Install `motion` in @multica/views
- Replace CSS transitions with motion.div layout animation for
  expand/collapse (spring-based FLIP), giving a natural bouncy feel
- Open/close uses spring scale + smooth opacity fade
- Layout animations are disabled during drag-to-resize (instant updates)

* fix(chat): remove spring bounce from expand/collapse animation

Use critically damped springs (bounce: 0) so the animation settles
directly at its target without overshooting.

* fix(chat): fix text distortion during expand/collapse animation

Use layout="position" instead of layout (full FLIP). Full FLIP uses
scale transforms to animate size changes, which distorts text and
child content. Position-only layout animates translate only — size
changes are instant, text stays crisp.

* fix: regenerate lockfile with pnpm@10.28.2

The lockfile was previously generated with pnpm 10.12.4, causing
unrelated churn (lost libc constraints, deprecated metadata). Reset
to main and regenerated with the repo's pinned pnpm@10.28.2 so
the diff is scoped to the new motion dependency only.
2026-05-03 22:56:22 +02:00
Jiayuan Zhang
d492b9d7a6 Revert "feat(quick-create): add preset issue fields (#2002)" (#2042)
This reverts commit a039c4d803.
2026-05-03 20:02:40 +02:00
23 changed files with 945 additions and 453 deletions

View File

@@ -426,7 +426,7 @@ export class ApiClient {
});
}
async quickCreateIssue(data: { agent_id: string; prompt: string; priority?: string; due_date?: string; project_id?: string }): Promise<{ task_id: string }> {
async quickCreateIssue(data: { agent_id: string; prompt: string }): Promise<{ task_id: string }> {
return this.fetch("/api/issues/quick-create", {
method: "POST",
body: JSON.stringify(data),

View File

@@ -0,0 +1,26 @@
import { beforeEach, describe, expect, it } from "vitest";
import { useQuickCreateStore } from "./quick-create-store";
const RESET_STATE = {
lastAgentId: null,
prompt: "",
keepOpen: false,
};
describe("quick create store", () => {
beforeEach(() => {
useQuickCreateStore.setState(RESET_STATE);
});
it("persists the agent prompt draft until explicitly cleared", () => {
const { setPrompt, clearPrompt } = useQuickCreateStore.getState();
setPrompt("Investigate the inbox loading regression");
expect(useQuickCreateStore.getState().prompt).toBe(
"Investigate the inbox loading regression",
);
clearPrompt();
expect(useQuickCreateStore.getState().prompt).toBe("");
});
});

View File

@@ -15,6 +15,9 @@ import { defaultStorage } from "../../platform/storage";
interface QuickCreateState {
lastAgentId: string | null;
setLastAgentId: (id: string | null) => void;
prompt: string;
setPrompt: (prompt: string) => void;
clearPrompt: () => void;
keepOpen: boolean;
setKeepOpen: (v: boolean) => void;
}
@@ -24,6 +27,9 @@ export const useQuickCreateStore = create<QuickCreateState>()(
(set) => ({
lastAgentId: null,
setLastAgentId: (id) => set({ lastAgentId: id }),
prompt: "",
setPrompt: (prompt) => set({ prompt }),
clearPrompt: () => set({ prompt: "" }),
keepOpen: false,
setKeepOpen: (v) => set({ keepOpen: v }),
}),

View File

@@ -0,0 +1,95 @@
import { beforeEach, describe, expect, it } from "vitest";
import { QueryClient } from "@tanstack/react-query";
import { onIssueLabelsChanged } from "./ws-updaters";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import type {
Issue,
IssueLabelsResponse,
Label,
ListIssuesCache,
} from "../types";
const WS_ID = "ws-1";
const ISSUE_ID = "issue-1";
const labelA: Label = {
id: "label-a",
workspace_id: WS_ID,
name: "bug",
color: "#ef4444",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const labelB: Label = {
id: "label-b",
workspace_id: WS_ID,
name: "feature",
color: "#22c55e",
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
const baseIssue: Issue = {
id: ISSUE_ID,
workspace_id: WS_ID,
number: 1,
identifier: "MUL-1",
title: "Test",
description: null,
status: "todo",
priority: "none",
assignee_type: null,
assignee_id: null,
creator_type: "member",
creator_id: "user-1",
parent_issue_id: null,
project_id: null,
position: 0,
due_date: null,
labels: [labelA],
created_at: "2025-01-01T00:00:00Z",
updated_at: "2025-01-01T00:00:00Z",
};
describe("onIssueLabelsChanged", () => {
let qc: QueryClient;
beforeEach(() => {
qc = new QueryClient();
});
it("patches the per-issue label cache when present (LabelPicker source)", () => {
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID), {
labels: [labelA],
});
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(
qc.getQueryData<IssueLabelsResponse>(labelKeys.byIssue(WS_ID, ISSUE_ID)),
).toEqual({ labels: [labelB] });
});
it("leaves the per-issue label cache untouched when the picker has not fetched", () => {
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
expect(qc.getQueryData(labelKeys.byIssue(WS_ID, ISSUE_ID))).toBeUndefined();
});
it("still patches the list and detail caches", () => {
qc.setQueryData<ListIssuesCache>(issueKeys.list(WS_ID), {
byStatus: { todo: { issues: [baseIssue], total: 1 } },
});
qc.setQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID), baseIssue);
onIssueLabelsChanged(qc, WS_ID, ISSUE_ID, [labelB]);
const list = qc.getQueryData<ListIssuesCache>(issueKeys.list(WS_ID));
expect(list?.byStatus.todo?.issues[0]?.labels).toEqual([labelB]);
const detail = qc.getQueryData<Issue>(issueKeys.detail(WS_ID, ISSUE_ID));
expect(detail?.labels).toEqual([labelB]);
});
});

View File

@@ -1,12 +1,13 @@
import type { QueryClient } from "@tanstack/react-query";
import { issueKeys } from "./queries";
import { labelKeys } from "../labels/queries";
import {
addIssueToBuckets,
findIssueLocation,
patchIssueInBuckets,
removeIssueFromBuckets,
} from "./cache-helpers";
import type { Issue, Label } from "../types";
import type { Issue, IssueLabelsResponse, Label } from "../types";
import type { ListIssuesCache } from "../types";
export function onIssueCreated(
@@ -73,9 +74,15 @@ export function onIssueUpdated(
}
/**
* Patch an issue's `labels` field in-place across the list cache, my-issues
* caches, and the detail cache. Triggered by the `issue_labels:changed` WS
* event after attach/detach so list/board chips update without a refetch.
* Patch an issue's labels in-place across the list cache, my-issues caches,
* the detail cache, and the per-issue label cache. Triggered by the
* `issue_labels:changed` WS event after attach/detach so list/board chips
* and the issue-detail Properties LabelPicker update without a refetch.
*
* The byIssue cache backs `LabelPicker`; without patching it, externally
* driven label changes (agents, other tabs) leave the picker stale until it
* remounts — `staleTime: Infinity` + `refetchOnWindowFocus: false` (see
* `query-client.ts`) means focus changes won't recover it.
*/
export function onIssueLabelsChanged(
qc: QueryClient,
@@ -89,6 +96,9 @@ export function onIssueLabelsChanged(
qc.setQueryData<Issue>(issueKeys.detail(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.setQueryData<IssueLabelsResponse>(labelKeys.byIssue(wsId, issueId), (old) =>
old ? { ...old, labels } : old,
);
qc.invalidateQueries({ queryKey: issueKeys.myAll(wsId) });
}

View File

@@ -2,6 +2,7 @@
import React, { useCallback, useEffect, useMemo, useRef } from "react";
import { useQuery, useQueryClient } from "@tanstack/react-query";
import { motion } from "motion/react";
import { Minus, Maximize2, Minimize2, ChevronDown, Plus, Check } from "lucide-react";
import { Button } from "@multica/ui/components/ui/button";
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
@@ -334,6 +335,8 @@ export function ChatWindow() {
setOpen(false);
}, [activeSessionId, pendingTaskId, setOpen]);
const isExpanded = useChatStore((s) => s.isExpanded);
const windowRef = useRef<HTMLDivElement>(null);
const { renderWidth, renderHeight, isAtMax, boundsReady, isDragging, toggleExpand, startDrag } = useChatResize(windowRef);
@@ -341,24 +344,37 @@ export function ChatWindow() {
// a real message, or a pending task whose timeline will stream in.
const hasMessages = messages.length > 0 || !!pendingTaskId;
const isVisible = isOpen && boundsReady;
const isVisible = isOpen && (isExpanded || boundsReady);
const containerClass = "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerClass = isExpanded
? "absolute inset-3 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden"
: "absolute bottom-2 right-2 z-50 flex flex-col rounded-xl ring-1 ring-foreground/10 bg-sidebar shadow-2xl overflow-hidden";
const containerStyle: React.CSSProperties = {
width: `${renderWidth}px`,
height: `${renderHeight}px`,
opacity: isVisible ? 1 : 0,
transform: isVisible ? "scale(1)" : "scale(0.95)",
...(!isExpanded ? { width: renderWidth, height: renderHeight } : {}),
transformOrigin: "bottom right",
pointerEvents: isOpen ? "auto" : "none",
transition: isDragging
? "none"
: "width 200ms ease-out, height 200ms ease-out, opacity 150ms ease-out, transform 150ms ease-out",
};
return (
<div ref={windowRef} className={containerClass} style={containerStyle}>
<ChatResizeHandles onDragStart={startDrag} />
<motion.div
ref={windowRef}
className={containerClass}
style={containerStyle}
layout="position"
initial={{ opacity: 0, scale: 0.95 }}
animate={{
opacity: isVisible ? 1 : 0,
scale: isVisible ? 1 : 0.95,
}}
transition={{
layout: isDragging
? { duration: 0 }
: { type: "spring", duration: 0.3, bounce: 0 },
opacity: { duration: 0.15 },
scale: { type: "spring", duration: 0.2, bounce: 0 },
}}
>
{!isExpanded && <ChatResizeHandles onDragStart={startDrag} />}
{/* Header — ⊕ new + session dropdown | window tools */}
<div className="flex items-center justify-between border-b px-4 py-2.5 gap-2">
<div className="flex items-center gap-1 min-w-0">
@@ -398,10 +414,10 @@ export function ChatWindow() {
/>
}
>
{isAtMax ? <Minimize2 /> : <Maximize2 />}
{isExpanded || isAtMax ? <Minimize2 /> : <Maximize2 />}
</TooltipTrigger>
<TooltipContent side="top">
{isAtMax ? "Restore" : "Expand"}
{isExpanded || isAtMax ? "Restore" : "Fullscreen"}
</TooltipContent>
</Tooltip>
<Tooltip>
@@ -474,7 +490,7 @@ export function ChatWindow() {
}
rightAdornment={<ContextAnchorButton />}
/>
</div>
</motion.div>
);
}

View File

@@ -157,7 +157,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
// Explicit for clarity — the real perf win is useEditorState in BubbleMenu.
shouldRerenderOnTransaction: false,
onCreate: ({ editor: ed }) => {
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown());
lastEmittedRef.current = stripBlobUrls(ed.getMarkdown()).trimEnd();
},
content: defaultValue ? preprocessMarkdown(defaultValue) : "",
contentType: defaultValue ? "markdown" : undefined,
@@ -173,7 +173,7 @@ const ContentEditor = forwardRef<ContentEditorRef, ContentEditorProps>(
if (!onUpdateRef.current) return;
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => {
const md = stripBlobUrls(ed.getMarkdown());
const md = stripBlobUrls(ed.getMarkdown()).trimEnd();
if (md === lastEmittedRef.current) return;
lastEmittedRef.current = md;
onUpdateRef.current?.(md);

View File

@@ -0,0 +1,130 @@
import { describe, it, expect, afterEach } from "vitest";
import { Editor } from "@tiptap/core";
import StarterKit from "@tiptap/starter-kit";
import { Markdown } from "@tiptap/markdown";
import { createMarkdownPasteExtension } from "./markdown-paste";
interface FakeClipboard {
files: never[];
getData: (type: string) => string;
}
function fakePasteEvent(text: string, html?: string) {
const data: FakeClipboard = {
files: [],
getData: (type) =>
type === "text/plain" ? text : type === "text/html" ? (html ?? "") : "",
};
return {
clipboardData: data,
preventDefault: () => {},
} as unknown as ClipboardEvent;
}
function makeEditor(content: object) {
const element = document.createElement("div");
document.body.appendChild(element);
return new Editor({
element,
extensions: [StarterKit, Markdown, createMarkdownPasteExtension()],
content,
});
}
function paste(editor: Editor, text: string, html?: string): boolean {
const event = fakePasteEvent(text, html);
return (
editor.view.someProp("handlePaste", (handler) =>
handler(editor.view, event, editor.view.state.selection.content()),
) === true
);
}
interface JsonNode {
type: string;
text?: string;
content?: JsonNode[];
}
function findFirst(json: JsonNode, type: string): JsonNode | undefined {
if (json.type === type) return json;
for (const child of json.content ?? []) {
const hit = findFirst(child, type);
if (hit) return hit;
}
return undefined;
}
function nodeText(node: JsonNode): string {
if (node.text !== undefined) return node.text;
return (node.content ?? []).map(nodeText).join("");
}
describe("markdownPaste — code block context", () => {
let editor: Editor | null = null;
afterEach(() => {
editor?.destroy();
editor = null;
document.body.innerHTML = "";
});
it("preserves blank lines when pasting into a code block (#1982)", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "codeBlock", content: [{ type: "text", text: "x" }] }],
});
// Place caret after "x" inside the code block.
editor.commands.setTextSelection(2);
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
const handled = paste(editor, "line1\n\nline2");
expect(handled).toBe(true);
const json = editor.getJSON() as JsonNode;
const codeBlock = findFirst(json, "codeBlock");
expect(codeBlock).toBeDefined();
// Code block content is preserved verbatim — blank line stays inside.
expect(nodeText(codeBlock!)).toBe("xline1\n\nline2");
// No paragraph leaked out carrying any of the pasted text.
const leakedParagraph = (json.content ?? []).find(
(n) => n.type === "paragraph" && nodeText(n).length > 0,
);
expect(leakedParagraph).toBeUndefined();
});
it("preserves fence characters pasted into a code block", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "codeBlock", content: [] }],
});
editor.commands.setTextSelection(1);
expect(editor.state.selection.$from.parent.type.name).toBe("codeBlock");
paste(editor, "```\nhello\n```");
const json = editor.getJSON() as JsonNode;
const codeBlock = findFirst(json, "codeBlock");
expect(codeBlock).toBeDefined();
expect(nodeText(codeBlock!)).toBe("```\nhello\n```");
});
it("still parses Markdown when pasting into a regular paragraph", () => {
editor = makeEditor({
type: "doc",
content: [{ type: "paragraph" }],
});
editor.commands.setTextSelection(1);
expect(editor.state.selection.$from.parent.type.name).toBe("paragraph");
paste(editor, "# Heading\n\nbody");
const json = editor.getJSON() as JsonNode;
const types = (json.content ?? []).map((n) => n.type);
// Markdown parsing produced a heading at the top.
expect(types).toContain("heading");
});
});

View File

@@ -46,6 +46,16 @@ export function createMarkdownPasteExtension() {
const text = clipboard.getData("text/plain");
if (!text) return false;
// If the caret is inside a code block, insert the text as-is.
// Code blocks must keep newlines literal; running Markdown
// parsing here would split a blank line (\n\n) into two
// paragraphs and tear the code block open. (#1982)
const { $from } = view.state.selection;
if ($from.parent.type.name === "codeBlock") {
view.dispatch(view.state.tr.insertText(text));
return true;
}
const html = clipboard.getData("text/html");
// If HTML contains data-pm-slice, the source is another

View File

@@ -0,0 +1,168 @@
import React from "react";
import { describe, expect, it, vi } from "vitest";
import { render, screen } from "@testing-library/react";
const longRepoUrl =
"https://github.com/multica-ai/a-very-long-repository-name-that-needs-a-tooltip";
vi.mock("@tanstack/react-query", () => ({
useQuery: () => ({ data: [] }),
}));
vi.mock("@multica/core/projects/mutations", () => ({
useCreateProject: () => ({ mutateAsync: vi.fn() }),
}));
vi.mock("@multica/core/projects", () => ({
useProjectDraftStore: (selector: (state: unknown) => unknown) =>
selector({
draft: {
title: "",
description: "",
status: "planned",
priority: "medium",
leadType: undefined,
leadId: undefined,
icon: undefined,
},
setDraft: vi.fn(),
clearDraft: vi.fn(),
}),
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "workspace-1",
}));
vi.mock("@multica/core/paths", () => ({
useCurrentWorkspace: () => ({
id: "workspace-1",
name: "Test Workspace",
slug: "test-workspace",
repos: [{ url: longRepoUrl }],
}),
useWorkspacePaths: () => ({
projectDetail: (id: string) => `/test-workspace/projects/${id}`,
}),
}));
vi.mock("@multica/core/workspace/queries", () => ({
memberListOptions: () => ({ queryKey: ["members"], queryFn: vi.fn() }),
agentListOptions: () => ({ queryKey: ["agents"], queryFn: vi.fn() }),
}));
vi.mock("@multica/core/workspace/hooks", () => ({
useActorName: () => ({ getActorName: vi.fn() }),
}));
vi.mock("../navigation", () => ({
useNavigation: () => ({ push: vi.fn() }),
}));
vi.mock("../editor", () => {
const ContentEditor = React.forwardRef<HTMLTextAreaElement, { placeholder?: string }>(
({ placeholder }, ref) => <textarea ref={ref} placeholder={placeholder} />,
);
ContentEditor.displayName = "ContentEditor";
return {
ContentEditor,
TitleEditor: ({
placeholder,
onChange,
}: {
placeholder?: string;
onChange?: (value: string) => void;
}) => <input placeholder={placeholder} onChange={(e) => onChange?.(e.target.value)} />,
};
});
vi.mock("../issues/components/priority-icon", () => ({
PriorityIcon: () => <span data-testid="priority-icon" />,
}));
vi.mock("../common/actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));
vi.mock("@multica/ui/components/ui/dialog", () => ({
Dialog: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
DialogTitle: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@multica/ui/components/ui/dropdown-menu", () => ({
DropdownMenu: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
DropdownMenuContent: ({ children }: { children: React.ReactNode }) => <>{children}</>,
DropdownMenuItem: ({
children,
onClick,
}: {
children: React.ReactNode;
onClick?: () => void;
}) => (
<button type="button" onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/ui/popover", () => ({
Popover: ({ children }: { children: React.ReactNode }) => <>{children}</>,
PopoverTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
PopoverContent: ({ children }: { children: React.ReactNode }) => <div>{children}</div>,
}));
vi.mock("@multica/ui/components/ui/tooltip", () => ({
Tooltip: ({ children }: { children: React.ReactNode }) => <>{children}</>,
TooltipTrigger: ({ render }: { render: React.ReactNode }) => <>{render}</>,
TooltipContent: ({ children }: { children: React.ReactNode }) => (
<div role="tooltip">{children}</div>
),
}));
vi.mock("@multica/ui/components/ui/button", () => ({
Button: ({
children,
disabled,
onClick,
type = "button",
}: {
children: React.ReactNode;
disabled?: boolean;
onClick?: () => void;
type?: "button" | "submit" | "reset";
}) => (
<button type={type} disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/common/emoji-picker", () => ({
EmojiPicker: () => null,
}));
vi.mock("@multica/ui/lib/utils", () => ({
cn: (...values: Array<string | false | null | undefined>) =>
values.filter(Boolean).join(" "),
}));
vi.mock("sonner", () => ({
toast: {
success: vi.fn(),
error: vi.fn(),
},
}));
import { CreateProjectModal } from "./create-project";
describe("CreateProjectModal", () => {
it("exposes full repository URLs in the repository picker", () => {
render(<CreateProjectModal onClose={vi.fn()} />);
expect(screen.getByTitle(longRepoUrl)).toHaveTextContent(longRepoUrl);
expect(screen.getByRole("tooltip", { name: longRepoUrl })).toBeInTheDocument();
});
});

View File

@@ -73,6 +73,32 @@ function PillButton({
);
}
function RepoUrlText({
url,
className,
}: {
url: string;
className?: string;
}) {
return (
<Tooltip>
<TooltipTrigger
render={
<span
title={url}
className={cn("truncate flex-1 text-left", className)}
>
{url}
</span>
}
/>
<TooltipContent side="top" align="start" className="max-w-sm break-all">
{url}
</TooltipContent>
</Tooltip>
);
}
export function CreateProjectModal({ onClose }: { onClose: () => void }) {
const router = useNavigation();
const workspace = useCurrentWorkspace();
@@ -446,7 +472,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
className="size-3.5"
/>
<GithubIcon className="size-3.5" />
<span className="truncate flex-1 text-left">{repo.url}</span>
<RepoUrlText url={repo.url} />
</button>
);
})}
@@ -492,7 +518,7 @@ export function CreateProjectModal({ onClose }: { onClose: () => void }) {
className="flex items-center gap-2 text-xs"
>
<GithubIcon className="size-3 text-muted-foreground" />
<span className="truncate flex-1">{url}</span>
<RepoUrlText url={url} />
<button
type="button"
onClick={() => toggleRepo(url)}

View File

@@ -0,0 +1,247 @@
import { forwardRef, useImperativeHandle, useRef, useState, type ReactNode } from "react";
import { describe, it, expect, vi, beforeEach } from "vitest";
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 mockSetPrompt = vi.hoisted(() => vi.fn());
const mockClearPrompt = vi.hoisted(() => vi.fn());
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
const mockSetLastMode = vi.hoisted(() => vi.fn());
const mockToastSuccess = vi.hoisted(() => vi.fn());
const mockQuickCreateStore = {
lastAgentId: null as string | null,
setLastAgentId: mockSetLastAgentId,
prompt: "Persisted draft prompt",
setPrompt: mockSetPrompt,
clearPrompt: mockClearPrompt,
keepOpen: false,
setKeepOpen: mockSetKeepOpen,
};
vi.mock("@tanstack/react-query", () => ({
useQuery: ({ queryKey }: { queryKey: string[] }) => {
switch (queryKey[0]) {
case "members":
return { data: [{ user_id: "user-1", role: "admin" }] };
case "agents":
return {
data: [{ id: "agent-1", name: "Bohan", archived_at: null, runtime_id: "runtime-1" }],
};
case "runtimes":
return { data: [{ id: "runtime-1", metadata: { cli_version: "1.2.3" } }] };
default:
return { data: [] };
}
},
}));
vi.mock("@multica/core/api", () => ({
api: {
quickCreateIssue: mockQuickCreateIssue,
},
ApiError: class ApiError extends Error {
body?: unknown;
},
}));
vi.mock("@multica/core/hooks", () => ({
useWorkspaceId: () => "ws-test",
}));
vi.mock("@multica/core/paths", () => ({
useCurrentWorkspace: () => ({ name: "Test Workspace" }),
}));
vi.mock("@multica/core/workspace/queries", () => ({
agentListOptions: () => ({ queryKey: ["agents"] }),
memberListOptions: () => ({ queryKey: ["members"] }),
}));
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
}));
vi.mock("@multica/core/issues/stores/create-mode-store", () => ({
useCreateModeStore: (selector?: (state: { setLastMode: typeof mockSetLastMode }) => unknown) =>
(selector ? selector({ setLastMode: mockSetLastMode }) : { setLastMode: mockSetLastMode }),
}));
vi.mock("@multica/core/auth", () => ({
useAuthStore: (selector?: (state: { user: { id: string } }) => unknown) =>
(selector ? selector({ user: { id: "user-1" } }) : { user: { id: "user-1" } }),
}));
vi.mock("@multica/core/runtimes", () => ({
runtimeListOptions: () => ({ queryKey: ["runtimes"] }),
checkQuickCreateCliVersion: () => ({ state: "ok", min: "1.0.0" }),
readRuntimeCliVersion: () => "1.2.3",
MIN_QUICK_CREATE_CLI_VERSION: "1.0.0",
}));
vi.mock("@multica/core/hooks/use-file-upload", () => ({
useFileUpload: () => ({ uploadWithToast: vi.fn(), uploading: false }),
}));
vi.mock("../issues/components/pickers/assignee-picker", () => ({
canAssignAgent: () => true,
}));
vi.mock("../common/actor-avatar", () => ({
ActorAvatar: () => <span data-testid="actor-avatar" />,
}));
vi.mock("../issues/components", () => ({
PriorityPicker: () => <div data-testid="priority-picker" />,
DueDatePicker: () => <div data-testid="due-date-picker" />,
}));
vi.mock("../projects/components/project-picker", () => ({
ProjectPicker: () => <div data-testid="project-picker" />,
}));
vi.mock("../common/pill-button", () => ({
PillButton: () => <div data-testid="pill-button" />,
}));
vi.mock("../editor", () => {
const ContentEditor = forwardRef(({ defaultValue, onUpdate, onSubmit, placeholder }: any, ref: any) => {
const valueRef = useRef(defaultValue || "");
const [value, setValue] = useState(defaultValue || "");
useImperativeHandle(ref, () => ({
getMarkdown: () => valueRef.current,
clearContent: () => {
valueRef.current = "";
setValue("");
},
uploadFile: vi.fn(),
focus: vi.fn(),
}));
return (
<textarea
value={value}
placeholder={placeholder}
onChange={(e) => {
valueRef.current = e.target.value;
setValue(e.target.value);
onUpdate?.(e.target.value);
}}
onKeyDown={(e) => {
if ((e.metaKey || e.ctrlKey) && e.key === "Enter") {
onSubmit?.();
}
}}
/>
);
});
ContentEditor.displayName = "ContentEditor";
return {
ContentEditor,
useFileDropZone: () => ({ isDragOver: false, dropZoneProps: {} }),
FileDropOverlay: () => null,
};
});
vi.mock("@multica/ui/components/ui/dialog", () => ({
DialogTitle: ({ children, className }: { children: ReactNode; className?: string }) => (
<div className={className}>{children}</div>
),
}));
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("@multica/ui/components/ui/button", () => ({
Button: ({ children, disabled, onClick }: { children: ReactNode; disabled?: boolean; onClick?: () => void }) => (
<button type="button" disabled={disabled} onClick={onClick}>
{children}
</button>
),
}));
vi.mock("@multica/ui/components/ui/switch", () => ({
Switch: ({ checked, onCheckedChange }: { checked: boolean; onCheckedChange: (v: boolean) => void }) => (
<input
aria-label="Create another"
type="checkbox"
checked={checked}
onChange={(e) => onCheckedChange(e.target.checked)}
/>
),
}));
vi.mock("@multica/ui/components/common/file-upload-button", () => ({
FileUploadButton: () => <button type="button">Upload file</button>,
}));
vi.mock("sonner", () => ({
toast: {
success: mockToastSuccess,
},
}));
import { AgentCreatePanel } from "./quick-create-issue";
describe("AgentCreatePanel", () => {
beforeEach(() => {
vi.clearAllMocks();
mockQuickCreateStore.lastAgentId = null;
mockQuickCreateStore.prompt = "Persisted draft prompt";
mockQuickCreateStore.keepOpen = false;
mockQuickCreateIssue.mockResolvedValue(undefined);
mockSetKeepOpen.mockImplementation((value: boolean) => {
mockQuickCreateStore.keepOpen = value;
});
});
it("loads the persisted prompt draft when no transient prompt is provided", () => {
render(<AgentCreatePanel onClose={vi.fn()} />);
expect(
screen.getByPlaceholderText(
'Tell the agent what to do, e.g. "let Bohan fix the inbox loading slowness in the Web project"',
),
).toHaveValue("Persisted draft prompt");
});
it("writes prompt changes back to the draft store and clears them after submit", async () => {
const user = userEvent.setup();
const onClose = vi.fn();
render(<AgentCreatePanel onClose={onClose} />);
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, "New agent prompt");
expect(mockSetPrompt).toHaveBeenLastCalledWith("New agent prompt");
await user.click(screen.getByRole("button", { name: /Create \(⌘↵\)/i }));
await waitFor(() => {
expect(mockQuickCreateIssue).toHaveBeenCalledWith({
agent_id: "agent-1",
prompt: "New agent prompt",
});
});
expect(mockSetLastAgentId).toHaveBeenCalledWith("agent-1");
expect(mockClearPrompt).toHaveBeenCalled();
expect(mockSetLastMode).toHaveBeenCalledWith("agent");
expect(onClose).toHaveBeenCalled();
});
});

View File

@@ -16,7 +16,7 @@ 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, memberListOptions } from "@multica/core/workspace/queries";
import { agentListOptions } from "@multica/core/workspace/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";
@@ -27,12 +27,11 @@ import {
MIN_QUICK_CREATE_CLI_VERSION,
} from "@multica/core/runtimes";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import type { Agent, IssuePriority } from "@multica/core/types";
import type { Agent } from "@multica/core/types";
import { ActorAvatar } from "../common/actor-avatar";
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
import { PriorityPicker, DueDatePicker } from "../issues/components";
import { ProjectPicker } from "../projects/components/project-picker";
import { useAuthStore } from "@multica/core/auth";
import { memberListOptions } from "@multica/core/workspace/queries";
import {
ContentEditor,
type ContentEditorRef,
@@ -40,7 +39,6 @@ import {
FileDropOverlay,
} from "../editor";
import { FileUploadButton } from "@multica/ui/components/common/file-upload-button";
import { PillButton } from "../common/pill-button";
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
@@ -82,6 +80,9 @@ export function AgentCreatePanel({
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
const promptDraft = useQuickCreateStore((s) => s.prompt);
const setPrompt = useQuickCreateStore((s) => s.setPrompt);
const clearPrompt = useQuickCreateStore((s) => s.clearPrompt);
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
@@ -129,7 +130,7 @@ export function AgentCreatePanel({
);
const versionBlocked = versionCheck.state !== "ok";
const initialPrompt = (data?.prompt as string) || "";
const initialPrompt = (data?.prompt as string) || promptDraft;
// The editor is uncontrolled — we read the latest markdown via the ref at
// submit/switch time. `hasContent` mirrors emptiness so the Create button
// can disable correctly without a controlled-input rerender on every keystroke.
@@ -139,9 +140,6 @@ export function AgentCreatePanel({
const [justSent, setJustSent] = useState(false);
const [sentCount, setSentCount] = useState(0);
const [error, setError] = useState<string | null>(null);
const [priority, setPriority] = useState<IssuePriority>("none");
const [dueDate, setDueDate] = useState<string | null>(null);
const [projectId, setProjectId] = useState<string | null>(null);
// Image paste/drop support: route uploads through the same helper Advanced
// uses, so users can paste screenshots straight into the prompt and the
@@ -169,14 +167,9 @@ export function AgentCreatePanel({
setSubmitting(true);
setError(null);
try {
await api.quickCreateIssue({
agent_id: agentId,
prompt: md,
...(priority !== "none" ? { priority } : {}),
...(dueDate ? { due_date: dueDate } : {}),
...(projectId ? { project_id: projectId } : {}),
});
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
setLastAgentId(agentId);
clearPrompt();
setLastMode("agent");
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
duration: 4000,
@@ -338,27 +331,6 @@ export function AgentCreatePanel({
</div>
)}
<div className="flex items-center gap-1.5 px-5 py-1.5 shrink-0 flex-wrap border-b">
<PriorityPicker
priority={priority}
onUpdate={(u) => u.priority && setPriority(u.priority)}
triggerRender={<PillButton />}
align="start"
/>
<DueDatePicker
dueDate={dueDate}
onUpdate={(u) => setDueDate(u.due_date ?? null)}
triggerRender={<PillButton />}
align="start"
/>
<ProjectPicker
projectId={projectId}
onUpdate={(u) => setProjectId(u.project_id ?? null)}
triggerRender={<PillButton />}
align="start"
/>
</div>
{/* Prompt — same rich editor Advanced uses, so paste/drop images,
mentions, and formatting all work. The dropZone wrapper enables
drag-and-drop file uploads alongside paste. */}
@@ -374,7 +346,10 @@ export function AgentCreatePanel({
ref={editorRef}
defaultValue={initialPrompt}
placeholder='Tell the agent what to do, e.g. "let Bohan fix the inbox loading slowness in the Web project"'
onUpdate={(md) => setHasContent(md.trim().length > 0)}
onUpdate={(md) => {
setHasContent(md.trim().length > 0);
setPrompt(md);
}}
onUploadFile={handleUploadFile}
onSubmit={submit}
debounceMs={150}

View File

@@ -74,6 +74,7 @@
"katex": "catalog:",
"lowlight": "^3.3.0",
"mermaid": "catalog:",
"motion": "^12.38.0",
"react-markdown": "^10.1.0",
"react-resizable-panels": "^4.7.5",
"recharts": "3.8.0",

60
pnpm-lock.yaml generated
View File

@@ -745,6 +745,9 @@ importers:
mermaid:
specifier: 'catalog:'
version: 11.14.0
motion:
specifier: ^12.38.0
version: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
react:
specifier: 'catalog:'
version: 19.2.3
@@ -4499,6 +4502,20 @@ packages:
resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==}
engines: {node: '>= 0.6'}
framer-motion@12.38.0:
resolution: {integrity: sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
fresh@2.0.0:
resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==}
engines: {node: '>= 0.8'}
@@ -5746,6 +5763,26 @@ packages:
mlly@1.8.2:
resolution: {integrity: sha512-d+ObxMQFmbt10sretNDytwt85VrbkhhUA/JBGm1MPaWJ65Cl4wOgLaB1NYvJSZ0Ef03MMEU/0xpPMXUIQ29UfA==}
motion-dom@12.38.0:
resolution: {integrity: sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==}
motion-utils@12.36.0:
resolution: {integrity: sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==}
motion@12.38.0:
resolution: {integrity: sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==}
peerDependencies:
'@emotion/is-prop-valid': '*'
react: ^18.0.0 || ^19.0.0
react-dom: ^18.0.0 || ^19.0.0
peerDependenciesMeta:
'@emotion/is-prop-valid':
optional: true
react:
optional: true
react-dom:
optional: true
ms@2.1.3:
resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==}
@@ -11490,6 +11527,15 @@ snapshots:
forwarded@0.2.0: {}
framer-motion@12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
motion-dom: 12.38.0
motion-utils: 12.36.0
tslib: 2.8.1
optionalDependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
fresh@2.0.0: {}
fs-extra@10.1.0:
@@ -13114,6 +13160,20 @@ snapshots:
pkg-types: 1.3.1
ufo: 1.6.3
motion-dom@12.38.0:
dependencies:
motion-utils: 12.36.0
motion-utils@12.36.0: {}
motion@12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3):
dependencies:
framer-motion: 12.38.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3)
tslib: 2.8.1
optionalDependencies:
react: 19.2.3
react-dom: 19.2.3(react@19.2.3)
ms@2.1.3: {}
msw@2.12.14(@types/node@25.5.0)(typescript@5.9.3):

View File

@@ -61,11 +61,7 @@ func buildQuickCreatePrompt(task Task) string {
b.WriteString(" - Never echo the title in the description.\n\n")
// priority
if task.QuickCreatePriority != "" {
fmt.Fprintf(&b, "- **priority**: pass `--priority %s`.\n\n", task.QuickCreatePriority)
} else {
b.WriteString("- **priority**: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n\n")
}
b.WriteString("- **priority**: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n\n")
// assignee
b.WriteString("- **assignee**:\n")
@@ -80,17 +76,8 @@ func buildQuickCreatePrompt(task Task) string {
b.WriteString(" - When the user did NOT name an assignee, default to YOURSELF (the picker agent): pass `--assignee <your agent name>`. Never leave the issue unassigned.\n\n")
}
// due date
if task.QuickCreateDueDate != "" {
fmt.Fprintf(&b, "- **due-date**: pass `--due-date %s`.\n\n", task.QuickCreateDueDate)
}
// fields to omit
if task.QuickCreateProjectID != "" {
fmt.Fprintf(&b, "- **project**: pass `--project %s`.\n\n", task.QuickCreateProjectID)
} else {
b.WriteString("- **project**: omit unless the platform UI shows a specific project context. The platform will route the issue to the workspace default.\n")
}
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

@@ -1,65 +0,0 @@
package daemon
import (
"strings"
"testing"
)
func TestBuildQuickCreatePrompt_AllExplicitFields(t *testing.T) {
task := Task{
QuickCreatePrompt: "Fix the login button",
QuickCreatePriority: "high",
QuickCreateDueDate: "2025-06-01T00:00:00Z",
QuickCreateProjectID: "123e4567-e89b-12d3-a456-426614174000",
}
got := buildQuickCreatePrompt(task)
for _, want := range []string{
"`--priority high`",
"`--due-date 2025-06-01T00:00:00Z`",
"`--project 123e4567-e89b-12d3-a456-426614174000`",
"`--priority high`.\n\n",
"`--due-date 2025-06-01T00:00:00Z`.\n\n",
} {
if !strings.Contains(got, want) {
t.Fatalf("prompt missing %q:\n%s", want, got)
}
}
if strings.Contains(got, `\n`) {
t.Fatalf("prompt should contain real newlines, got literal \\n:\n%s", got)
}
if strings.Contains(got, "Map P0/P1") {
t.Fatalf("prompt should not include fallback priority guidance when explicit priority is set:\n%s", got)
}
}
func TestBuildQuickCreatePrompt_PriorityOnly(t *testing.T) {
task := Task{
QuickCreatePrompt: "Urgent: server is down",
QuickCreatePriority: "urgent",
}
got := buildQuickCreatePrompt(task)
if !strings.Contains(got, "`--priority urgent`") {
t.Fatalf("prompt missing explicit priority flag:\n%s", got)
}
if strings.Contains(got, "Map P0/P1") {
t.Fatalf("prompt should not include fallback priority guidance when explicit priority is set:\n%s", got)
}
if strings.Contains(got, "`--due-date") || strings.Contains(got, "`--project") {
t.Fatalf("prompt should not inject unset quick-create flags:\n%s", got)
}
}
func TestBuildQuickCreatePrompt_NoneSet(t *testing.T) {
task := Task{QuickCreatePrompt: "Something came up"}
got := buildQuickCreatePrompt(task)
if !strings.Contains(got, "Map P0/P1") {
t.Fatalf("prompt should include fallback priority guidance when no explicit priority is set:\n%s", got)
}
if strings.Contains(got, `\n`) {
t.Fatalf("prompt should contain real newlines, got literal \\n:\n%s", got)
}
}

View File

@@ -33,34 +33,31 @@ type ProjectResourceData struct {
// Task represents a claimed task from the server.
// Agent data (name, skills) is populated by the claim endpoint.
type Task struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
Agent *AgentData `json:"agent,omitempty"`
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
Agent *AgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
QuickCreatePriority string `json:"quick_create_priority,omitempty"` // priority explicitly selected in quick-create UI
QuickCreateDueDate string `json:"quick_create_due_date,omitempty"` // due date explicitly selected in quick-create UI
QuickCreateProjectID string `json:"quick_create_project_id,omitempty"` // project explicitly selected in quick-create UI
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // human-readable project title for context injection
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // project-scoped resources to expose to the agent
PriorSessionID string `json:"prior_session_id,omitempty"` // Claude session ID from a previous task on this issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on this issue
TriggerCommentID string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind for the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message content for chat tasks
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot run_only tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this run
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
}
// AgentData holds agent details returned by the claim endpoint.

View File

@@ -134,48 +134,45 @@ type ProjectResourceData struct {
}
type AgentTaskResponse struct {
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
Status string `json:"status"`
Priority int32 `json:"priority"`
DispatchedAt *string `json:"dispatched_at"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
Result any `json:"result"`
Error *string `json:"error"`
FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask
Attempt int32 `json:"attempt"`
MaxAttempts int32 `json:"max_attempts"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
Agent *TaskAgentData `json:"agent,omitempty"`
ID string `json:"id"`
AgentID string `json:"agent_id"`
RuntimeID string `json:"runtime_id"`
IssueID string `json:"issue_id"`
WorkspaceID string `json:"workspace_id"`
Status string `json:"status"`
Priority int32 `json:"priority"`
DispatchedAt *string `json:"dispatched_at"`
StartedAt *string `json:"started_at"`
CompletedAt *string `json:"completed_at"`
Result any `json:"result"`
Error *string `json:"error"`
FailureReason string `json:"failure_reason,omitempty"` // see TaskService.MaybeRetryFailedTask
Attempt int32 `json:"attempt"`
MaxAttempts int32 `json:"max_attempts"`
ParentTaskID *string `json:"parent_task_id,omitempty"`
Agent *TaskAgentData `json:"agent,omitempty"`
Repos []RepoData `json:"repos,omitempty"`
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // for surfacing in agent context
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // resources attached to the project
CreatedAt string `json:"created_at"`
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
QuickCreatePriority string `json:"quick_create_priority,omitempty"` // priority explicitly selected in quick-create UI
QuickCreateDueDate string `json:"quick_create_due_date,omitempty"` // due date explicitly selected in quick-create UI
QuickCreateProjectID string `json:"quick_create_project_id,omitempty"` // project explicitly selected in quick-create UI
Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue
ProjectID string `json:"project_id,omitempty"` // issue's project, when present
ProjectTitle string `json:"project_title,omitempty"` // for surfacing in agent context
ProjectResources []ProjectResourceData `json:"project_resources,omitempty"` // resources attached to the project
CreatedAt string `json:"created_at"`
PriorSessionID string `json:"prior_session_id,omitempty"` // session ID from a previous task on same issue
PriorWorkDir string `json:"prior_work_dir,omitempty"` // work_dir from a previous task on same issue
TriggerCommentID *string `json:"trigger_comment_id,omitempty"` // comment that triggered this task
TriggerCommentContent string `json:"trigger_comment_content,omitempty"` // content of the triggering comment
TriggerSummary *string `json:"trigger_summary,omitempty"` // canonical short description snapshot — comment text / autopilot title — taken at task creation; survives source edits/deletes
TriggerAuthorType string `json:"trigger_author_type,omitempty"` // "agent" or "member" — author kind of the triggering comment
TriggerAuthorName string `json:"trigger_author_name,omitempty"` // display name of the triggering comment author
ChatSessionID string `json:"chat_session_id,omitempty"` // non-empty for chat tasks
ChatMessage string `json:"chat_message,omitempty"` // user message for chat tasks
AutopilotRunID string `json:"autopilot_run_id,omitempty"` // non-empty for autopilot-spawned tasks
AutopilotID string `json:"autopilot_id,omitempty"` // autopilot that spawned this task
AutopilotTitle string `json:"autopilot_title,omitempty"` // autopilot title used as task context
AutopilotDescription string `json:"autopilot_description,omitempty"` // autopilot description used as task prompt
AutopilotSource string `json:"autopilot_source,omitempty"` // manual, schedule, webhook, or api
AutopilotTriggerPayload json.RawMessage `json:"autopilot_trigger_payload,omitempty"` // optional trigger payload for webhook/api runs
QuickCreatePrompt string `json:"quick_create_prompt,omitempty"` // user's natural-language input for quick-create tasks
Kind string `json:"kind"` // discriminator: "comment" | "autopilot" | "chat" | "quick_create" | "direct" — used by the activity row to label tasks that have no linked issue
}
// TaskAgentData holds agent info included in claim responses so the daemon

View File

@@ -1095,15 +1095,6 @@ func (h *Handler) ClaimTaskByRuntime(w http.ResponseWriter, r *http.Request) {
if json.Unmarshal(task.Context, &qc) == nil && qc.Type == service.QuickCreateContextType {
hasQuickCreate = true
resp.QuickCreatePrompt = qc.Prompt
if qc.Priority != nil {
resp.QuickCreatePriority = *qc.Priority
}
if qc.DueDate != nil {
resp.QuickCreateDueDate = *qc.DueDate
}
if qc.ProjectID != nil {
resp.QuickCreateProjectID = *qc.ProjectID
}
resp.WorkspaceID = qc.WorkspaceID
if ws, err := h.Queries.GetWorkspace(r.Context(), parseUUID(qc.WorkspaceID)); err == nil && ws.Repos != nil {
var repos []RepoData

View File

@@ -18,7 +18,6 @@ import (
"github.com/go-chi/chi/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/multica-ai/multica/server/internal/logger"
"github.com/multica-ai/multica/server/internal/service"
"github.com/multica-ai/multica/server/internal/util"
"github.com/multica-ai/multica/server/pkg/agent"
db "github.com/multica-ai/multica/server/pkg/db/generated"
@@ -27,33 +26,33 @@ import (
// IssueResponse is the JSON response for an issue.
type IssueResponse struct {
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Number int32 `json:"number"`
Identifier string `json:"identifier"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
CreatorType string `json:"creator_type"`
CreatorID string `json:"creator_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Position float64 `json:"position"`
DueDate *string `json:"due_date"`
CreatedAt string `json:"created_at"`
UpdatedAt string `json:"updated_at"`
Reactions []IssueReactionResponse `json:"reactions,omitempty"`
Attachments []AttachmentResponse `json:"attachments,omitempty"`
// Labels are bulk-attached by list/detail endpoints so the client can render
// chips without an N+1 round-trip per row. Pointer + omitempty so paths that
// don't load labels (e.g. UpdateIssue, batch UpdateIssues, the issue:updated
// WS broadcast) emit no `labels` field at all — the client merge then
// preserves whatever labels are already in cache. nil pointer = "field
// absent, do not touch"; non-nil (incl. empty slice) = authoritative list.
Labels *[]LabelResponse `json:"labels,omitempty"`
Labels *[]LabelResponse `json:"labels,omitempty"`
}
func issueToResponse(i db.Issue, issuePrefix string) IssueResponse {
@@ -287,7 +286,7 @@ func buildSearchQuery(phrase string, terms []string, queryNum int, hasNum bool,
}
escapedPhrase := escapeLike(phrase)
phraseParam := nextArg(escapedPhrase) // $1
phraseParam := nextArg(escapedPhrase) // $1
phraseContains := "'%' || " + phraseParam + " || '%'"
phraseStartsWith := phraseParam + " || '%'"
@@ -859,11 +858,8 @@ func (h *Handler) ChildIssueProgress(w http.ResponseWriter, r *http.Request) {
// into a `multica issue create` invocation in the background; success and
// failure both surface as inbox notifications to the requester.
type QuickCreateIssueRequest struct {
AgentID string `json:"agent_id"`
Prompt string `json:"prompt"`
Priority *string `json:"priority,omitempty"`
DueDate *string `json:"due_date,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
AgentID string `json:"agent_id"`
Prompt string `json:"prompt"`
}
// QuickCreateIssueResponse echoes the queued task id so the frontend can
@@ -887,43 +883,6 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
if req.Priority != nil {
priority := strings.ToLower(strings.TrimSpace(*req.Priority))
if priority == "" {
req.Priority = nil
} else {
switch priority {
case "urgent", "high", "medium", "low":
req.Priority = &priority
default:
writeError(w, http.StatusBadRequest, "priority must be one of: urgent, high, medium, low")
return
}
}
}
if req.DueDate != nil {
dueDate := strings.TrimSpace(*req.DueDate)
if dueDate == "" {
req.DueDate = nil
} else {
if _, err := time.Parse(time.RFC3339, dueDate); err != nil {
writeError(w, http.StatusBadRequest, "invalid due_date format, expected RFC3339")
return
}
req.DueDate = &dueDate
}
}
if req.ProjectID != nil {
projectID := strings.TrimSpace(*req.ProjectID)
if projectID == "" {
req.ProjectID = nil
} else {
if _, ok := parseUUIDOrBadRequest(w, projectID, "project_id"); !ok {
return
}
req.ProjectID = &projectID
}
}
workspaceID := h.resolveWorkspaceID(r)
wsUUID, ok := parseUUIDOrBadRequest(w, workspaceID, "workspace_id")
@@ -984,11 +943,7 @@ func (h *Handler) QuickCreateIssue(w http.ResponseWriter, r *http.Request) {
return
}
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt, func(qc *service.QuickCreateContext) {
qc.Priority = req.Priority
qc.DueDate = req.DueDate
qc.ProjectID = req.ProjectID
})
task, err := h.TaskService.EnqueueQuickCreateTask(r.Context(), wsUUID, requesterUUID, agentUUID, prompt)
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")
@@ -1087,16 +1042,16 @@ func readRuntimeCLIVersion(metadata []byte) string {
}
type CreateIssueRequest struct {
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
DueDate *string `json:"due_date"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
Title string `json:"title"`
Description *string `json:"description"`
Status string `json:"status"`
Priority string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
DueDate *string `json:"due_date"`
AttachmentIDs []string `json:"attachment_ids,omitempty"`
// OriginType / OriginID stamp the new issue with its provenance so
// platform-internal flows can deterministically locate it later. Only
// trusted callers should set these — currently the daemon CLI passes
@@ -1332,16 +1287,16 @@ func (h *Handler) CreateIssue(w http.ResponseWriter, r *http.Request) {
}
type UpdateIssueRequest struct {
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
Title *string `json:"title"`
Description *string `json:"description"`
Status *string `json:"status"`
Priority *string `json:"priority"`
AssigneeType *string `json:"assignee_type"`
AssigneeID *string `json:"assignee_id"`
Position *float64 `json:"position"`
DueDate *string `json:"due_date"`
ParentIssueID *string `json:"parent_issue_id"`
ProjectID *string `json:"project_id"`
}
func (h *Handler) UpdateIssue(w http.ResponseWriter, r *http.Request) {

View File

@@ -1,134 +0,0 @@
package handler
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/multica-ai/multica/server/internal/service"
)
func setHandlerTestRuntimeCLIVersion(t *testing.T, version string) {
t.Helper()
if _, err := testPool.Exec(context.Background(), `
UPDATE agent_runtime
SET metadata = jsonb_set(COALESCE(metadata, '{}'::jsonb), '{cli_version}', to_jsonb($2::text), true)
WHERE id = $1
`, handlerTestRuntimeID(t), version); err != nil {
t.Fatalf("set runtime cli_version: %v", err)
}
}
func TestQuickCreateIssue_StoresExplicitFields(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
ctx := context.Background()
setHandlerTestRuntimeCLIVersion(t, "0.2.24")
agentID := createHandlerTestAgent(t, "Quick Create Test Agent", nil)
const dueDate = "2025-06-01T00:00:00Z"
const projectID = "123e4567-e89b-12d3-a456-426614174000"
w := httptest.NewRecorder()
req := newRequest(http.MethodPost, "/api/issues/quick-create", map[string]any{
"agent_id": agentID,
"prompt": "Create a follow-up issue",
"priority": " HIGH ",
"due_date": " " + dueDate + " ",
"project_id": " " + projectID + " ",
})
testHandler.QuickCreateIssue(w, req)
if w.Code != http.StatusAccepted {
t.Fatalf("QuickCreateIssue: expected 202, got %d: %s", w.Code, w.Body.String())
}
var resp QuickCreateIssueResponse
if err := json.NewDecoder(w.Body).Decode(&resp); err != nil {
t.Fatalf("decode response: %v", err)
}
var contextJSON []byte
if err := testPool.QueryRow(ctx, `SELECT context FROM agent_task_queue WHERE id = $1`, resp.TaskID).Scan(&contextJSON); err != nil {
t.Fatalf("load queued task context: %v", err)
}
t.Cleanup(func() {
testPool.Exec(context.Background(), `DELETE FROM agent_task_queue WHERE id = $1`, resp.TaskID)
})
var qc service.QuickCreateContext
if err := json.Unmarshal(contextJSON, &qc); err != nil {
t.Fatalf("unmarshal quick-create context: %v", err)
}
if qc.Type != service.QuickCreateContextType {
t.Fatalf("context type = %q, want %q", qc.Type, service.QuickCreateContextType)
}
if qc.Priority == nil || *qc.Priority != "high" {
t.Fatalf("context priority = %v, want high", qc.Priority)
}
if qc.DueDate == nil || *qc.DueDate != dueDate {
t.Fatalf("context due_date = %v, want %s", qc.DueDate, dueDate)
}
if qc.ProjectID == nil || *qc.ProjectID != projectID {
t.Fatalf("context project_id = %v, want %s", qc.ProjectID, projectID)
}
}
func TestQuickCreateIssue_RejectsInvalidOptionalFields(t *testing.T) {
if testHandler == nil {
t.Skip("database not available")
}
setHandlerTestRuntimeCLIVersion(t, "0.2.24")
agentID := createHandlerTestAgent(t, "Quick Create Validation Agent", nil)
tests := []struct {
name string
body map[string]any
want string
}{
{
name: "invalid priority",
body: map[string]any{
"agent_id": agentID,
"prompt": "Create something",
"priority": "none",
},
want: "priority must be one of: urgent, high, medium, low",
},
{
name: "invalid due date",
body: map[string]any{
"agent_id": agentID,
"prompt": "Create something",
"due_date": "tomorrow",
},
want: "invalid due_date format, expected RFC3339",
},
{
name: "invalid project id",
body: map[string]any{
"agent_id": agentID,
"prompt": "Create something",
"project_id": "not-a-uuid",
},
want: "invalid project_id",
},
}
for _, tc := range tests {
t.Run(tc.name, func(t *testing.T) {
w := httptest.NewRecorder()
req := newRequest(http.MethodPost, "/api/issues/quick-create", tc.body)
testHandler.QuickCreateIssue(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("QuickCreateIssue: expected 400, got %d: %s", w.Code, w.Body.String())
}
if body := w.Body.String(); body == "" || !strings.Contains(body, tc.want) {
t.Fatalf("response body %q does not contain %q", body, tc.want)
}
})
}
}

View File

@@ -208,13 +208,10 @@ func (s *TaskService) EnqueueTaskForMention(ctx context.Context, issue db.Issue,
// and switches to the quick-create prompt template; the completion path
// uses RequesterID + WorkspaceID to write the inbox notification.
type QuickCreateContext struct {
Type string `json:"type"`
Prompt string `json:"prompt"`
RequesterID string `json:"requester_id"`
WorkspaceID string `json:"workspace_id"`
Priority *string `json:"priority,omitempty"`
DueDate *string `json:"due_date,omitempty"`
ProjectID *string `json:"project_id,omitempty"`
Type string `json:"type"`
Prompt string `json:"prompt"`
RequesterID string `json:"requester_id"`
WorkspaceID string `json:"workspace_id"`
}
// QuickCreateContextType marks a task as a quick-create job.
@@ -226,7 +223,7 @@ 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, opts ...func(*QuickCreateContext)) (db.AgentTaskQueue, error) {
func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, requesterID pgtype.UUID, agentID pgtype.UUID, prompt string) (db.AgentTaskQueue, error) {
agent, err := s.Queries.GetAgent(ctx, agentID)
if err != nil {
return db.AgentTaskQueue{}, fmt.Errorf("load agent: %w", err)
@@ -244,9 +241,6 @@ func (s *TaskService) EnqueueQuickCreateTask(ctx context.Context, workspaceID, r
RequesterID: util.UUIDToString(requesterID),
WorkspaceID: util.UUIDToString(workspaceID),
}
for _, opt := range opts {
opt(&payload)
}
contextJSON, err := json.Marshal(payload)
if err != nil {
return db.AgentTaskQueue{}, fmt.Errorf("marshal quick-create context: %w", err)