Compare commits

..

1 Commits

Author SHA1 Message Date
Naiyuan Qing
bcfb98a576 chore(docs): remove shipped agent-runtime redesign + workspace audit docs
These were transitional handoff/design docs that fulfilled their purpose:

- docs/agent-runtime-status-redesign.md (802 lines) — design + plan for
  PR #1794 (presence v3, availability + last-task split). Shipped.
- docs/agent-runtime-ui-design-brief.md (530 lines) — paired designer
  brief for the same redesign. Shipped.
- HANDOFF_ARCHITECTURE_AUDIT.md (383 lines) — 4-task audit packaged for
  the workspace URL refactor (PR #1138/#1141). The URL refactor itself
  shipped; the other tasks are either resolved or live in code as the
  source of truth. File:line snapshots inside have rotted.

Follows the precedent set by #1504 (chore(docs): remove shipped plan and
proposal docs). Code is the source of truth once the work is in.
2026-04-29 15:07:45 +08:00
12 changed files with 159 additions and 564 deletions

View File

@@ -1,36 +0,0 @@
"use client";
import { create } from "zustand";
import { createJSONStorage, persist } from "zustand/middleware";
import { defaultStorage } from "../../platform/storage";
/**
* Last create-issue mode the user landed on. Drives the global `c` shortcut
* and the in-modal mode switch — pressing `c` opens whichever modal the user
* used last, and the switch button in either modal updates this so the
* preference sticks.
*
* Workspace-agnostic on purpose: the user's mental preference for "how do I
* file an issue" doesn't change per workspace, so this lives in plain
* localStorage rather than the workspace-aware StateStorage that scopes
* per-workspace stores like quick-create-store / draft-store.
*/
export type CreateMode = "agent" | "manual";
interface CreateModeState {
lastMode: CreateMode;
setLastMode: (mode: CreateMode) => void;
}
export const useCreateModeStore = create<CreateModeState>()(
persist(
(set) => ({
lastMode: "agent",
setLastMode: (mode) => set({ lastMode: mode }),
}),
{
name: "multica_create_mode",
storage: createJSONStorage(() => defaultStorage),
},
),
);

View File

