mirror of
https://github.com/multica-ai/multica.git
synced 2026-06-25 00:19:29 +02:00
Compare commits
1 Commits
fix/agent-
...
chore/remo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
bcfb98a576 |
@@ -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),
|
||||
},
|
||||
),
|
||||
);
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user