mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-23 07:29:14 +02:00
Compare commits
2 Commits
agent/lamb
...
agent/lamb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ad23f930d6 | ||
|
|
db1d8eace3 |
@@ -36,8 +36,6 @@ function FileUploadButton({
|
||||
type="button"
|
||||
onClick={() => inputRef.current?.click()}
|
||||
disabled={disabled}
|
||||
aria-label="Attach file"
|
||||
title="Attach file"
|
||||
className={cn(
|
||||
"inline-flex items-center justify-center rounded-full text-muted-foreground hover:bg-accent hover:text-foreground transition-colors disabled:opacity-50 disabled:pointer-events-none",
|
||||
btnSize,
|
||||
|
||||
@@ -9,7 +9,6 @@ const mockCreateIssue = vi.hoisted(() => vi.fn());
|
||||
const mockSetDraft = vi.hoisted(() => vi.fn());
|
||||
const mockClearDraft = vi.hoisted(() => vi.fn());
|
||||
const mockSetLastAssignee = vi.hoisted(() => vi.fn());
|
||||
const mockSetKeepOpen = vi.hoisted(() => vi.fn());
|
||||
const mockToastCustom = vi.hoisted(() => vi.fn());
|
||||
const mockToastDismiss = vi.hoisted(() => vi.fn());
|
||||
const mockToastError = vi.hoisted(() => vi.fn());
|
||||
@@ -31,11 +30,6 @@ const mockDraftStore = {
|
||||
setLastAssignee: mockSetLastAssignee,
|
||||
};
|
||||
|
||||
const mockQuickCreateStore = {
|
||||
keepOpen: false,
|
||||
setKeepOpen: mockSetKeepOpen,
|
||||
};
|
||||
|
||||
vi.mock("../navigation", () => ({
|
||||
useNavigation: () => ({ push: mockPush }),
|
||||
}));
|
||||
@@ -66,11 +60,6 @@ vi.mock("@multica/core/issues/stores/draft-store", () => ({
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/stores/quick-create-store", () => ({
|
||||
useQuickCreateStore: (selector?: (state: typeof mockQuickCreateStore) => unknown) =>
|
||||
(selector ? selector(mockQuickCreateStore) : mockQuickCreateStore),
|
||||
}));
|
||||
|
||||
vi.mock("@multica/core/issues/mutations", () => ({
|
||||
useCreateIssue: () => ({ mutateAsync: mockCreateIssue }),
|
||||
useUpdateIssue: () => ({ mutate: vi.fn() }),
|
||||
@@ -90,10 +79,6 @@ vi.mock("../editor", () => {
|
||||
const [value, setValue] = useState(defaultValue || "");
|
||||
useImperativeHandle(ref, () => ({
|
||||
getMarkdown: () => valueRef.current,
|
||||
clearContent: () => {
|
||||
valueRef.current = "";
|
||||
setValue("");
|
||||
},
|
||||
uploadFile: vi.fn(),
|
||||
}));
|
||||
return (
|
||||
@@ -193,23 +178,6 @@ vi.mock("@multica/ui/components/ui/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: ({ onSelect }: { onSelect: (file: File) => void }) => (
|
||||
<button type="button" onClick={() => onSelect(new File(["test"], "test.txt"))}>
|
||||
@@ -242,10 +210,6 @@ function renderModal(element: React.ReactElement) {
|
||||
describe("CreateIssueModal", () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockQuickCreateStore.keepOpen = false;
|
||||
mockSetKeepOpen.mockImplementation((v: boolean) => {
|
||||
mockQuickCreateStore.keepOpen = v;
|
||||
});
|
||||
mockCreateIssue.mockResolvedValue({
|
||||
id: "issue-123",
|
||||
identifier: "TES-123",
|
||||
@@ -297,44 +261,4 @@ describe("CreateIssueModal", () => {
|
||||
expect(mockPush).toHaveBeenCalledWith("/ws-test/issues/issue-123");
|
||||
expect(mockToastDismiss).toHaveBeenCalledWith("toast-1");
|
||||
});
|
||||
|
||||
it("keeps manual mode open and clears content when create another is enabled", async () => {
|
||||
const user = userEvent.setup();
|
||||
const onClose = vi.fn();
|
||||
mockQuickCreateStore.keepOpen = true;
|
||||
|
||||
renderModal(<CreateIssueModal onClose={onClose} />);
|
||||
|
||||
await user.type(screen.getByPlaceholderText("Issue title"), "First follow-up issue");
|
||||
await user.type(screen.getByPlaceholderText("Add description..."), "Description to clear");
|
||||
await user.click(screen.getByRole("button", { name: "Create Issue" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(mockCreateIssue).toHaveBeenCalledWith({
|
||||
title: "First follow-up issue",
|
||||
description: "Description to clear",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assignee_type: undefined,
|
||||
assignee_id: undefined,
|
||||
due_date: undefined,
|
||||
attachment_ids: undefined,
|
||||
parent_issue_id: undefined,
|
||||
project_id: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
expect(onClose).not.toHaveBeenCalled();
|
||||
expect(screen.getByPlaceholderText("Issue title")).toHaveValue("");
|
||||
expect(screen.getByPlaceholderText("Add description...")).toHaveValue("");
|
||||
expect(mockSetDraft).toHaveBeenCalledWith({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assigneeType: undefined,
|
||||
assigneeId: undefined,
|
||||
dueDate: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
} from "@multica/ui/components/ui/dropdown-menu";
|
||||
import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/ui/tooltip";
|
||||
import { Button } from "@multica/ui/components/ui/button";
|
||||
import { Switch } from "@multica/ui/components/ui/switch";
|
||||
import { ContentEditor, type ContentEditorRef, TitleEditor, useFileDropZone, FileDropOverlay } from "../editor";
|
||||
import { StatusIcon, StatusPicker, PriorityPicker, AssigneePicker, DueDatePicker } from "../issues/components";
|
||||
import { BacklogAgentHintContent } from "../issues/components/backlog-agent-hint-dialog";
|
||||
@@ -39,7 +38,6 @@ import { useCurrentWorkspace, useWorkspacePaths } from "@multica/core/paths";
|
||||
import { useWorkspaceId } from "@multica/core/hooks";
|
||||
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
|
||||
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
|
||||
import { useQuickCreateStore } from "@multica/core/issues/stores/quick-create-store";
|
||||
import { issueDetailOptions } from "@multica/core/issues/queries";
|
||||
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
|
||||
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
|
||||
@@ -86,11 +84,8 @@ export function ManualCreatePanel({
|
||||
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
|
||||
const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
|
||||
const setLastMode = useCreateModeStore((s) => s.setLastMode);
|
||||
const keepOpen = useQuickCreateStore((s) => s.keepOpen);
|
||||
const setKeepOpen = useQuickCreateStore((s) => s.setKeepOpen);
|
||||
|
||||
const [title, setTitle] = useState(draft.title);
|
||||
const [formResetKey, setFormResetKey] = useState(0);
|
||||
const descEditorRef = useRef<ContentEditorRef>(null);
|
||||
const { isDragOver: descDragOver, dropZoneProps: descDropZoneProps } = useFileDropZone({
|
||||
onDrop: (files) => files.forEach((f) => descEditorRef.current?.uploadFile(f)),
|
||||
@@ -143,28 +138,6 @@ export function ManualCreatePanel({
|
||||
|
||||
const createIssueMutation = useCreateIssue();
|
||||
const updateIssueMutation = useUpdateIssue();
|
||||
const resetForNextIssue = () => {
|
||||
setTitle("");
|
||||
setStatus("todo");
|
||||
setPriority("none");
|
||||
setDueDate(null);
|
||||
setProjectId(undefined);
|
||||
setParentIssueId(undefined);
|
||||
setChildIssues([]);
|
||||
setAttachmentIds([]);
|
||||
setDraft({
|
||||
title: "",
|
||||
description: "",
|
||||
status: "todo",
|
||||
priority: "none",
|
||||
assigneeType,
|
||||
assigneeId,
|
||||
dueDate: null,
|
||||
});
|
||||
descEditorRef.current?.clearContent();
|
||||
setFormResetKey((key) => key + 1);
|
||||
};
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!title.trim() || submitting) return;
|
||||
setSubmitting(true);
|
||||
@@ -213,8 +186,6 @@ export function ManualCreatePanel({
|
||||
|
||||
if (shouldShowBacklogHint) {
|
||||
setBacklogHintIssueId(issue.id);
|
||||
} else if (keepOpen) {
|
||||
resetForNextIssue();
|
||||
} else {
|
||||
onClose();
|
||||
}
|
||||
@@ -333,7 +304,6 @@ export function ManualCreatePanel({
|
||||
{/* Title */}
|
||||
<div className="px-5 pb-2 shrink-0">
|
||||
<TitleEditor
|
||||
key={formResetKey}
|
||||
autoFocus
|
||||
defaultValue={draft.title}
|
||||
placeholder="Issue title"
|
||||
@@ -524,30 +494,20 @@ export function ManualCreatePanel({
|
||||
/>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<FileUploadButton
|
||||
onSelect={(file) => descEditorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToAgent}
|
||||
title="Switch to create with agent — describe in one line and let the agent file it"
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to Agent
|
||||
Switch to agent
|
||||
</button>
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={keepOpen}
|
||||
onCheckedChange={setKeepOpen}
|
||||
/>
|
||||
Create another
|
||||
</label>
|
||||
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
|
||||
{submitting ? "Creating..." : "Create Issue"}
|
||||
</Button>
|
||||
|
||||
@@ -252,7 +252,6 @@ export function AgentCreatePanel({
|
||||
on the first focusable element on mount, causing the tooltip to
|
||||
auto-pop every open. */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClose}
|
||||
title="Close"
|
||||
aria-label="Close"
|
||||
@@ -269,7 +268,6 @@ export function AgentCreatePanel({
|
||||
render={
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Select agent"
|
||||
className="flex items-center gap-2 text-xs text-muted-foreground hover:text-foreground transition-colors cursor-pointer rounded-sm px-1.5 py-1 -ml-1.5 hover:bg-accent/60"
|
||||
>
|
||||
<span>Created by</span>
|
||||
@@ -309,9 +307,6 @@ export function AgentCreatePanel({
|
||||
size={16}
|
||||
/>
|
||||
<span className="flex-1 truncate">{a.name}</span>
|
||||
{agentId === a.id && (
|
||||
<Check className="size-3.5 text-muted-foreground" />
|
||||
)}
|
||||
</DropdownMenuItem>
|
||||
))
|
||||
)}
|
||||
@@ -355,30 +350,30 @@ export function AgentCreatePanel({
|
||||
)}
|
||||
|
||||
{/* Footer */}
|
||||
<div className="flex flex-col gap-2 border-t px-4 py-3 shrink-0 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex min-h-7 items-center gap-2">
|
||||
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileUploadButton
|
||||
size="sm"
|
||||
disabled={uploading}
|
||||
onSelect={(file) => editorRef.current?.uploadFile(file)}
|
||||
/>
|
||||
{keepOpen && sentCount > 0 && (
|
||||
<span className="text-xs text-emerald-600 dark:text-emerald-400">
|
||||
{sentCount} sent
|
||||
</span>
|
||||
)}
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{keepOpen && sentCount > 0 && (
|
||||
<span className="text-emerald-600 dark:text-emerald-400">{sentCount} sent · </span>
|
||||
)}
|
||||
⌘↵ to submit
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center justify-end gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={switchToManual}
|
||||
title="Switch to manual create — fill the fields yourself"
|
||||
className="flex shrink-0 items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
className="flex items-center gap-1.5 text-xs px-2 py-1 rounded-sm text-muted-foreground hover:text-foreground hover:bg-accent/60 transition-colors cursor-pointer"
|
||||
>
|
||||
<ArrowLeftRight className="size-3.5" />
|
||||
Switch to Manual
|
||||
Switch to manual
|
||||
</button>
|
||||
<label className="flex shrink-0 items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<label className="flex items-center gap-1.5 text-xs text-muted-foreground cursor-pointer select-none">
|
||||
<Switch
|
||||
size="sm"
|
||||
checked={keepOpen}
|
||||
@@ -395,11 +390,11 @@ export function AgentCreatePanel({
|
||||
? `Daemon CLI must be ≥ ${versionCheck.min}`
|
||||
: undefined
|
||||
}
|
||||
className={justSent ? "min-w-28 !bg-emerald-600 !text-white" : "min-w-28"}
|
||||
className={justSent ? "!bg-emerald-600 !text-white" : undefined}
|
||||
>
|
||||
{submitting ? "Sending…" : uploading ? "Uploading…" : justSent ? (
|
||||
<span className="flex items-center gap-1"><Check className="size-3.5" />Sent</span>
|
||||
) : "Create (⌘↵)"}
|
||||
) : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -32,22 +32,21 @@ func BuildPrompt(task Task) string {
|
||||
|
||||
// buildQuickCreatePrompt constructs a prompt for quick-create tasks. The
|
||||
// user typed a single natural-language sentence in the create-issue modal;
|
||||
// the agent's job is to translate it into one `multica issue create` CLI
|
||||
// invocation, using its judgment to decide whether fetching referenced URLs
|
||||
// would produce a better issue. No issue exists yet, so the agent must NOT
|
||||
// call `multica issue get` or attempt to comment — there's nothing to read
|
||||
// or reply to.
|
||||
// the agent's only job is to translate it into one `multica issue create`
|
||||
// CLI invocation. No issue exists yet, so the agent must NOT call
|
||||
// `multica issue get` or attempt to comment — there's nothing to read or
|
||||
// reply to.
|
||||
func buildQuickCreatePrompt(task Task) string {
|
||||
var b strings.Builder
|
||||
b.WriteString("You are running as a quick-create assistant for a Multica workspace.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your job is to create a well-formed issue from the user's input with a single `multica issue create` command.\n\n")
|
||||
b.WriteString("A user pressed the quick-create shortcut and typed a one-line description. There is NO existing issue. Your only job is to translate the description into a single `multica issue create` command and run it.\n\n")
|
||||
fmt.Fprintf(&b, "User input:\n> %s\n\n", task.QuickCreatePrompt)
|
||||
b.WriteString("Field rules:\n")
|
||||
b.WriteString("- title: required. A concise but semantically rich summary that lets a reader understand what the issue is about at a glance. If the user input references external resources (PRs, issues, URLs, etc.), use your judgment to decide whether fetching the resource would produce a meaningfully better title — if so, fetch it and incorporate the relevant context. For example, \"review PR #123\" is much less useful than \"Review PR #123: Refactor auth module to OAuth2\". Strip filler words but preserve key semantic information.\n")
|
||||
b.WriteString("- description: always provide a rich, self-contained description. The created issue will be picked up and executed by an agent — the more accurate context the description contains, the better the agent will understand and execute the task. Use your judgment to gather context: if the user input contains URLs or references, fetch them and summarize the relevant parts. Spell out what needs to be done, what the background is, and any constraints or details that would help the executing agent avoid misunderstandings. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\" → urgent. If unspecified, omit.\n")
|
||||
b.WriteString("- title: required. A short, imperative summary extracted from the user input (e.g. \"fix inbox loading\"). Strip filler words.\n")
|
||||
b.WriteString("- description: optional. Include only if the user supplied detail beyond the title; otherwise omit. Never echo the title here.\n")
|
||||
b.WriteString("- priority: one of `urgent`, `high`, `medium`, `low`, or omit. Map P0/P1 → urgent/high; \"asap\"/\"紧急\" → urgent; \"低优先级\" → low. If unspecified, omit.\n")
|
||||
b.WriteString("- assignee:\n")
|
||||
b.WriteString(" - When the user names someone (\"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `Unrecognized assignee: X`.\n")
|
||||
b.WriteString(" - When the user names someone (\"分给 X\" / \"assign to X\" / \"@X\"), call `multica workspace members --output json` and find the matching member by display name (case-insensitive substring match is fine). On a clean match, pass `--assignee <name>`. On no match or ambiguous match, do NOT pass `--assignee` — instead append a final line to the description: `未识别 assignee: X`.\n")
|
||||
agentName := ""
|
||||
if task.Agent != nil {
|
||||
agentName = task.Agent.Name
|
||||
|
||||
Reference in New Issue
Block a user