@@ -1,5 +1,4 @@
export { useIssueSelectionStore } from "./selection-store";
export { useCreateModeStore, type CreateMode } from "./create-mode-store";
export { useIssueDraftStore } from "./draft-store";
export { useRecentIssuesStore, type RecentIssueEntry } from "./recent-issues-store";
export {

View File

@@ -1,100 +0,0 @@
// Tracks the last time the current user mentioned a given target (member /
// agent / issue / "all"), per workspace, in browser storage. Used to rank the
// mention suggestion dropdown so recently-mentioned targets surface first.
//
// Data is per-device by design — the goal is "make the next mention faster",
// not a cross-device profile. If localStorage is unavailable (SSR, sandboxed
// environments) every accessor degrades to a no-op so callers can use it
// unconditionally.
import type { MentionItem } from "./mention-suggestion";
type RecencyMap = Record<string, number>;
const STORAGE_PREFIX = "multica:mention-recency:";
const MAX_ENTRIES = 200;
function storageKey(workspaceId: string): string {
return `${STORAGE_PREFIX}${workspaceId}`;
}
function getStorage(): Storage | null {
if (typeof window === "undefined") return null;
try {
return window.localStorage;
} catch {
return null;
}
}
function readRecencyMap(workspaceId: string): RecencyMap {
const storage = getStorage();
if (!storage) return {};
const raw = storage.getItem(storageKey(workspaceId));
if (!raw) return {};
try {
const parsed = JSON.parse(raw);
if (parsed && typeof parsed === "object") return parsed as RecencyMap;
} catch {
// Corrupt entry — drop it on the next write rather than throwing.
}
return {};
}
function writeRecencyMap(workspaceId: string, map: RecencyMap): void {
const storage = getStorage();
if (!storage) return;
try {
storage.setItem(storageKey(workspaceId), JSON.stringify(map));
} catch {
// Quota exceeded or storage disabled — silently skip.
}
}
function recencyKey(item: Pick<MentionItem, "type" | "id">): string {
return `${item.type}:${item.id}`;
}
export function recordMentionUsage(
workspaceId: string,
item: Pick<MentionItem, "type" | "id">,
): void {
if (!workspaceId) return;
const map = readRecencyMap(workspaceId);
map[recencyKey(item)] = Date.now();
// Lazy prune: keep the map bounded so it doesn't grow forever as members
// and agents come and go.
const entries = Object.entries(map);
if (entries.length > MAX_ENTRIES) {
entries.sort(([, ta], [, tb]) => tb - ta);
const trimmed: RecencyMap = {};
for (const [key, ts] of entries.slice(0, MAX_ENTRIES)) {
trimmed[key] = ts;
}
writeRecencyMap(workspaceId, trimmed);
return;
}
writeRecencyMap(workspaceId, map);
}
export function getRecencyMap(workspaceId: string): RecencyMap {
if (!workspaceId) return {};
return readRecencyMap(workspaceId);
}
// Sorts user-type mention items (member/agent) by recency DESC, with an
// alphabetical name fallback for items the user has never mentioned. Used to
// merge the previously-separate member and agent buckets into a single list.
export function sortUserItemsByRecency(
items: MentionItem[],
recency: RecencyMap,
): MentionItem[] {
return [...items].sort((a, b) => {
const ra = recency[recencyKey(a)] ?? 0;
const rb = recency[recencyKey(b)] ?? 0;
if (ra !== rb) return rb - ra;
return a.label.localeCompare(b.label);
});
}

View File

@@ -27,11 +27,6 @@ import { StatusIcon } from "../../issues/components/status-icon";
import { Badge } from "@multica/ui/components/ui/badge";
import type { IssueStatus } from "@multica/core/types";
import type { SuggestionOptions, SuggestionProps } from "@tiptap/suggestion";
import {
getRecencyMap,
recordMentionUsage,
sortUserItemsByRecency,
} from "./mention-recency";
// ---------------------------------------------------------------------------
// Types
@@ -190,10 +185,7 @@ export const MentionList = forwardRef<MentionListRef, MentionListProps>(
const selectItem = useCallback(
(index: number) => {
const item = displayItems[index];
if (!item) return;
const wsId = getCurrentWsId();
if (wsId) recordMentionUsage(wsId, item);
command(item);
if (item) command(item);
},
[displayItems, command],
);
@@ -382,15 +374,6 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
.filter((a) => !a.archived_at && a.name.toLowerCase().includes(q))
.map((a) => ({ id: a.id, label: a.name, type: "agent" as const }));
// Members and agents share a single ranked list — recently mentioned
// targets come first regardless of type, with an alphabetical fallback
// for everyone the user hasn't mentioned yet on this device.
const recency = getRecencyMap(wsId);
const userItems = sortUserItemsByRecency(
[...memberItems, ...agentItems],
recency,
);
// Cached issues give an instant first paint; MentionList adds server
// matches for done/cancelled and any other issues not in this cache.
const issueItems: MentionItem[] = cachedIssues
@@ -401,7 +384,7 @@ export function createMentionSuggestion(qc: QueryClient): Omit<
)
.map(issueToMention);
return [...allItem, ...userItems, ...issueItems];
return [...allItem, ...memberItems, ...agentItems, ...issueItems];
}
return {

View File

@@ -38,7 +38,6 @@ import { Tooltip, TooltipTrigger, TooltipContent } from "@multica/ui/components/
import { Collapsible, CollapsibleTrigger, CollapsibleContent } from "@multica/ui/components/ui/collapsible";
import { StatusIcon } from "../issues/components/status-icon";
import { useIssueDraftStore } from "@multica/core/issues/stores/draft-store";
import { useCreateModeStore } from "@multica/core/issues/stores/create-mode-store";
import {
Sidebar,
SidebarContent,
@@ -396,14 +395,13 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
},
});
// Global "C" shortcut: opens whichever create mode the user landed on last
// (agent vs manual), persisted in useCreateModeStore. The mode switch lives
// inside both modal footers so users can flip without remembering which
// shortcut goes where — `c` always means "open the create flow I prefer".
// Global "C" shortcut: opens the quick-create modal by default; Shift+C
// jumps straight to the legacy advanced form for users who want every
// field. Both branches honor the same focus / open-modal guards.
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "c" && e.key !== "C") return;
if (e.metaKey || e.ctrlKey || e.altKey || e.shiftKey) return;
if (e.metaKey || e.ctrlKey || e.altKey) return;
const tag = (e.target as HTMLElement)?.tagName;
const isEditable =
tag === "INPUT" ||
@@ -413,11 +411,10 @@ export function AppSidebar({ topSlot, searchSlot, headerClassName, headerStyle }
if (isEditable) return;
if (useModalStore.getState().modal) return;
e.preventDefault();
const lastMode = useCreateModeStore.getState().lastMode;
if (lastMode === "manual") {
// Auto-fill project when on a project detail page (manual form only —
// agent mode lets the agent infer project from the prompt).
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
// Auto-fill project when on a project detail page (advanced form only —
// quick-create lets the agent infer project from prompt).
const projectMatch = pathname.match(/^\/[^/]+\/projects\/([^/]+)$/);
if (e.shiftKey) {
const data = projectMatch ? { project_id: projectMatch[1] } : undefined;
useModalStore.getState().open("create-issue", data);
} else {

View File

@@ -1,94 +0,0 @@
"use client";
import { useState } from "react";
import { cn } from "@multica/ui/lib/utils";
import { Dialog, DialogContent } from "@multica/ui/components/ui/dialog";
import {
useCreateModeStore,
type CreateMode,
} from "@multica/core/issues/stores/create-mode-store";
import { AgentCreatePanel } from "./quick-create-issue";
import { ManualCreatePanel, manualDialogContentClass } from "./create-issue";
/**
* Shell that owns the single `<Dialog>` AND `<DialogContent>` for the
* create-issue flow. Mode switching unmounts/mounts only the inner panel
* body — the Portal, Backdrop, and Popup all stay in the DOM, so Base UI
* never replays the open animation. That's what makes the switch feel
* instant; an earlier version put `<DialogContent>` inside each panel and
* the close→open animation cycle still fired on every toggle.
*
* `initialMode` comes from the modal registry (`quick-create-issue` →
* agent, `create-issue` → manual). Subsequent switches are local state
* only and never round-trip through the modal store.
*
* Carry payload: when a panel switches mode it can hand a payload up via
* `onSwitchMode`; the shell stores it as the next panel's `data` so seeding
* works exactly like a fresh open.
*
* Manual-mode `isExpanded` / `backlogHintIssueId` are lifted up because they
* drive `DialogContent`'s className — the className lives here in the shell
* since the Popup is here, but the toggles for those states live in the
* manual panel body.
*/
export function CreateIssueDialog({
onClose,
initialMode,
data,
}: {
onClose: () => void;
initialMode: CreateMode;
data?: Record<string, unknown> | null;
}) {
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const [mode, setMode] = useState<CreateMode>(initialMode);
const [panelData, setPanelData] = useState(data ?? null);
const [isExpanded, setIsExpanded] = useState(false);
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
const switchTo = (next: CreateMode) => (carry?: Record<string, unknown> | null) => {
setLastMode(next);
setPanelData(carry ?? null);
setMode(next);
};
const className =
mode === "agent"
? cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
"!max-w-xl !w-full",
// Smooth size transition when switching modes — the manual mode
// uses the same easing.
"!transition-all !duration-300 !ease-out",
)
: manualDialogContentClass(isExpanded, backlogHintIssueId);
return (
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={className}
>
{mode === "agent" ? (
<AgentCreatePanel
onClose={onClose}
onSwitchMode={switchTo("manual")}
data={panelData}
/>
) : (
<ManualCreatePanel
onClose={onClose}
onSwitchMode={switchTo("agent")}
data={panelData}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
backlogHintIssueId={backlogHintIssueId}
setBacklogHintIssueId={setBacklogHintIssueId}
/>
)}
</DialogContent>
</Dialog>
);
}

View File

@@ -5,7 +5,6 @@ import { useQuery } from "@tanstack/react-query";
import { useNavigation } from "../navigation";
import {
ArrowDown,
ArrowLeftRight,
ArrowUp,
Check,
ChevronRight,
@@ -18,6 +17,7 @@ import { cn } from "@multica/ui/lib/utils";
import { toast } from "sonner";
import type { Issue, IssueStatus, IssuePriority, IssueAssigneeType } from "@multica/core/types";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
@@ -37,7 +37,6 @@ import { ProjectPicker } from "../projects/components/project-picker";
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 { issueDetailOptions } from "@multica/core/issues/queries";
import { useCreateIssue, useUpdateIssue } from "@multica/core/issues/mutations";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
@@ -47,34 +46,10 @@ import { PillButton } from "../common/pill-button";
import { IssuePickerModal } from "./issue-picker-modal";
// ---------------------------------------------------------------------------
// ManualCreatePanel — manual-mode body of the create-issue dialog. Renders
// DialogContent + everything inside; the surrounding `<Dialog>` is owned by
// CreateIssueDialog so mode switching swaps only the inner panel without
// remounting the Dialog Root (no overlay flash). `onSwitchMode` flips the
// shell's local mode state.
// CreateIssueModal
// ---------------------------------------------------------------------------
export function ManualCreatePanel({
onClose,
onSwitchMode,
data,
isExpanded,
setIsExpanded,
backlogHintIssueId,
setBacklogHintIssueId,
}: {
onClose: () => void;
/** Called with the carry payload to seed the agent panel after switch. */
onSwitchMode?: (carry?: Record<string, unknown> | null) => void;
data?: Record<string, unknown> | null;
/** Lifted to the shell so DialogContent's mode-aware className can react
* without the body itself having to live inside DialogContent (which would
* re-mount the Portal on mode swap and replay the open animation). */
isExpanded: boolean;
setIsExpanded: (v: boolean) => void;
backlogHintIssueId: string | null;
setBacklogHintIssueId: (id: string | null) => void;
}) {
export function CreateIssueModal({ onClose, data }: { onClose: () => void; data?: Record<string, unknown> | null }) {
const router = useNavigation();
const p = useWorkspacePaths();
const workspaceName = useCurrentWorkspace()?.name;
@@ -83,7 +58,6 @@ export function ManualCreatePanel({
const setDraft = useIssueDraftStore((s) => s.setDraft);
const clearDraft = useIssueDraftStore((s) => s.clearDraft);
const setLastAssignee = useIssueDraftStore((s) => s.setLastAssignee);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const [title, setTitle] = useState(draft.title);
const descEditorRef = useRef<ContentEditorRef>(null);
@@ -107,6 +81,9 @@ export function ManualCreatePanel({
// object, and we never need to hydrate from an ID the way we do for parent.
const [childIssues, setChildIssues] = useState<Issue[]>([]);
const [childPickerOpen, setChildPickerOpen] = useState(false);
const [isExpanded, setIsExpanded] = useState(false);
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
// Fetch parent issue details for the chip (status/identifier/title).
// List cache usually has it already, so this resolves synchronously.
const wsId = useWorkspaceId();
@@ -178,7 +155,6 @@ export function ManualCreatePanel({
}
setLastAssignee(assigneeType, assigneeId);
setLastMode("manual");
clearDraft();
const shouldShowBacklogHint =
status === "backlog" && assigneeType === "agent" && assigneeId &&
@@ -223,25 +199,32 @@ export function ManualCreatePanel({
}
};
// Switch to agent mode. Hand the typed text up to the shell as the carry
// payload; the shell stores it as the next panel's `data` so the agent
// panel reads `data.prompt` on mount. Concatenate title + description so
// nothing the user typed is lost — the agent derives a fresh title from
// the combined text. Persist the mode flip so the next `c` lands in agent.
const switchToAgent = () => {
const desc = descEditorRef.current?.getMarkdown()?.trim() ?? "";
const prompt = [title.trim(), desc].filter(Boolean).join("\n\n");
setLastMode("agent");
onSwitchMode?.({
prompt,
...(assigneeType === "agent" && assigneeId
? { agent_id: assigneeId }
: {}),
});
};
return (
<>
<Dialog
open
onOpenChange={(v) => {
if (!v) {
setBacklogHintIssueId(null);
onClose();
}
}}
>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
backlogHintIssueId
? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0"
: "!transition-all !duration-300 !ease-out",
!backlogHintIssueId && isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: !backlogHintIssueId
? "!max-w-2xl !w-full !h-96 !-translate-y-1/2"
: "",
)}
>
{backlogHintIssueId ? (
<BacklogAgentHintContent
onKeepInBacklog={() => {
@@ -269,7 +252,7 @@ export function ManualCreatePanel({
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">Create manually</span>
<span className="font-medium">New issue</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
@@ -498,74 +481,13 @@ export function ManualCreatePanel({
<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 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
</button>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</div>
<Button size="sm" onClick={handleSubmit} disabled={!title.trim() || submitting}>
{submitting ? "Creating..." : "Create Issue"}
</Button>
</div>
</>
)}
</>
);
}
/** className for DialogContent in manual mode — depends on isExpanded and the
* backlog-hint sub-state. Exported so the shell (which now owns the
* DialogContent) can apply the same visual treatment without duplicating it. */
export function manualDialogContentClass(
isExpanded: boolean,
backlogHintIssueId: string | null,
) {
return cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2",
backlogHintIssueId
? "!max-w-[480px] !w-[calc(100vw-2rem)] !h-auto !-translate-y-1/2 !transition-none !duration-0"
: "!transition-all !duration-300 !ease-out",
!backlogHintIssueId && isExpanded
? "!max-w-4xl !w-full !h-5/6 !-translate-y-1/2"
: !backlogHintIssueId
? "!max-w-2xl !w-full !h-96 !-translate-y-1/2"
: "",
);
}
// Thin Dialog-wrapping export — registry mounts the panel directly under the
// shell's shared Dialog, but a few legacy callers (and the test suite) still
// import this module's modal version. Equivalent runtime behavior to the
// pre-refactor component when used standalone.
import { Dialog as DialogRoot } from "@multica/ui/components/ui/dialog";
export function CreateIssueModal(props: {
onClose: () => void;
data?: Record<string, unknown> | null;
}) {
const [isExpanded, setIsExpanded] = useState(false);
const [backlogHintIssueId, setBacklogHintIssueId] = useState<string | null>(null);
return (
<DialogRoot open onOpenChange={(v) => { if (!v) props.onClose(); }}>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={manualDialogContentClass(isExpanded, backlogHintIssueId)}
>
<ManualCreatePanel
{...props}
isExpanded={isExpanded}
setIsExpanded={setIsExpanded}
backlogHintIssueId={backlogHintIssueId}
setBacklogHintIssueId={setBacklogHintIssueId}
/>
</DialogContent>
</DialogRoot>
</Dialog>
);
}

View File

@@ -1,16 +1,22 @@
"use client";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ArrowLeftRight, ChevronRight, Sparkles, X as XIcon } from "lucide-react";
import { useEffect, useMemo, useRef, useState } from "react";
import { ChevronRight, Sparkles, X as XIcon } from "lucide-react";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { DialogTitle } from "@multica/ui/components/ui/dialog";
import { cn } from "@multica/ui/lib/utils";
import {
Dialog,
DialogContent,
DialogTitle,
} from "@multica/ui/components/ui/dialog";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@multica/ui/components/ui/dropdown-menu";
import { Tooltip, TooltipContent, TooltipTrigger } from "@multica/ui/components/ui/tooltip";
import { Button } from "@multica/ui/components/ui/button";
import { api, ApiError } from "@multica/core/api";
import { useWorkspaceId } from "@multica/core/hooks";
@@ -18,36 +24,22 @@ import { useCurrentWorkspace } from "@multica/core/paths";
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";
import { useFileUpload } from "@multica/core/hooks/use-file-upload";
import { useModalStore } from "@multica/core/modals";
import type { Agent } from "@multica/core/types";
import { ActorAvatar } from "../common/actor-avatar";
import { canAssignAgent } from "../issues/components/pickers/assignee-picker";
import { useAuthStore } from "@multica/core/auth";
import { memberListOptions } from "@multica/core/workspace/queries";
import {
ContentEditor,
type ContentEditorRef,
useFileDropZone,
FileDropOverlay,
} from "../editor";
// AgentCreatePanel — agent-mode body of the create-issue dialog. Renders
// only the inner content; the surrounding `<Dialog>` AND `<DialogContent>`
// (Portal + Overlay + Popup) are owned by CreateIssueDialog so mode-switching
// swaps only this body. Lifting the Portal is what eliminates the close→open
// animation flash — Base UI replays Popup enter/exit when DialogContent is
// remounted, even inside a still-open Dialog Root.
//
// `onSwitchMode` is wired by the shell — the panel calls it (no payload from
// agent → manual; the shared draft store carries description + agent).
export function AgentCreatePanel({
// QuickCreateIssueModal — a streamlined create-issue UI: pick an agent, type
// one line, submit. The agent translates the line into a `multica issue
// create` call asynchronously; the modal closes immediately and the user is
// notified via inbox when the agent finishes.
export function QuickCreateIssueModal({
onClose,
onSwitchMode,
data,
}: {
onClose: () => void;
onSwitchMode?: () => void;
data?: Record<string, unknown> | null;
}) {
const workspaceName = useCurrentWorkspace()?.name;
@@ -72,7 +64,6 @@ export function AgentCreatePanel({
const lastAgentId = useQuickCreateStore((s) => s.lastAgentId);
const setLastAgentId = useQuickCreateStore((s) => s.setLastAgentId);
const setLastMode = useCreateModeStore((s) => s.setLastMode);
const [agentId, setAgentId] = useState<string | undefined>(() => {
const seed = (data?.agent_id as string) || lastAgentId || undefined;
@@ -98,43 +89,22 @@ export function AgentCreatePanel({
);
const initialPrompt = (data?.prompt as string) || "";
// 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.
const editorRef = useRef<ContentEditorRef>(null);
const [hasContent, setHasContent] = useState(initialPrompt.trim().length > 0);
const [prompt, setPrompt] = useState(initialPrompt);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = 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
// agent receives them as embedded markdown image URLs in the prompt.
const { uploadWithToast } = useFileUpload(api);
const handleUploadFile = useCallback(
(file: File) => uploadWithToast(file),
[uploadWithToast],
);
const { isDragOver, dropZoneProps } = useFileDropZone({
onDrop: (files) => files.forEach((f) => editorRef.current?.uploadFile(f)),
});
useEffect(() => {
// Defer focus so it lands after the dialog's focus trap has settled —
// otherwise the trap can bounce focus back to the first focusable header
// button on the next tick.
const id = requestAnimationFrame(() => editorRef.current?.focus());
return () => cancelAnimationFrame(id);
textareaRef.current?.focus();
}, []);
const submit = async () => {
const md = editorRef.current?.getMarkdown()?.trim() ?? "";
if (!md || !agentId || submitting) return;
if (!prompt.trim() || !agentId || submitting) return;
setSubmitting(true);
setError(null);
try {
await api.quickCreateIssue({ agent_id: agentId, prompt: md });
await api.quickCreateIssue({ agent_id: agentId, prompt: prompt.trim() });
setLastAgentId(agentId);
setLastMode("agent");
toast.success("Sent to agent — you'll get an inbox notification when it's done", {
duration: 4000,
});
@@ -157,26 +127,39 @@ export function AgentCreatePanel({
}
};
// Switch to the manual form, carrying what the user typed over as the
// description (markdown, including any pasted images) so they don't lose
// their work. The picked agent becomes the default assignee candidate
// (still editable). We seed the shared issue-draft store directly because
// the manual panel reads its initial values from there. Persist the mode
// flip so the next `c` lands in manual.
const switchToManual = () => {
const md = editorRef.current?.getMarkdown() ?? "";
// Switch to the legacy advanced form, carrying the prompt over as the
// description so the user doesn't lose what they typed. The picked agent
// becomes the default assignee candidate (still editable). We seed the
// shared issue-draft store directly because the legacy modal reads its
// initial values from there rather than from `data`.
const switchToAdvanced = () => {
useIssueDraftStore.getState().setDraft({
description: md,
description: prompt,
...(agentId
? { assigneeType: "agent" as const, assigneeId: agentId }
: {}),
});
setLastMode("manual");
onSwitchMode?.();
useModalStore.getState().open("create-issue");
};
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
e.preventDefault();
submit();
}
};
return (
<>
<Dialog open onOpenChange={(v) => { if (!v) onClose(); }}>
<DialogContent
finalFocus={false}
showCloseButton={false}
className={cn(
"p-0 gap-0 flex flex-col overflow-hidden",
"!top-1/2 !left-1/2 !-translate-x-1/2 !-translate-y-1/2",
"!max-w-xl !w-full",
)}
>
<DialogTitle className="sr-only">Quick create issue</DialogTitle>
{/* Header */}
@@ -184,20 +167,38 @@ export function AgentCreatePanel({
<div className="flex items-center gap-1.5 text-xs">
<span className="text-muted-foreground">{workspaceName}</span>
<ChevronRight className="size-3 text-muted-foreground/50" />
<span className="font-medium">Create with agent</span>
<span className="font-medium">Quick create</span>
</div>
<div className="flex items-center gap-1">
<Tooltip>
<TooltipTrigger
render={
<button
onClick={switchToAdvanced}
className="text-xs px-2 py-1 rounded-sm opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
Advanced
</button>
}
/>
<TooltipContent side="bottom">
Open the full form with all fields
</TooltipContent>
</Tooltip>
<Tooltip>
<TooltipTrigger
render={
<button
onClick={onClose}
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
}
/>
<TooltipContent side="bottom">Close</TooltipContent>
</Tooltip>
</div>
{/* Native `title` instead of Base UI Tooltip — Tooltip opens on
keyboard focus, and the dialog's focus trap briefly lands focus
on the first focusable element on mount, causing the tooltip to
auto-pop every open. */}
<button
onClick={onClose}
title="Close"
aria-label="Close"
className="rounded-sm p-1.5 opacity-70 hover:opacity-100 hover:bg-accent/60 transition-all cursor-pointer"
>
<XIcon className="size-4" />
</button>
</div>
{/* Agent picker */}
@@ -254,23 +255,17 @@ export function AgentCreatePanel({
</DropdownMenu>
</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. */}
<div
{...dropZoneProps}
className="relative px-5 pb-3 min-h-[140px]"
>
<ContentEditor
ref={editorRef}
defaultValue={initialPrompt}
{/* Prompt textarea */}
<div className="px-5 pb-3">
<textarea
ref={textareaRef}
value={prompt}
onChange={(e) => setPrompt(e.target.value)}
onKeyDown={handleKeyDown}
placeholder='Describe the issue, e.g. "fix inbox loading slowness, assign to naiyuan, P1"'
onUpdate={(md) => setHasContent(md.trim().length > 0)}
onUploadFile={handleUploadFile}
onSubmit={submit}
debounceMs={150}
rows={5}
className="w-full resize-none bg-transparent text-sm placeholder:text-muted-foreground focus:outline-none"
/>
{isDragOver && <FileDropOverlay />}
</div>
{error && (
@@ -280,25 +275,15 @@ export function AgentCreatePanel({
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t shrink-0">
<span className="text-xs text-muted-foreground"> to submit</span>
<div className="flex items-center gap-2">
<button
type="button"
onClick={switchToManual}
title="Switch to manual create — fill the fields yourself"
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
</button>
<Button
size="sm"
onClick={submit}
disabled={!hasContent || !agentId || submitting}
>
{submitting ? "Sending…" : "Create"}
</Button>
</div>
<Button
size="sm"
onClick={submit}
disabled={!prompt.trim() || !agentId || submitting}
>
{submitting ? "Sending…" : "Create"}
</Button>
</div>
</>
</DialogContent>
</Dialog>
);
}

View File

@@ -2,7 +2,8 @@
import { useModalStore } from "@multica/core/modals";
import { CreateWorkspaceModal } from "./create-workspace";
import { CreateIssueDialog } from "./create-issue-dialog";
import { CreateIssueModal } from "./create-issue";
import { QuickCreateIssueModal } from "./quick-create-issue";
import { CreateProjectModal } from "./create-project";
import { FeedbackModal } from "./feedback";
import { SetParentIssueModal } from "./set-parent-issue";
@@ -18,12 +19,10 @@ export function ModalRegistry() {
switch (modal) {
case "create-workspace":
return <CreateWorkspaceModal onClose={close} />;
// Both modal types open the same shell so the in-modal mode switch is
// instant — only the inner panel swaps, the Dialog Root stays mounted.
case "create-issue":
return <CreateIssueDialog onClose={close} initialMode="manual" data={data} />;
return <CreateIssueModal onClose={close} data={data} />;
case "quick-create-issue":
return <CreateIssueDialog onClose={close} initialMode="agent" data={data} />;
return <QuickCreateIssueModal onClose={close} data={data} />;
case "create-project":
return <CreateProjectModal onClose={close} />;
case "feedback":

View File

@@ -72,9 +72,6 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica issue get <id> --output json` — Get full issue details (title, description, status, priority, assignee)\n")
b.WriteString("- `multica issue list [--status X] [--priority X] [--assignee X] [--limit N] [--offset N] --output json` — List issues in workspace (default limit: 50; JSON output includes `total`, `has_more` — use offset to paginate when `has_more` is true)\n")
b.WriteString("- `multica issue comment list <issue-id> [--limit N] [--offset N] [--since <RFC3339>] --output json` — List comments on an issue (supports pagination; includes id, parent_id for threading)\n")
b.WriteString("- `multica issue label list <issue-id> --output json` — List labels currently attached to an issue\n")
b.WriteString("- `multica issue subscriber list <issue-id> --output json` — List members/agents subscribed to an issue\n")
b.WriteString("- `multica label list --output json` — List all labels defined in the workspace (returns id + name + color)\n")
b.WriteString("- `multica workspace get --output json` — Get workspace details and context\n")
b.WriteString("- `multica workspace members [workspace-id] --output json` — List workspace members (user IDs, names, roles)\n")
b.WriteString("- `multica agent list --output json` — List agents in workspace\n")
@@ -87,15 +84,9 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("- `multica autopilot runs <id> [--limit N] --output json` — List execution history for an autopilot\n\n")
b.WriteString("### Write\n")
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--status X] [--assignee X] [--parent <issue-id>] [--project <project-id>] [--due-date <RFC3339>] [--attachment <path>]` — Create a new issue. `--attachment` may be repeated to upload multiple files; labels and subscribers are not accepted here, attach them after create with the commands below.\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X] [--status X] [--assignee X] [--parent <issue-id>] [--project <project-id>] [--due-date <RFC3339>]` — Update one or more issue fields in a single call. Use `--parent \"\"` to clear the parent.\n")
b.WriteString("- `multica issue status <id> <status>` — Shortcut for `issue update --status` when you only need to flip status (todo, in_progress, in_review, done, blocked, backlog, cancelled)\n")
b.WriteString("- `multica issue assign <id> --to <name>` — Assign an issue to a member or agent by name (use `--unassign` to remove assignee)\n")
b.WriteString("- `multica issue label add <issue-id> <label-id>` — Attach a label to an issue (look up the label id via `multica label list`)\n")
b.WriteString("- `multica issue label remove <issue-id> <label-id>` — Detach a label from an issue\n")
b.WriteString("- `multica issue subscriber add <issue-id> [--user <name>]` — Subscribe a member or agent to issue updates (defaults to the caller when `--user` is omitted)\n")
b.WriteString("- `multica issue subscriber remove <issue-id> [--user <name>]` — Unsubscribe a member or agent\n")
b.WriteString("- `multica issue comment add <issue-id> --content \"...\" [--parent <comment-id>] [--attachment <path>]` — Post a comment (use `--parent` to reply to a specific comment; `--attachment` may be repeated)\n")
b.WriteString("- `multica issue create --title \"...\" [--description \"...\"] [--priority X] [--assignee X] [--parent <issue-id>] [--status X]` — Create a new issue\n")
b.WriteString("- `multica issue assign <id> --to <name>` — Assign an issue to a member or agent by name (use --unassign to remove assignee)\n")
b.WriteString("- `multica issue comment add <issue-id> --content \"...\" [--parent <comment-id>]` — Post a comment (use --parent to reply to a specific comment)\n")
b.WriteString(" - **For multi-line content (anything with line breaks, paragraphs, code blocks, backticks, or quotes), you MUST pipe via stdin** — bash does NOT expand `\\n` inside double quotes, so writing `--content \"para1\\n\\npara2\"` stores the literal 4-char sequence and the comment renders without line breaks. Use a HEREDOC instead:\n")
b.WriteString("\n")
b.WriteString(" ```\n")
@@ -108,7 +99,8 @@ func buildMetaSkillContent(provider string, ctx TaskContextForEnv) string {
b.WriteString("\n")
b.WriteString(" - The same rule applies to `--description` on `multica issue create` and `multica issue update` — use `--description-stdin` and pipe a HEREDOC for any multi-line description; the inline `--description \"...\"` form is for short single-line text only.\n")
b.WriteString("- `multica issue comment delete <comment-id>` — Delete a comment\n")
b.WriteString("- `multica label create --name \"...\" --color \"#hex\"` — Define a new workspace label (use this only when the label you need does not exist yet; reuse existing labels via `multica label list` first)\n")
b.WriteString("- `multica issue status <id> <status>` — Update issue status (todo, in_progress, in_review, done, blocked)\n")
b.WriteString("- `multica issue update <id> [--title X] [--description X] [--priority X]` — Update issue fields\n")
b.WriteString("- `multica autopilot create --title \"...\" --agent <name> --mode create_issue [--description \"...\"]` — Create an autopilot\n")
b.WriteString("- `multica autopilot update <id> [--title X] [--description X] [--status active|paused]` — Update an autopilot\n")
b.WriteString("- `multica autopilot trigger <id>` — Manually trigger an autopilot to run once\n")

View File

@@ -517,23 +517,18 @@ const heartbeatHasPendingTimeout = 1 * time.Second
func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
start := time.Now()
authPath := middleware.DaemonAuthPathFromContext(r.Context())
var (
outcome = "unauth"
runtimeID string
decodeMs, runtimeLookupMs, workspaceCheckMs int64
authMs, updateMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs int64
probeSkillsTimedOut, probeImportTimedOut bool
)
defer func() {
logHeartbeatEndpointSlow(runtimeID, outcome, authPath, start, decodeMs, runtimeLookupMs, workspaceCheckMs, authMs, updateMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs, probeSkillsTimedOut, probeImportTimedOut)
logHeartbeatEndpointSlow(runtimeID, outcome, start, authMs, updateMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs, probeSkillsTimedOut, probeImportTimedOut)
}()
decodeStart := time.Now()
var req DaemonHeartbeatRequest
decodeErr := json.NewDecoder(r.Body).Decode(&req)
decodeMs = time.Since(decodeStart).Milliseconds()
if decodeErr != nil {
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
outcome = "bad_body"
writeError(w, http.StatusBadRequest, "invalid request body")
return
@@ -546,38 +541,17 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
}
runtimeID = req.RuntimeID
// Inlined and instrumented version of requireDaemonRuntimeAccess so we
// can attribute the runtime-lookup and workspace-check sub-stages
// independently in slow-logs. Together with the auth_path label set by
// DaemonAuth middleware, this lets us tell whether prod heartbeat tail
// latency is in pgx pool acquisition (runtime_lookup_ms), in the PAT
// fallback workspace-membership query (workspace_check_ms), or upstream.
runtimeUUID, ok := parseUUIDOrBadRequest(w, req.RuntimeID, "runtime_id")
// Verify the caller owns this runtime's workspace.
rt, ok := h.requireDaemonRuntimeAccess(w, r, req.RuntimeID)
if !ok {
outcome = "bad_runtime_id"
return
}
lookupStart := time.Now()
rt, lookupErr := h.Queries.GetAgentRuntime(r.Context(), runtimeUUID)
runtimeLookupMs = time.Since(lookupStart).Milliseconds()
if lookupErr != nil {
outcome = "runtime_not_found"
writeError(w, http.StatusNotFound, "runtime not found")
return
}
wsCheckStart := time.Now()
wsOK := h.requireDaemonWorkspaceAccess(w, r, uuidToString(rt.WorkspaceID))
workspaceCheckMs = time.Since(wsCheckStart).Milliseconds()
if !wsOK {
outcome = "workspace_denied"
return
}
authMs = time.Since(start).Milliseconds()
updateStart := time.Now()
_, updateErr := h.Queries.UpdateAgentRuntimeHeartbeat(r.Context(), rt.ID)
_, err := h.Queries.UpdateAgentRuntimeHeartbeat(r.Context(), rt.ID)
updateMs = time.Since(updateStart).Milliseconds()
if updateErr != nil {
if err != nil {
outcome = "error_update"
writeError(w, http.StatusInternalServerError, "heartbeat failed")
return
@@ -663,10 +637,8 @@ func (h *Handler) DaemonHeartbeat(w http.ResponseWriter, r *http.Request) {
// logHeartbeatEndpointSlow emits one structured log when /api/daemon/heartbeat
// exceeds 500ms, splitting auth / update / probe / pop phases for both queues
// so the prod tail can be attributed without flooding logs at normal rates.
// auth_ms is further decomposed into decode_ms, runtime_lookup_ms, and
// workspace_check_ms; auth_path labels which token kind authenticated the
// request ("daemon_token", "pat", or "jwt"). Mirrors logClaimEndpointSlow.
func logHeartbeatEndpointSlow(runtimeID, outcome, authPath string, start time.Time, decodeMs, runtimeLookupMs, workspaceCheckMs, authMs, updateMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs int64, probeSkillsTimedOut, probeImportTimedOut bool) {
// Mirrors logClaimEndpointSlow for consistency.
func logHeartbeatEndpointSlow(runtimeID, outcome string, start time.Time, authMs, updateMs, probeSkillsMs, popSkillsMs, probeImportMs, popImportMs int64, probeSkillsTimedOut, probeImportTimedOut bool) {
totalMs := time.Since(start).Milliseconds()
if totalMs < 500 && !probeSkillsTimedOut && !probeImportTimedOut {
return
@@ -674,12 +646,8 @@ func logHeartbeatEndpointSlow(runtimeID, outcome, authPath string, start time.Ti
slog.Info("heartbeat_endpoint slow",
"runtime_id", runtimeID,
"outcome", outcome,
"auth_path", authPath,
"total_ms", totalMs,
"auth_ms", authMs,
"decode_ms", decodeMs,
"runtime_lookup_ms", runtimeLookupMs,
"workspace_check_ms", workspaceCheckMs,
"update_ms", updateMs,
"probe_skills_ms", probeSkillsMs,
"pop_skills_ms", popSkillsMs,

View File

@@ -17,14 +17,6 @@ type daemonContextKey int
const (
ctxKeyDaemonWorkspaceID daemonContextKey = iota
ctxKeyDaemonID
ctxKeyDaemonAuthPath
)
// Daemon auth path labels exposed via context for slow-log attribution.
const (
DaemonAuthPathDaemonToken = "daemon_token"
DaemonAuthPathPAT = "pat"
DaemonAuthPathJWT = "jwt"
)
// DaemonWorkspaceIDFromContext returns the workspace ID set by DaemonAuth middleware.
@@ -39,20 +31,11 @@ func DaemonIDFromContext(ctx context.Context) string {
return id
}
// DaemonAuthPathFromContext returns which token kind authenticated this
// request — "daemon_token", "pat", or "jwt" — for telemetry. Empty when the
// request did not pass through DaemonAuth.
func DaemonAuthPathFromContext(ctx context.Context) string {
p, _ := ctx.Value(ctxKeyDaemonAuthPath).(string)
return p
}
// WithDaemonContext returns a new context with the daemon workspace ID and daemon ID set.
// This is used by tests to simulate daemon token authentication.
func WithDaemonContext(ctx context.Context, workspaceID, daemonID string) context.Context {
ctx = context.WithValue(ctx, ctxKeyDaemonWorkspaceID, workspaceID)
ctx = context.WithValue(ctx, ctxKeyDaemonID, daemonID)
ctx = context.WithValue(ctx, ctxKeyDaemonAuthPath, DaemonAuthPathDaemonToken)
return ctx
}
@@ -88,7 +71,6 @@ func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler {
ctx := context.WithValue(r.Context(), ctxKeyDaemonWorkspaceID, uuidToString(dt.WorkspaceID))
ctx = context.WithValue(ctx, ctxKeyDaemonID, dt.DaemonID)
ctx = context.WithValue(ctx, ctxKeyDaemonAuthPath, DaemonAuthPathDaemonToken)
next.ServeHTTP(w, r.WithContext(ctx))
return
}
@@ -104,8 +86,7 @@ func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler {
}
r.Header.Set("X-User-ID", uuidToString(pat.UserID))
go queries.UpdatePersonalAccessTokenLastUsed(context.Background(), pat.ID)
ctx := context.WithValue(r.Context(), ctxKeyDaemonAuthPath, DaemonAuthPathPAT)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r)
return
}
@@ -133,8 +114,7 @@ func DaemonAuth(queries *db.Queries) func(http.Handler) http.Handler {
return
}
r.Header.Set("X-User-ID", sub)
ctx := context.WithValue(r.Context(), ctxKeyDaemonAuthPath, DaemonAuthPathJWT)
next.ServeHTTP(w, r.WithContext(ctx))
next.ServeHTTP(w, r)
})
}
}