mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-20 05:08:49 +02:00
Compare commits
7 Commits
agent/lamb
...
fix/code-b
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
565d45cdcc | ||
|
|
cb078c0f36 | ||
|
|
e13e5edc8e | ||
|
|
fee393df1f | ||
|
|
1ff4e27e77 | ||
|
|
fbf9460d5e | ||
|
|
d492b9d7a6 |
@@ -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),
|
||||
|
||||
26
packages/core/issues/stores/quick-create-store.test.ts
Normal file
26
packages/core/issues/stores/quick-create-store.test.ts
Normal 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("");
|
||||
});
|
||||
});
|
||||
@@ -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 }),
|
||||
}),
|
||||
|
||||
95
packages/core/issues/ws-updaters.test.ts
Normal file
95
packages/core/issues/ws-updaters.test.ts
Normal 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]);
|
||||
});
|
||||
});
|
||||
@@ -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) });
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
130
packages/views/editor/extensions/markdown-paste.test.ts
Normal file
130
packages/views/editor/extensions/markdown-paste.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
@@ -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
|
||||
|
||||
168
packages/views/modals/create-project.test.tsx
Normal file
168
packages/views/modals/create-project.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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)}
|
||||
|
||||
247
packages/views/modals/quick-create-issue.test.tsx
Normal file
247
packages/views/modals/quick-create-issue.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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}
|
||||
|
||||
@@ -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
60
pnpm-lock.yaml
generated
@@ -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):
|
||||
|
||||
@@ -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")
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user