diff --git a/.env.example b/.env.example index 095a51beaf3..0472ed1e271 100644 --- a/.env.example +++ b/.env.example @@ -76,6 +76,9 @@ MULTICA_TRUSTED_PROXIES= # chat title generation (the generic OpenAI-compatible passthrough endpoints # were removed in MUL-4309 — LLM access is internal-only). When both API key # and base URL are empty the layer is disabled and callers fall back silently. +# This layer does NOT back Graph "Create with AI": graph authoring is queued as +# a task on a runtime agent the user picks, so configuring these changes +# nothing about it and leaving them empty does not disable it. # - API key for the upstream (OpenAI or any OpenAI-compatible gateway). MULTICA_LLM_API_KEY= # - Base URL of the upstream. Leave unset to use OpenAI's default diff --git a/apps/mobile/components/graphs/editor-screen.tsx b/apps/mobile/components/graphs/editor-screen.tsx index 8ea4a4de3da..e2352ed434e 100644 --- a/apps/mobile/components/graphs/editor-screen.tsx +++ b/apps/mobile/components/graphs/editor-screen.tsx @@ -67,8 +67,8 @@ export function createGraphNode( inputs: { context: { source: "run_input", path: "" } }, outputSchema: object, executionMode: "none", - maxAttempts: 2, - timeoutSeconds: 3600, + maxAttempts: 16, + timeoutSeconds: 21600, }; case "approval": return { ...base, approval: { mode: "all", approverIds: [] } }; @@ -105,7 +105,7 @@ export function createGraphNode( outputSchema: object, loop: { body: emptyGraph("Loop body"), - maxIterations: 3, + maxIterations: 16, condition: { op: "eq", left: { source: "constant", value: false }, diff --git a/apps/mobile/components/graphs/node-screen.tsx b/apps/mobile/components/graphs/node-screen.tsx index f12205b5054..897f2409c48 100644 --- a/apps/mobile/components/graphs/node-screen.tsx +++ b/apps/mobile/components/graphs/node-screen.tsx @@ -132,10 +132,10 @@ export function GraphNodeScreen() { ]} disabled={disabled} /> - {number("Maximum attempts (1–3)", node.maxAttempts ?? 2, [ + {number("Maximum attempts", node.maxAttempts ?? 16, [ "maxAttempts", ])} - {number("Timeout in seconds", node.timeoutSeconds ?? 3600, [ + {number("Timeout in seconds", node.timeoutSeconds ?? 21600, [ "timeoutSeconds", ])} diff --git a/apps/mobile/components/graphs/run-screen.tsx b/apps/mobile/components/graphs/run-screen.tsx index cc4cf7f45ce..a50f0fca7e4 100644 --- a/apps/mobile/components/graphs/run-screen.tsx +++ b/apps/mobile/components/graphs/run-screen.tsx @@ -187,7 +187,8 @@ function NodeDetail({ {node.attempts.length > 0 && ( <> - {node.attempts.length} / {node.maxAttempts} attempts + {node.attempts.length} / {node.maxAttempts + node.grantedAttempts}{" "} + attempts {node.issueId && ( - {!loading && !enabled && ( + {failureText && ( +
+

{failureText}

+ {failureCode === "generation_busy" && ( +

+ {t(($) => $.studio.retry_hint)} +

+ )} +
+ )} + {!loading && mode === "unavailable" && !disabled && (

{t(($) => $.studio.unavailable)}

@@ -174,7 +243,7 @@ export function GraphAiComposer({ + {!candidates.length ? null : !hasOnlineRuntime ? ( + + ) : ( + + )} + + + + + ); +} + +/** + * The design conversations this user left behind, on the one screen that would + * otherwise silently start another one. + * + * Discarding is the only action, and that is the honest one: the conversation + * is real and still running, but what it was designing lived in a draft + * document the client no longer has, and the alias directory cannot be + * reconstructed from the session — resuming would hand the model a directory + * that does not match the one its own history was written against. What is left + * is the handle that deletes a conversation together with its hidden carrier. + */ +function GraphUnfinishedSessions() { + const { t } = useT("graphs"); + const wsId = useWorkspaceId(); + const qc = useQueryClient(); + const sessions = useQuery(graphBuilderSessionsOptions(wsId)); + const [discarding, setDiscarding] = useState(""); + const rows = sessions.data ?? []; + if (!rows.length) return null; + + const discard = async (sessionId: string) => { + setDiscarding(sessionId); + try { + await api.deleteChatSession(sessionId); + } catch { + // Left in the list, which is the truth: it still exists. + } finally { + setDiscarding(""); + void qc.invalidateQueries({ queryKey: graphBuilderSessionKeys.list(wsId) }); + } + }; + + return ( +
+

+ {t(($) => $.studio.builder.recovery_title)} +

+

+ {t(($) => $.studio.builder.recovery_hint)} +

+ +
+ ); +} + +function SessionTimestamp({ at }: { at: string }) { + const timeAgo = useTimeAgo(); + return ( + + {timeAgo(at)} + + ); +} + +/** + * The list carries the last message still in the wire format — the user side is + * a JSON envelope holding the whole draft, the assistant side ends in a + * `` block — so both sides go through the same decoding the + * conversation itself uses. Shown raw, every row is a wall of JSON. + */ +function sessionPreview( + session: Pick< + GraphBuilderSessionSummary, + "lastMessageContent" | "lastMessageRole" + >, +): string { + const content = session.lastMessageContent; + if (!content) return ""; + return session.lastMessageRole === "user" + ? decodeGraphBuilderInput(content) + : stripGraphDraft(content); +} diff --git a/packages/views/graphs/graph-ai-workspace.test.tsx b/packages/views/graphs/graph-ai-workspace.test.tsx new file mode 100644 index 00000000000..2bc48ed6fb3 --- /dev/null +++ b/packages/views/graphs/graph-ai-workspace.test.tsx @@ -0,0 +1,368 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { + fireEvent, + render, + screen, + waitFor, + within, +} from "@testing-library/react"; +import { + createSerialDefinition, + definitionToAliases, + encodeGraphBuilderInput, + graphDraftKey, + useGraphDraftStore, + type GraphDefinition, + type GraphDraftAi, +} from "@multica/core/graphs"; +import type { Agent, ChatMessage, RuntimeDevice } from "@multica/core/types"; +import labels from "../locales/en/graphs.json"; +import { graphBuilderDirectory } from "./graph-ai-actors"; +import { GraphAiWorkspace } from "./graph-ai-workspace"; + +const mocks = vi.hoisted(() => ({ + messages: [] as ChatMessage[], + pending: false, + missing: false, + onOpenEditor: vi.fn(), + onMissing: vi.fn(), + runtimes: [] as RuntimeDevice[], + transform: undefined as ((content: string) => string) | undefined, +})); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: ( + selector: (value: unknown) => string, + options?: Record, + ) => { + let text = selector(labels); + for (const [key, value] of Object.entries(options ?? {})) + text = text.replace(`{{${key}}}`, String(value)); + return text; + }, + }), +})); +// The session, the composer, the transcript and the canvas all have their own +// behavior and their own tests. What is under test here is what this component +// does with the messages they hand it. +vi.mock("./use-graph-builder-session", () => ({ + useGraphBuilderSession: () => ({ + messages: mocks.messages, + missing: mocks.missing, + messagesLoading: false, + pendingTask: undefined, + pending: mocks.pending, + starting: false, + closing: false, + error: null, + restoreDraftRequest: null, + handleRestoreDraftApplied: vi.fn(), + start: vi.fn(), + destroy: vi.fn(async () => true), + send: vi.fn(async () => true), + stop: vi.fn(), + }), +})); +vi.mock("react-resizable-panels", () => ({ + useDefaultLayout: () => ({ defaultLayout: undefined, onLayoutChanged: vi.fn() }), +})); +vi.mock("@multica/ui/components/ui/resizable", () => ({ + ResizablePanelGroup: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ResizablePanel: ({ children }: { children: React.ReactNode }) => ( +
{children}
+ ), + ResizableHandle: () => null, +})); +vi.mock("../chat/components/chat-message-list", () => ({ + ChatMessageSkeleton: () =>

Loading the conversation

, + ChatMessageList: ({ + messages, + transformContent, + }: { + messages: { id: string; role: string; content: string }[]; + transformContent?: (content: string) => string; + }) => { + mocks.transform = transformContent; + return ( +
+ {messages.map((message) => ( +

+ {transformContent ? transformContent(message.content) : message.content} +

+ ))} +
+ ); + }, +})); +vi.mock("../chat/components/chat-input", () => ({ + ChatInput: ({ disabled }: { disabled?: boolean }) => ( + + ), +})); +vi.mock("./graph-canvas", () => ({ + GraphCanvas: ({ definition }: { definition: GraphDefinition }) => ( +
+ {definition.name}:{definition.nodes.map((node) => node.nodeKey).join(",")} +
+ ), +})); + +const agents = [ + { + id: "agent-1", + name: "Builder", + description: "", + model: "claude", + runtime_id: "runtime-1", + runtime_bound: true, + archived_at: null, + skills: [], + }, + { + id: "agent-2", + name: "Reviewer", + description: "", + model: "claude", + runtime_id: "runtime-1", + runtime_bound: true, + archived_at: null, + skills: [], + }, +] as unknown as Agent[]; +const online = { + id: "runtime-1", + status: "online", + provider: "claude", + owner_id: "user-1", + visibility: "private", +} as unknown as RuntimeDevice; + +const ai: GraphDraftAi = { + sessionId: "session-1", + agentId: "carrier-1", + executorId: "agent-1", + scopeIds: ["agent-1", "agent-2"], + approverId: "member-1", + runtimeId: "runtime-1", + appliedMessageId: null, +}; +const draftKey = graphDraftKey("ws-1", "user-1"); +const directory = graphBuilderDirectory(ai, agents, [online], "user-1"); + +function step(nodeKey: string, agentId: string) { + return { + nodeKey, + type: "agent" as const, + agentId, + instructions: `Do ${nodeKey}`, + executionMode: "none" as const, + maxAttempts: 2, + timeoutSeconds: 60, + inputs: {}, + outputSchema: { type: "object" }, + }; +} +const current = createSerialDefinition("Research", [step("research", "agent-1")], "member-1"); +const next = createSerialDefinition( + "Research and review", + [step("research", "agent-1"), step("review", "agent-2")], + "member-1", +); + +/** A reply in the wire form the contract asks for. */ +function reply(summary: string, definition: unknown, prose = "Here it is.") { + return `${prose}\n${JSON.stringify({ summary, definition })}`; +} +function aliased(definition: GraphDefinition) { + return definitionToAliases(definition, directory); +} +function turn(id: string, content: string, role: "user" | "assistant") { + return { id, role, content } as ChatMessage; +} + +/** + * The slice of the editor this component is mounted into: the definition, the + * history and the conversation come off the draft document, exactly as they do + * there. Merging is asserted through the store rather than through a spy + * because the store is where "the draft followed the conversation" lives. + */ +function Harness() { + const draft = useGraphDraftStore((state) => state.documents[draftKey]); + return ( + + useGraphDraftStore.getState().edit(draftKey, updated) + } + onOpenEditor={mocks.onOpenEditor} + onMissing={mocks.onMissing} + /> + ); +} +function draftDocument() { + return useGraphDraftStore.getState().documents[draftKey]!; +} +const canvas = () => screen.getByLabelText("Canvas").textContent; + +beforeEach(() => { + vi.clearAllMocks(); + window.localStorage.clear(); + useGraphDraftStore.setState({ documents: {} }); + useGraphDraftStore.getState().open(draftKey, current, 0); + useGraphDraftStore.getState().bindAi(draftKey, ai); + mocks.messages = []; + mocks.pending = false; + mocks.missing = false; + mocks.runtimes = [online]; +}); + +describe("Graph design workspace", () => { + it("merges a reply into the draft the canvas and the store both read", () => { + mocks.messages = [ + turn("m1", encodeGraphBuilderInput("research, then review", current, directory), "user"), + turn("m2", reply("Added a review step", aliased(next)), "assistant"), + ]; + render(); + expect(canvas()).toBe("Research and review:start,research,review,approval,end"); + expect(draftDocument().definition.name).toBe("Research and review"); + expect(screen.getByText("Added a review step")).toBeVisible(); + // The reply has been merged, so it is never merged a second time: this is + // what makes a refresh idempotent. + expect(draftDocument().ai?.appliedMessageId).toBe("m2"); + // Both of the reply's teammate aliases resolved to real agents. + const nodes = draftDocument().definition.nodes; + expect(nodes.find((node) => node.nodeKey === "review")?.agentId).toBe("agent-2"); + }); + it("reads a reply against the alias order the conversation was started with", () => { + const wire = aliased(next) as { nodes: { node_key: string; agent_id: string }[] }; + expect(wire.nodes.find((node) => node.node_key === "research")?.agent_id).toBe( + "agent_default", + ); + expect(wire.nodes.find((node) => node.node_key === "review")?.agent_id).toBe( + "agent_1", + ); + }); + it("keeps the merged draft across a reload without merging it again", () => { + mocks.messages = [ + turn("m1", encodeGraphBuilderInput("research, then review", current, directory), "user"), + turn("m2", reply("Added a review step", aliased(next)), "assistant"), + ]; + const first = render(); + expect(draftDocument().past).toHaveLength(1); + first.unmount(); + // A reload: the messages come back from the server, the draft from storage. + useGraphDraftStore.setState({ documents: {} }); + useGraphDraftStore.getState().open(draftKey, current, 0); + render(); + expect(canvas()).toBe("Research and review:start,research,review,approval,end"); + // No second merge means no second undo step and no phantom edit. + expect(draftDocument().past).toHaveLength(0); + expect(draftDocument().ai?.appliedMessageId).toBe("m2"); + }); + it("leaves a turn that proposes nothing alone", () => { + mocks.messages = [turn("m1", "What should the first step deliver?", "assistant")]; + render(); + expect(canvas()).toBe("Research:start,research,approval,end"); + expect(draftDocument().past).toHaveLength(0); + expect(screen.queryByRole("alert")).toBeNull(); + }); + it("does not judge a reply while it is still being written", () => { + mocks.pending = true; + mocks.messages = [ + turn("m1", 'One moment.{"summary":"x","definit', "assistant"), + ]; + const view = render(); + // Half a block is what a stream looks like, not a malformed draft. + expect(screen.queryByRole("alert")).toBeNull(); + expect(draftDocument().ai?.appliedMessageId).toBeNull(); + mocks.pending = false; + view.rerender(); + expect(screen.getByRole("alert")).toHaveTextContent( + "The draft in this reply is not valid JSON", + ); + }); + it("keeps the previous draft and explains a block it cannot read", () => { + mocks.messages = [ + turn("m2", 'Sorry.{"definition":{', "assistant"), + ]; + render(); + expect(canvas()).toBe("Research:start,research,approval,end"); + expect(draftDocument().past).toHaveLength(0); + expect(screen.getByRole("alert")).toHaveTextContent( + "not valid JSON, so the canvas is unchanged", + ); + expect(draftDocument().ai?.appliedMessageId).toBe("m2"); + }); + it("refuses an alias the directory does not offer and names the ones it does", () => { + const wire = aliased(next) as { nodes: { node_key: string; agent_id: string }[] }; + wire.nodes.find((node) => node.node_key === "review")!.agent_id = "agent_9"; + mocks.messages = [turn("m2", reply("Added a review step", wire), "assistant")]; + render(); + expect(canvas()).toBe("Research:start,research,approval,end"); + const alert = screen.getByRole("alert"); + expect(alert).toHaveTextContent("agent_9"); + expect(alert).toHaveTextContent( + "Available teammates: agent_1, agent_default, approver_default", + ); + }); + it("hides the block from the transcript, mid-stream included", () => { + mocks.messages = [ + turn("m2", 'Here it is.\n{"definition":{"nodes"', "assistant"), + ]; + render(); + expect(mocks.transform).toBeTypeOf("function"); + // What the reader sees: the prose, and nothing of the payload. + expect(screen.getByLabelText("Transcript")).toHaveTextContent("Here it is."); + expect(screen.getByLabelText("Transcript")).not.toHaveTextContent( + "graph_draft", + ); + }); + it("undoes a merge back to the previous version", () => { + mocks.messages = [ + turn("m2", reply("Added a review step", aliased(next)), "assistant"), + ]; + render(); + fireEvent.click(screen.getByRole("button", { name: "Undo" })); + expect(canvas()).toBe("Research:start,research,approval,end"); + }); + it("holds the conversation while its runtime is offline", () => { + mocks.runtimes = [{ ...online, status: "offline" } as unknown as RuntimeDevice]; + render(); + expect(screen.getByRole("button", { name: "Send message" })).toBeDisabled(); + expect(screen.getByText("Runtime offline")).toBeVisible(); + }); + it("hands a conversation the server no longer has back to the caller", () => { + mocks.missing = true; + render(); + expect(mocks.onMissing).toHaveBeenCalledOnce(); + }); +}); + +describe("Graph design workspace exit", () => { + it("keeps the draft but forgets the conversation once the server confirms", async () => { + mocks.messages = [turn("m2", reply("Added a review step", aliased(next)), "assistant")]; + render(); + fireEvent.click(screen.getByRole("button", { name: "Exit" })); + fireEvent.click( + within(screen.getByRole("alertdialog")).getByRole("button", { + name: "Exit", + }), + ); + await waitFor(() => + expect(useGraphDraftStore.getState().documents[draftKey]?.ai).toBeNull(), + ); + // Ending the conversation does not undo the work it produced. + expect(draftDocument().definition.name).toBe("Research and review"); + }); +}); diff --git a/packages/views/graphs/graph-ai-workspace.tsx b/packages/views/graphs/graph-ai-workspace.tsx new file mode 100644 index 00000000000..e3c219be6dd --- /dev/null +++ b/packages/views/graphs/graph-ai-workspace.tsx @@ -0,0 +1,425 @@ +"use client"; + +import { useEffect, useMemo, useRef, useState } from "react"; +import { useDefaultLayout } from "react-resizable-panels"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@multica/ui/components/ui/alert-dialog"; +import { + ResizableHandle, + ResizablePanel, + ResizablePanelGroup, +} from "@multica/ui/components/ui/resizable"; +import { Button } from "@multica/ui/components/ui/button"; +import { cn } from "@multica/ui/lib/utils"; +import { + decodeGraphBuilderInput, + encodeGraphBuilderInput, + hasGraphDraftBlock, + mergeGraphDraft, + parseGraphDraft, + stripGraphDraft, + useGraphDraftStore, + type GraphDefinition, + type GraphDraftAi, + type GraphDraftMergeFailure, +} from "@multica/core/graphs"; +import type { Agent, RuntimeDevice } from "@multica/core/types"; +import { ChatInput } from "../chat/components/chat-input"; +import { + ChatMessageList, + ChatMessageSkeleton, +} from "../chat/components/chat-message-list"; +import { useT } from "../i18n"; +import { graphBuilderDirectory } from "./graph-ai-actors"; +import { GraphCanvas } from "./graph-canvas"; +import { useGraphBuilderSession } from "./use-graph-builder-session"; + +interface MergeError { + messageId: string; + reason: GraphDraftMergeFailure; + alias?: string; + availableAliases?: string[]; + detail?: string; +} + +/** + * One design conversation: the chat on the left, the draft it is writing on the + * right. + * + * The draft is merged into the graph draft store the moment a reply lands + * (C3), so the canvas is not a preview of a proposal to accept or reject — it is + * the draft, and saving it is the editor's existing job. That is the whole + * difference from the one-shot pipeline this replaced: saying what to change is + * the edit, and every turn is one undo step. + * + * Mounted with `key={sessionId}`, so every piece of state here belongs to + * exactly one conversation and a remount is the only reset that cannot forget a + * field. + */ +export function GraphAiWorkspace({ + draftKey, + ai, + definition, + agents, + runtimes, + currentUserId, + canUndo, + onApplyDefinition, + onOpenEditor, + onMissing, +}: { + draftKey: string; + ai: GraphDraftAi; + definition: GraphDefinition; + agents: Agent[]; + runtimes: RuntimeDevice[]; + currentUserId: string | null; + canUndo: boolean; + /** Applies a merged definition, with the editor's validation resets. */ + onApplyDefinition: (definition: GraphDefinition) => void; + onOpenEditor: () => void; + /** The conversation no longer exists; the caller unbinds and starts over. */ + onMissing: () => void; +}) { + const { t } = useT("graphs"); + const { defaultLayout, onLayoutChanged } = useDefaultLayout({ + id: "multica_graph_builder_layout", + }); + const [confirmingExit, setConfirmingExit] = useState(false); + const [summary, setSummary] = useState(""); + const [mergeError, setMergeError] = useState(null); + + const directory = useMemo( + () => graphBuilderDirectory(ai, agents, runtimes, currentUserId), + [ai, agents, runtimes, currentUserId], + ); + const runtime = runtimes.find((device) => device.id === ai.runtimeId) ?? null; + const runtimeOnline = runtime?.status === "online"; + + // Read through refs so the encoder and the apply callback stay current + // without making the session hook or the merge effect depend on every render. + const encodeRef = useRef({ definition, directory }); + encodeRef.current = { definition, directory }; + const applyRef = useRef(onApplyDefinition); + applyRef.current = onApplyDefinition; + + const builder = useGraphBuilderSession({ + sessionId: ai.sessionId, + encodeInput: (text) => + encodeGraphBuilderInput( + text, + encodeRef.current.definition, + encodeRef.current.directory, + ), + }); + const { messages, pending } = builder; + + // The latest assistant reply, read through its scalar identity and content so + // a realtime update that mutates the cached array in place still re-runs this. + const latest = useMemo(() => { + const reversed = [...messages].reverse(); + return reversed.find((message) => message.role === "assistant"); + }, [messages]); + const latestId = latest?.id; + const latestContent = latest?.content; + const appliedMessageId = ai.appliedMessageId; + + useEffect(() => { + // A reply that is still being written is not a malformed one: an unclosed + // block is exactly what half a stream looks like, so no verdict is reached + // until the turn is over. Without this, every streamed frame would be + // judged — and marked applied — as unparsable JSON. + if (pending) return; + if (!latestId || !latestContent) return; + if (latestId === appliedMessageId) return; + const store = useGraphDraftStore.getState(); + const payload = parseGraphDraft(latestContent); + if (!payload) { + // A turn that proposed nothing is a legitimate answer to a question; a + // turn that opened a block and produced nothing usable is not, and it is + // recorded so the failure is reported once instead of on every frame. + if (!hasGraphDraftBlock(latestContent)) return; + store.markAiApplied(draftKey, latestId); + setMergeError({ messageId: latestId, reason: "unparsable" }); + return; + } + const merged = mergeGraphDraft( + encodeRef.current.definition, + payload, + encodeRef.current.directory, + ); + // Recorded whether or not the merge succeeded: a rejected reply has been + // judged, and retrying it on every render would never change the answer. + store.markAiApplied(draftKey, latestId); + if (merged.ok) { + applyRef.current(merged.definition); + setSummary(merged.summary ?? ""); + setMergeError(null); + return; + } + setMergeError({ + messageId: latestId, + reason: merged.reason, + ...(merged.alias ? { alias: merged.alias } : {}), + ...(merged.availableAliases + ? { availableAliases: merged.availableAliases } + : {}), + ...(merged.detail ? { detail: merged.detail } : {}), + }); + }, [appliedMessageId, draftKey, latestContent, latestId, pending]); + + useEffect(() => { + if (builder.missing) onMissing(); + }, [builder.missing, onMissing]); + + const displayMessages = useMemo( + () => + messages.map((message) => ({ + ...message, + content: + message.role === "user" + ? decodeGraphBuilderInput(message.content) + : stripGraphDraft(message.content), + })), + [messages], + ); + + const examples = [ + t(($) => $.studio.example_research), + t(($) => $.studio.example_release), + t(($) => $.studio.example_content), + ]; + + const exit = async () => { + if (!(await builder.destroy())) return; + setConfirmingExit(false); + useGraphDraftStore.getState().clearAi(draftKey); + }; + + const mergeText = mergeError + ? { + unparsable: t(($) => $.studio.builder.merge_unparsable), + invalid: t(($) => $.studio.builder.merge_invalid), + too_large: t(($) => $.studio.builder.merge_too_large), + unknown_alias: t(($) => $.studio.builder.merge_unknown_alias, { + alias: mergeError.alias ?? "", + available: (mergeError.availableAliases ?? []).join(", "), + }), + }[mergeError.reason] + : ""; + + return ( + <> + {/* The group lives here, not on the route, so its two panels are its only + children. A group whose children alternate between one and two panels + cannot keep a single persisted layout straight. */} + + +
+
+
+

+ {t(($) => $.studio.builder.chat_title)} +

+

+ {t(($) => $.studio.builder.chat_hint)} +

+
+
+
+
+ + {builder.messagesLoading ? ( + + ) : messages.length > 0 || pending ? ( + + ) : ( +
+
+

+ {t(($) => $.studio.builder.empty_title)} +

+

+ {t(($) => $.studio.builder.empty_description)} +

+
+ {examples.map((example) => ( + + ))} +
+
+
+ )} + + {builder.error ? ( +
+ {builder.error} +
+ ) : null} + + + builder.send(content, commitInput) + } + onStop={() => void builder.stop()} + isRunning={pending} + disabled={!runtimeOnline} + agentName={t(($) => $.studio.builder.chat_title)} + draftKeyOverride={`graph-builder:${ai.sessionId}`} + editorKeyOverride={`graph-builder:${ai.sessionId}`} + restoreDraftRequest={builder.restoreDraftRequest} + onRestoreDraftApplied={builder.handleRestoreDraftApplied} + /> +
+
+ + + + +
+
+
+

+ {t(($) => $.studio.builder.canvas_title)} +

+

+ {t(($) => $.studio.builder.canvas_hint)} +

+
+ +
+
+ {summary && ( +

+ {summary} +

+ )} + {mergeError && ( +
+

{mergeText}

+ {mergeError.detail && ( +

+ {mergeError.detail} +

+ )} +
+ )} +
+ +
+
+
+

+ {t(($) => $.studio.builder.footer_hint)} +

+
+ + +
+
+
+
+
+ + { + if (!open && !builder.closing) setConfirmingExit(false); + }} + > + + + + {t(($) => $.studio.builder.exit_title)} + + + {t(($) => $.studio.builder.exit_confirm)} + + + + + {t(($) => $.studio.builder.exit_cancel)} + + { + // Keep the dialog up while the delete is in flight; it closes + // from `exit` only once the server has accepted. + event.preventDefault(); + void exit(); + }} + disabled={builder.closing} + > + {t(($) => $.studio.builder.exit)} + + + + + + ); +} diff --git a/packages/views/graphs/graph-canvas.tsx b/packages/views/graphs/graph-canvas.tsx index 1b37d6d5936..2f1a89ac529 100644 --- a/packages/views/graphs/graph-canvas.tsx +++ b/packages/views/graphs/graph-canvas.tsx @@ -187,6 +187,8 @@ export function GraphCanvas({ disabled, onError, states, + details, + overview, }: { definition: GraphDefinition; onChange?: (definition: GraphDefinition) => void; @@ -195,6 +197,15 @@ export function GraphCanvas({ disabled?: boolean; onError?: (error: Error) => void; states?: Record; + /** Per-node line shown under the label. The run page passes what a node produced. */ + details?: Record; + /** + * Read the graph as a whole: ignore the viewport whoever last edited the + * draft left behind, and let the fit zoom out far enough to include every + * node. A run is read as a shape, so half of it hidden past the pane edge is + * the worst outcome; an editor is read node by node, so it stays legible. + */ + overview?: boolean; }) { const labels = useGraphNodeLabels(); const { t } = useT("graphs"); @@ -214,9 +225,10 @@ export function GraphCanvas({ node, label: labels[node.type] ?? node.type, detail: - node.instructions?.split(/\r?\n/)[0] ?? - node.http?.url ?? - node.script?.language ?? + details?.[node.nodeKey] || + node.instructions?.split(/\r?\n/)[0] || + node.http?.url || + node.script?.language || "", state: states?.[node.nodeKey], }, @@ -267,9 +279,9 @@ export function GraphCanvas({ edges={edges} nodeTypes={nodeTypes} style={style} - fitView={!definition.layout?.viewport} - fitViewOptions={{ minZoom: 0.65, maxZoom: 1, padding: 0.12 }} - defaultViewport={definition.layout?.viewport} + fitView={overview || !definition.layout?.viewport} + fitViewOptions={{ minZoom: overview ? 0.3 : 0.65, maxZoom: 1, padding: 0.12 }} + defaultViewport={overview ? undefined : definition.layout?.viewport} minZoom={0.1} maxZoom={4} nodesDraggable={editable} diff --git a/packages/views/graphs/graph-editor.test.tsx b/packages/views/graphs/graph-editor.test.tsx index 0fb5414bc5b..a10ed9bbc92 100644 --- a/packages/views/graphs/graph-editor.test.tsx +++ b/packages/views/graphs/graph-editor.test.tsx @@ -24,6 +24,18 @@ const mocks = vi.hoisted(() => ({ save: vi.fn(), generate: vi.fn(), enabled: true, + // Every existing case here exercises the legacy path; chat cases opt in. + builderEnabled: false, + runtimes: [] as unknown[], + agents: [ + { + id: "agent-1", + name: "Builder", + model: "deepseek", + custom_args: ["--verbose"], + runtime_config: { effort: "high" }, + }, + ], replace: vi.fn(), actor: { userId: "user-1", role: "admin", memberId: "member-1" }, })); @@ -53,15 +65,7 @@ vi.mock("@multica/core/workspace/queries", () => ({ agentListOptions: () => ({ queryKey: ["agents"], queryFn: async () => [], - initialData: [ - { - id: "agent-1", - name: "Builder", - model: "deepseek", - custom_args: ["--verbose"], - runtime_config: { effort: "high" }, - }, - ], + initialData: mocks.agents, }), memberListOptions: () => ({ queryKey: ["members"], @@ -92,10 +96,51 @@ vi.mock("@multica/core/graphs", async (original) => ({ useGenerateGraph: () => ({ mutateAsync: mocks.generate }), graphGenerationOptions: () => ({ queryKey: ["generation-options"], - queryFn: async () => ({ enabled: mocks.enabled }), - initialData: { enabled: mocks.enabled }, + queryFn: async () => ({ + enabled: mocks.enabled, + builderEnabled: mocks.builderEnabled, + }), + initialData: { + enabled: mocks.enabled, + builderEnabled: mocks.builderEnabled, + }, }), })); +vi.mock("@multica/core/runtimes", () => ({ + runtimeListOptions: () => ({ + queryKey: ["runtimes"], + queryFn: async () => [], + initialData: mocks.runtimes, + }), +})); +// The two AI modes are their own units, with their own tests; these cases are +// about which one the editor mounts and what it hands them. +vi.mock("./graph-ai-setup-panel", () => ({ + GraphAiSetupPanel: ({ onManual }: { onManual: () => void }) => ( +
+

Setup step

+ +
+ ), +})); +vi.mock("./graph-ai-workspace", () => ({ + GraphAiWorkspace: ({ + ai, + onMissing, + }: { + ai: { sessionId: string }; + onMissing: () => void; + }) => ( +
+

{ai.sessionId}

+ +
+ ), +})); // Canvas pointer/drag behavior is covered in browser acceptance; these tests isolate document state and proposal semantics. vi.mock("./graph-canvas", () => ({ useGraphNodeLabels: () => ({ @@ -179,17 +224,50 @@ function generate() { }); fireEvent.click(screen.getByRole("button", { name: "Generate workflow" })); } +function agent(id: string, name: string) { + return { + id, + name, + model: "deepseek", + custom_args: [] as string[], + runtime_config: { effort: "high" }, + }; +} const draftKey = graphDraftKey("ws-1", "user-1"); beforeEach(() => { vi.clearAllMocks(); window.localStorage.clear(); useGraphDraftStore.setState({ documents: {} }); mocks.enabled = true; + mocks.builderEnabled = false; + mocks.runtimes = []; + mocks.agents = [agent("agent-1", "Builder")]; mocks.actor.role = "admin"; mocks.save.mockResolvedValue({ id: "graph-1", revision: 1 }); }); describe("AI graph proposals", () => { + it("defaults agent nodes to 16 attempts and six hours without fixed upper limits", () => { + manualEditor(); + expect(screen.getByLabelText(labels.editor.attempts)).toHaveValue(16); + expect(screen.getByLabelText(labels.editor.timeout)).toHaveValue(21600); + expect(screen.getByLabelText(labels.editor.attempts)).not.toHaveAttribute( + "max", + ); + expect(screen.getByLabelText(labels.editor.timeout)).not.toHaveAttribute("max"); + fireEvent.change(screen.getByLabelText(labels.editor.attempts), { + target: { value: "100" }, + }); + fireEvent.change(screen.getByLabelText(labels.editor.timeout), { + target: { value: "172800" }, + }); + expect( + useGraphDraftStore.getState().documents[draftKey]?.definition.nodes[1], + ).toMatchObject({ maxAttempts: 100, timeoutSeconds: 172800 }); + fireEvent.click(screen.getByRole("button", { name: "Add node" })); + expect(screen.getByLabelText(labels.editor.attempts)).toHaveValue(16); + expect(screen.getByLabelText(labels.editor.timeout)).toHaveValue(21600); + }); it("edits a readable step name without changing execution bindings", () => { manualEditor(); fireEvent.change(screen.getByLabelText("Step name"), { @@ -313,6 +391,52 @@ describe("AI graph proposals", () => { }); }); +describe("Chat authoring", () => { + beforeEach(() => { + mocks.builderEnabled = true; + }); + it("hands AI mode to the setup step until a conversation exists", () => { + editor(); + expect(screen.getByText("Setup step")).toBeVisible(); + // The one-shot form and its request state belong to the other path. + expect( + screen.queryByRole("button", { name: "Generate workflow" }), + ).toBeNull(); + expect( + screen.queryByRole("combobox", { name: "Workflow agent" }), + ).toBeNull(); + }); + it("opens the bound conversation and falls back to the setup step without it", () => { + editor(); + act(() => + useGraphDraftStore.getState().bindAi(draftKey, { + sessionId: "session-1", + agentId: "carrier-1", + executorId: "agent-1", + scopeIds: ["agent-1"], + approverId: "member-1", + runtimeId: "runtime-1", + appliedMessageId: null, + }), + ); + expect(screen.getByLabelText("Design conversation")).toHaveTextContent( + "session-1", + ); + fireEvent.click(screen.getByRole("button", { name: "Conversation lost" })); + expect(screen.getByText("Setup step")).toBeVisible(); + expect(useGraphDraftStore.getState().documents[draftKey]?.ai).toBeNull(); + }); + it("keeps the one-shot form on a server that has no builder", () => { + mocks.builderEnabled = false; + editor(); + expect( + screen.getByRole("button", { name: "Generate workflow" }), + ).toBeVisible(); + expect(screen.queryByText("Setup step")).toBeNull(); + }); +}); + + describe("Graph production editor", () => { it("recovers a workspace/user draft after unmount and offers explicit discard", () => { const first = manualEditor(); diff --git a/packages/views/graphs/graph-editor.tsx b/packages/views/graphs/graph-editor.tsx index 157d27df3b3..93f92cc7a0d 100644 --- a/packages/views/graphs/graph-editor.tsx +++ b/packages/views/graphs/graph-editor.tsx @@ -20,6 +20,7 @@ import { exportGraphDefinition, graphDefinitionChanges, graphDraftKey, + graphGenerationMode, graphGenerationOptions, importGraphDefinition, layoutGraph, @@ -35,6 +36,8 @@ import { type GraphRecord, } from "@multica/core/graphs"; import { errorCode } from "@multica/core/api/client"; +import { api } from "@multica/core/api"; +import { runtimeListOptions } from "@multica/core/runtimes"; import { agentListOptions, memberListOptions, @@ -51,6 +54,8 @@ import { useT } from "../i18n"; import { GraphValueView } from "./graph-result-view"; import { Field, GraphError, useGraphActor, useGraphCommandKey } from "./shared"; import { GraphAiComposer } from "./graph-ai-composer"; +import { GraphAiSetupPanel } from "./graph-ai-setup-panel"; +import { GraphAiWorkspace } from "./graph-ai-workspace"; import { GraphCanvas, useGraphNodeLabels } from "./graph-canvas"; import { GraphNodeInspector } from "./graph-node-inspector"; import { GraphSchemaBuilder } from "./graph-schema-fields"; @@ -70,8 +75,8 @@ function blankDefinition(name = ""): GraphDefinition { inputs: { context: { source: "run_input", path: "" } }, outputSchema: objectSchema, executionMode: "none", - maxAttempts: 2, - timeoutSeconds: 3600, + maxAttempts: 16, + timeoutSeconds: 21600, }, ], "", @@ -131,7 +136,7 @@ function newNode(type: string, nodeKey: string): GraphNodeDefinition { outputSchema: objectSchema, loop: { body: emptyBody(nodeKey), - maxIterations: 3, + maxIterations: 16, condition: { op: "eq", left: { source: "constant", value: false }, @@ -213,7 +218,8 @@ export function GraphEditor({ navigation = useNavigation(), paths = useWorkspacePaths(); const agents = useQuery(agentListOptions(wsId)), - members = useQuery(memberListOptions(wsId)); + members = useQuery(memberListOptions(wsId)), + runtimes = useQuery(runtimeListOptions(wsId)); const generationOptions = useQuery(graphGenerationOptions(wsId)); const generate = useGenerateGraph(wsId), save = useSaveGraph(wsId), @@ -235,8 +241,11 @@ export function GraphEditor({ const [mode, setMode] = useState<"ai" | "editor">(graph ? "editor" : "ai"); const [localError, setLocalError] = useState(null), [definitionJSON, setDefinitionJSON] = useState(""); + // Only the legacy path still owns a request: it is one prompt, one answer, + // and an agent the user names, so it keeps its own small state. The chat path + // keeps all of it in the conversation, on the server. const [prompt, setPrompt] = useState(""), - [generationAgentId, setGenerationAgentId] = useState(""), + [generationDefaultAgentId, setGenerationDefaultAgentId] = useState(""), [generationApproverId, setGenerationApproverId] = useState(""); const [messages, setMessages] = useState< NonNullable @@ -272,12 +281,15 @@ export function GraphEditor({ (node) => node.nodeKey === key, )?.type === "loop", ); - const generationAgents = (agents.data ?? []).filter( - (agent) => !agent.archived_at, + const generationAgents = useMemo( + () => (agents.data ?? []).filter((agent) => !agent.archived_at), + [agents.data], ); - const selectedGenerationAgent = - generationAgentId || - (generationAgents.length === 1 ? generationAgents[0]!.id : ""); + const generationMode = graphGenerationMode(generationOptions.data); + // The chat path is the only one this build authors with; the legacy path is + // what remains for a deployment whose server has no builder at all, and it + // still names a single agent for its one request. + const chatAuthoring = mode === "ai" && generationMode === "builder"; const selectedGenerationApprover = generationApproverId || (members.data?.some((member) => member.id === actor.memberId) @@ -302,6 +314,13 @@ export function GraphEditor({ window.addEventListener("beforeunload", warn); return () => window.removeEventListener("beforeunload", warn); }, [draft?.dirty, draft?.storageError, draft?.storageConflict]); + // A workspace with exactly one agent preselects it for the legacy request; + // with more than one the choice belongs to the user. + useEffect(() => { + if (generationAgents.length !== 1) return; + const only = generationAgents[0]!.id; + setGenerationDefaultAgentId((current) => current || only); + }, [generationAgents]); const change = (next: GraphDefinition) => { useGraphDraftStore.getState().edit(draftKey, next); validate.reset(); @@ -312,6 +331,12 @@ export function GraphEditor({ change(replaceScope(definition, scopePath, { ...next, schemaVersion: 2 })); async function saveDraft() { if (disabled || draft?.conflict || draft?.storageConflict) return; + // Read before `saved`, which clears the draft document this comes from. + // The conversation that designed a new graph is retired with it: the draft + // key follows the graph to `:` from here on, so a session left running + // would be one nothing points at. Best-effort — the graph is about to be + // committed, and cleanup failing must not turn that into a retryable error. + const designedBy = !graph ? (draft?.ai?.sessionId ?? null) : null; try { const request = { definition, @@ -325,6 +350,14 @@ export function GraphEditor({ useGraphDraftStore .getState() .saved(draftKey, definition, result.revision); + if (designedBy) { + try { + await api.setChatSessionArchived(designedBy, true); + } catch { + // Archived, not deleted, would have been tidier; a failed archive + // leaves a listed conversation the user can discard by hand. + } + } validate.reset(); toast.success(t(($) => $.page.saved)); if (!graph) navigation.replace(paths.graphDetail(result.id)); @@ -333,8 +366,7 @@ export function GraphEditor({ } } async function generateProposal() { - if (generating || disabled || generationOptions.data?.enabled !== true) - return; + if (generating || disabled || generationMode !== "legacy") return; setGenerating(true); setLocalError(null); const current = @@ -347,7 +379,7 @@ export function GraphEditor({ ].slice(-20); const result = await generate.mutateAsync({ prompt: prompt.trim(), - agentId: selectedGenerationAgent, + agentId: generationDefaultAgentId, approverId: selectedGenerationApprover, ...(current.name.trim() || draft?.dirty ? { currentDefinition: current } @@ -469,7 +501,13 @@ export function GraphEditor({ } }} > -
+ {/* The chat workspace is two panels that each own their own scrolling, so + in that one mode this column stops being the page scroller. */} +
{mode === "ai" ? ( - <> - void generateProposal()} - onManual={() => setMode("editor")} - /> - {messages.length > 0 && ( -
-

- {t(($) => $.production.conversation)} -

- {messages.map((message, index) => ( -

- {message.content} -

- ))} -
- )} - + generationMode === "builder" ? ( + draft?.ai ? ( + // Keyed by the conversation: everything inside — which reply has + // been merged, the composer, the encoded context — belongs to one + // session, so a new one has to be a remount. + { + setMode("editor"); + setScopePath([]); + setSelectedKey("start"); + }} + onMissing={() => + useGraphDraftStore.getState().clearAi(draftKey) + } + /> + ) : ( + setMode("editor")} + /> + ) + ) : ( + <> + void generateProposal()} + onManual={() => setMode("editor")} + /> + {messages.length > 0 && ( +
+

+ {t(($) => $.production.conversation)} +

+ {messages.map((message, index) => ( +

+ {message.content} +

+ ))} +
+ )} + + ) ) : (
diff --git a/packages/views/graphs/graph-node-inspector.tsx b/packages/views/graphs/graph-node-inspector.tsx index b87ce7e2452..4fe64d5fd57 100644 --- a/packages/views/graphs/graph-node-inspector.tsx +++ b/packages/views/graphs/graph-node-inspector.tsx @@ -209,8 +209,7 @@ export function GraphNodeInspector({ id={id} type="number" min={1} - max={3} - value={node.maxAttempts ?? 1} + value={node.maxAttempts ?? 16} onChange={(event) => patch({ maxAttempts: Number(event.target.value) }) } @@ -223,8 +222,7 @@ export function GraphNodeInspector({ id={id} type="number" min={1} - max={86400} - value={node.timeoutSeconds ?? 3600} + value={node.timeoutSeconds ?? 21600} onChange={(event) => patch({ timeoutSeconds: Number(event.target.value) }) } diff --git a/packages/views/graphs/graph-result-view.tsx b/packages/views/graphs/graph-result-view.tsx index 81714e62b4c..4e3707632da 100644 --- a/packages/views/graphs/graph-result-view.tsx +++ b/packages/views/graphs/graph-result-view.tsx @@ -1,5 +1,6 @@ "use client"; -import { FileText, GitCommitHorizontal } from "lucide-react"; +import { useState } from "react"; +import { ChevronRight, FileText, GitCommitHorizontal } from "lucide-react"; import { asSchema, type JsonObject, @@ -11,20 +12,57 @@ import { ReadonlyContent } from "../editor/readonly-content"; import { useT } from "../i18n"; import { JsonDetails } from "./shared"; -export { graphArtifactLink } from "@multica/core/graphs/artifacts"; -import { graphArtifactLink } from "@multica/core/graphs/artifacts"; +export { + graphArtifactLink, + parseGraphUrl, +} from "@multica/core/graphs/artifacts"; +import { graphArtifactLink, parseGraphUrl } from "@multica/core/graphs/artifacts"; -export function GraphValueView({ - value, - schema = {}, - depth = 0, -}: { - value: unknown; - schema?: JsonObject; - depth?: number; -}) { +/** + * A collection longer than this is folded. Fifty-seven sources are context, not + * the answer, and rendering each as its own bordered row is what turned a run + * page into ninety screens. A few leading entries stay visible so the reader + * can see what kind of thing the list holds. + */ +const FOLD_AT = 8; +const FOLD_PREVIEW = 3; + +function isPlainObject(value: unknown): value is JsonObject { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * A list of objects sharing a few keys is a table, not a stack of cards. Run + * output is mostly this shape — findings, claims, metrics — so this one rule + * removes the bulk of a long page. + */ +function tableColumns(items: unknown[]): string[] | null { + if (items.length < 3) return null; + const keys: string[] = []; + for (const item of items) { + if (!isPlainObject(item)) return null; + for (const key of Object.keys(item)) { + if (!keys.includes(key)) keys.push(key); + } + } + return keys.length >= 2 && keys.length <= 6 ? keys : null; +} + +/** + * Scalars before collections, order otherwise preserved. A report is read + * headline-first: the conclusion and the caveats are what the reader came for, + * and the evidence lists are the appendix. Agents emit these keys in whatever + * order they composed them, which is how a conclusion ends up below four + * thousand pixels of URLs. + */ +function orderEntries(entries: [string, unknown][]): [string, unknown][] { + const rank = (value: unknown) => (isPlainObject(value) || Array.isArray(value) ? 1 : 0); + return [...entries].sort(([, a], [, b]) => rank(a) - rank(b)); +} + +function useResultLabels(): Record { const { t } = useT("graphs"); - const labels: Record = { + return { summary: t(($) => $.result_labels.summary), results: t(($) => $.result_labels.results), findings: t(($) => $.result_labels.findings), @@ -34,15 +72,211 @@ export function GraphValueView({ input: t(($) => $.result_labels.input), output: t(($) => $.result_labels.output), }; +} + +function fieldLabel( + key: string, + field: JsonObject, + labels: Record, +): string { + if (typeof field.title === "string" && field.title) return field.title; + if (typeof field.description === "string" && field.description) + return field.description; + return labels[key] ?? key.replaceAll("_", " "); +} + +function Disclosure({ + open, + count, + onToggle, +}: { + open: boolean; + count: number; + onToggle: () => void; +}) { + const { t } = useT("graphs"); + return ( + + ); +} + +/** A cell has to survive being one row of a table: bounded, never a paragraph wall. */ +function CellValue({ value }: { value: unknown }) { + if (value === null || value === undefined) + return —; + if ( + typeof value === "string" || + typeof value === "number" || + typeof value === "boolean" + ) + return ( + + {String(value)} + + ); + return ( + + {JSON.stringify(value)} + + ); +} + +function ValueTable({ + items, + columns, + schema, +}: { + items: unknown[]; + columns: string[]; + schema: JsonObject; +}) { + const labels = useResultLabels(); + const properties = asSchema(schema.properties); + return ( +
+ + + + {columns.map((key) => ( + + ))} + + + + {items.map((item, index) => ( + + {columns.map((key) => ( + + ))} + + ))} + +
+ {fieldLabel(key, asSchema(properties[key]), labels)} +
+ +
+
+ ); +} + +/** Objects that are not uniform enough for a table still read better as records than as cards. */ +function ValueRecords({ + items, + schema, + depth, +}: { + items: unknown[]; + schema: JsonObject; + depth: number; +}) { + return ( +
    + {items.map((item, index) => ( +
  1. + +
  2. + ))} +
+ ); +} + +function ValueList({ + value, + schema, + depth, +}: { + value: unknown[]; + schema: JsonObject; + depth: number; +}) { + const [open, setOpen] = useState(false); + const folded = value.length > FOLD_AT; + const shown = folded && !open ? value.slice(0, FOLD_PREVIEW) : value; + // Columns come from the whole list, not the preview: a key that first appears + // in a hidden row would otherwise lose its column and its values with it. + const columns = tableColumns(value); + return ( +
+ {columns ? ( + + ) : shown.every((item) => !isPlainObject(item) && !Array.isArray(item)) ? ( +
    + {shown.map((item, index) => ( +
  • + +
  • + ))} +
+ ) : ( + + )} + {folded && ( + setOpen((current) => !current)} + /> + )} +
+ ); +} + +export function GraphValueView({ + value, + schema = {}, + depth = 0, +}: { + value: unknown; + schema?: JsonObject; + depth?: number; +}) { + const labels = useResultLabels(); if (value === null || value === undefined) return —; - if (typeof value === "string") + if (typeof value === "string") { + const url = parseGraphUrl(value); + if (url) + return ( + + {value} + + ); return ( ); + } if (typeof value === "number" || typeof value === "boolean") return ( @@ -55,33 +289,22 @@ export function GraphValueView({ {JSON.stringify(value, null, 2)} ); - if (Array.isArray(value)) - return ( -
    - {value.map((item, index) => ( -
  1. - -
  2. - ))} -
- ); + if (Array.isArray(value)) { + if (!value.length) + return —; + return ; + } + const entries = orderEntries(Object.entries(asSchema(value))); + if (!entries.length) + return —; return ( -
- {Object.entries(asSchema(value)).map(([key, item]) => { +
+ {entries.map(([key, item]) => { const field = asSchema(asSchema(schema.properties)[key]); - const label = - (typeof field.title === "string" && field.title) || - (typeof field.description === "string" && field.description) || - labels[key] || - key.replaceAll("_", " "); return (
- {label} + {fieldLabel(key, field, labels)}
@@ -92,6 +315,7 @@ export function GraphValueView({
); } + export function GraphArtifactCards({ artifacts, repositories = [], diff --git a/packages/views/graphs/graph-run-page.test.tsx b/packages/views/graphs/graph-run-page.test.tsx new file mode 100644 index 00000000000..0bfac52b211 --- /dev/null +++ b/packages/views/graphs/graph-run-page.test.tsx @@ -0,0 +1,333 @@ +import { fireEvent, render, screen } from "@testing-library/react"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import labels from "../locales/en/graphs.json"; +import { GraphRunPage } from "./graph-run-page"; + +const box = vi.hoisted(() => ({ run: {} as Record })); + +vi.mock("../i18n", () => ({ + useT: () => ({ + t: (selector: (value: typeof labels) => string) => selector(labels), + }), +})); +vi.mock("../navigation", () => ({ + useNavigation: () => ({ + pathname: "/acme/graphs/runs/run-1", + searchParams: new URLSearchParams(), + }), + AppLink: ({ href, children, ...props }: React.ComponentProps<"a">) => ( + + {children} + + ), +})); +vi.mock("../common/task-transcript", () => ({ + TranscriptButton: () => , +})); +vi.mock("@multica/core/hooks", () => ({ useWorkspaceId: () => "ws-1" })); +vi.mock("@multica/core/permissions", () => ({ + useCurrentMember: () => ({ + userId: "user-1", + member: { id: "member-1" }, + role: "owner", + }), +})); +vi.mock("@multica/core/paths", () => ({ + useWorkspacePaths: () => ({ + graphs: () => "/acme/graphs", + graphRun: (id: string) => `/acme/graphs/runs/${id}`, + graphDetail: (id: string) => `/acme/graphs/${id}`, + issueDetail: (id: string) => `/acme/issues/${id}`, + attachmentPreview: (id: string) => `/acme/attachments/${id}`, + }), +})); +vi.mock("@multica/core/workspace/queries", () => ({ + memberListOptions: () => ({ + queryKey: ["members"], + queryFn: async () => [], + initialData: [], + }), +})); +vi.mock("@multica/core/graphs", async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + graphRunOptions: () => ({ + queryKey: ["run"], + queryFn: async () => box.run, + initialData: box.run, + }), + graphNodeTasksOptions: () => ({ + queryKey: ["node-tasks"], + queryFn: async () => [], + initialData: [], + }), + graphStartOptions: () => ({ + queryKey: ["start-options"], + queryFn: async () => ({ repositories: [] }), + initialData: { repositories: [] }, + }), + useControlGraphRun: () => ({ mutate: vi.fn(), isPending: false }), + useApproveGraphRun: () => ({ mutate: vi.fn(), isPending: false }), + useReplaceGraphApprover: () => ({ mutate: vi.fn(), isPending: false }), + }; +}); +// The canvas is React Flow and the value renderer has its own tests, so this +// file only asserts how the run page wires them together. +vi.mock("./graph-result-view", () => ({ + GraphValueView: ({ value }: { value: unknown }) => ( +
{JSON.stringify(value)}
+ ), + GraphArtifactCards: () =>

Artifacts

, +})); +vi.mock("./graph-canvas", () => ({ + GraphCanvas: ({ + onSelect, + states, + selectedKey, + details, + }: { + onSelect?: (key: string) => void; + states?: Record; + selectedKey?: string; + details?: Record; + }) => ( +
+ {Object.entries(states ?? {}).map(([key, state]) => ( + + ))} +
+ ), +})); + +const definition = { + schemaVersion: 2, + nodes: [ + { nodeKey: "start", type: "start", label: "Start" }, + { nodeKey: "research", type: "agent", label: "Research" }, + { nodeKey: "end", type: "end", label: "End" }, + ], + edges: [], +}; + +function node( + id: string, + nodeKey: string, + state: string, + extra: Record = {}, +) { + return { + id, + nodeKey, + nodeType: nodeKey === "research" ? "agent" : nodeKey, + label: nodeKey[0]!.toUpperCase() + nodeKey.slice(1), + state, + attempts: [], + maxAttempts: 3, + input: {}, + output: null, + submissions: [], + ...extra, + }; +} + +function makeRun(overrides: Record = {}) { + return { + id: "run-1", + rootIssueId: "issue-1", + initiatorId: "user-1", + state: "succeeded", + controlState: "run", + revision: 4, + graphName: "Release research", + definition, + input: {}, + inputHash: "hash-1", + versionId: "version-1", + output: { brief: { conclusion: "Ship it" } }, + artifacts: [], + events: [], + nodes: [ + node("n1", "start", "succeeded"), + node("n2", "research", "succeeded", { + output: { summary: "three facts" }, + }), + node("n3", "end", "succeeded"), + ], + ...overrides, + }; +} + +function renderPage() { + return render( + + + , + ); +} + +beforeEach(() => { + box.run = makeRun(); +}); + +describe("Graph run page", () => { + it("gives the run a flow pane and a record pane side by side", () => { + renderPage(); + const flow = screen.getByTestId("run-flow"); + const rail = screen.getByTestId("run-rail"); + // One grid, two panes: the page is a fixed-height workspace, so a run with + // fifty-seven sources cannot lengthen it. + expect(rail.parentElement).toBe(flow.parentElement); + expect(flow.compareDocumentPosition(rail)).toBe( + Node.DOCUMENT_POSITION_FOLLOWING, + ); + }); + + it("leads the record pane with the run result", () => { + renderPage(); + const rail = screen.getByTestId("run-rail"); + const result = screen.getByTestId("run-result"); + const detail = screen.getByTestId("node-detail"); + expect(result.parentElement).toBe(rail); + expect( + result.compareDocumentPosition(detail) & Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("opens on the node that produced the result, not the synthetic end node", () => { + renderPage(); + expect(screen.getByRole("heading", { level: 2, name: "Research" })).toBeVisible(); + expect(screen.getByTestId("graph-flow")).toHaveAttribute( + "data-selected", + "research", + ); + }); + + it("shows what each node produced on the node itself", () => { + renderPage(); + const details = JSON.parse( + screen.getByTestId("graph-flow").getAttribute("data-details") ?? "{}", + ); + expect(details.research).toBe("three facts"); + expect(details.start).toBe(""); + }); + + it("opens on the node that needs attention when one does", () => { + box.run = makeRun({ + state: "blocked", + nodes: [ + node("n1", "start", "succeeded"), + node("n2", "research", "blocked", { + blockReason: "execution_unknown", + output: { summary: "three facts" }, + }), + node("n3", "end", "pending"), + ], + }); + renderPage(); + expect(screen.getByRole("heading", { level: 2, name: "Research" })).toBeVisible(); + expect( + screen.getByText( + "Execution has not been confirmed stopped. A replacement attempt cannot start yet.", + ), + ).toBeVisible(); + expect(screen.getByTestId("graph-flow")).toHaveAttribute( + "data-selected", + "research", + ); + }); + + it("switches the record pane when a node is selected on the flow", () => { + renderPage(); + fireEvent.click( + screen.getByRole("button", { name: "flow:end:succeeded" }), + ); + expect(screen.getByRole("heading", { level: 2, name: "End" })).toBeVisible(); + expect(screen.getByTestId("graph-flow")).toHaveAttribute( + "data-selected", + "end", + ); + }); + + it("surfaces a pending approval above the flow and gates rejection on a reason", () => { + box.run = makeRun({ + state: "waiting_approval", + approvals: [ + { + id: "gate-1", + nodeRunId: "n2", + state: "pending", + candidateDigest: "digest-1", + approverId: "member-1", + mode: "all", + quorum: 1, + decisions: [], + candidate: { outputs: { research: { summary: "three facts" } } }, + }, + ], + }); + renderPage(); + const approve = screen.getByRole("button", { name: "Approve candidate" }); + expect(approve).toBeEnabled(); + expect( + screen + .getByTestId("run-flow") + .compareDocumentPosition(approve) & Node.DOCUMENT_POSITION_PRECEDING, + ).toBeTruthy(); + const reject = screen.getByRole("button", { name: "Reject candidate" }); + expect(reject).toBeDisabled(); + fireEvent.change(screen.getByLabelText("Reason"), { + target: { value: "Evidence is thin" }, + }); + expect(reject).toBeEnabled(); + }); + + it("keeps the reason field out of the header when nothing is being decided", () => { + renderPage(); + expect(screen.queryByLabelText("Reason")).toBeNull(); + }); + + // The engine ends every run that is not a success by requesting cancellation - + // that is how it stops advancing while it drains live attempts - and the run + // keeps the marker afterwards. A rejected run therefore has to render as + // rejected and nothing else, or it reads as a cancellation still in progress + // that no button on the page could ever finish. + it("drops the control state from a run that has already ended", () => { + box.run = makeRun({ state: "rejected", controlState: "cancel_requested" }); + renderPage(); + expect(screen.getByText("Rejected")).toBeVisible(); + expect(screen.queryByText("Cancelling")).toBeNull(); + expect( + screen.queryByText( + "Cancellation requested. Waiting for execution to stop.", + ), + ).toBeNull(); + }); + + it("still says a run that is being stopped is stopping", () => { + box.run = makeRun({ state: "active", controlState: "cancel_requested" }); + renderPage(); + expect( + screen.getByText( + "Cancellation requested. Waiting for execution to stop.", + ), + ).toBeVisible(); + }); + + it("falls back to a node list when the run carries no definition", () => { + box.run = makeRun({ definition: undefined }); + renderPage(); + expect(screen.queryByTestId("graph-flow")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: /Research/ })); + expect(screen.getByRole("heading", { level: 2, name: "Research" })).toBeVisible(); + }); +}); diff --git a/packages/views/graphs/graph-run-page.tsx b/packages/views/graphs/graph-run-page.tsx index 7643a9fa333..8e34cc72e4e 100644 --- a/packages/views/graphs/graph-run-page.tsx +++ b/packages/views/graphs/graph-run-page.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState } from "react"; +import { useState, type ReactNode } from "react"; import { useQuery } from "@tanstack/react-query"; import { ArrowLeft } from "lucide-react"; import { toast } from "sonner"; @@ -8,6 +8,7 @@ import { canApproveGraphRun, canManageGraphRun, canReplaceGraphApprover, + graphControlState, graphNodeTasksOptions, graphRunActions, graphRunOptions, @@ -24,7 +25,10 @@ import { type GraphRunAction, type GraphApproval, type GraphNodeDefinition, + type GraphRepositoryOption, + type JsonObject, } from "@multica/core/graphs"; +import type { MemberWithUser } from "@multica/core/types"; import { useWorkspaceId } from "@multica/core/hooks"; import { useWorkspacePaths } from "@multica/core/paths"; import { memberListOptions } from "@multica/core/workspace/queries"; @@ -56,12 +60,87 @@ import { import { GraphCanvas } from "./graph-canvas"; import { GraphArtifactCards, GraphValueView } from "./graph-result-view"; -function NodeTimeline({ +// States worth opening a run page on, most urgent first. A run that is live, +// stuck, or failed is what the reader came for. +const FOCUS_STATES = [ + "running", + "waiting_approval", + "blocked", + "failed", + "queued", + "dispatched", + "started", + "retry_wait", +]; + +// Field names an agent reaches for when it means "here is the answer". Preferred +// over whatever key happens to come first, so a node box shows its conclusion +// rather than its opening sentence. +const SUMMARY_KEYS = ["conclusion", "summary", "verdict", "topic", "title"]; + +function firstText(value: unknown, depth = 0): string { + if (typeof value === "string") return value; + if (depth > 4 || !value || typeof value !== "object") return ""; + if (Array.isArray(value)) return ""; + const entries = Object.entries(value as JsonObject); + for (const key of SUMMARY_KEYS) { + const match = entries.find(([name]) => name === key); + if (match) { + const text = firstText(match[1], depth + 1); + if (text) return text; + } + } + for (const [, item] of entries) { + const text = firstText(item, depth + 1); + if (text) return text; + } + return ""; +} + +/** What a node produced, in one line, for the node box on the flow. */ +function nodeDetail(node: GraphNodeRun): string { + return firstText(node.output).replace(/\s+/g, " ").trim().slice(0, 160); +} + +function focusNodeId(nodes: GraphNodeRun[]): string { + for (const state of FOCUS_STATES) { + const match = nodes.find((node) => node.state === state); + if (match) return match.id; + } + // Nothing needs attention: open the node that produced the result. Run output + // comes from the last node that wrote one, not from the synthetic end node, + // so the reader lands on the answer instead of an empty box. + return ( + (nodes.filter((node) => node.output).at(-1) ?? nodes.at(-1))?.id ?? "" + ); +} + +/** Appendix blocks of the rail: labelled, closed, out of the reading path. */ +function RailDetails({ + label, + children, +}: { + label: string; + children: ReactNode; +}) { + return ( +
+ + {label} + +
{children}
+
+ ); +} + +function NodeDetail({ wsId, node, definition, canManage, pending, + reason, + onReasonChange, onAction, }: { wsId: string; @@ -69,38 +148,55 @@ function NodeTimeline({ definition?: GraphNodeDefinition; canManage: boolean; pending: boolean; + reason: string; + onReasonChange: (reason: string) => void; onAction: (action: GraphRunAction, nodeRunId?: string) => void; }) { const { t } = useT("graphs"); const paths = useWorkspacePaths(); const tasks = useQuery(graphNodeTasksOptions(wsId, node.issueId ?? "")); return ( -
  • -

    +

    {node.label || definition?.label || node.nodeKey} -

    + +
    +
    {node.issueId && ( {t(($) => $.run.node_issue)} )} + {["agent", "script"].includes(node.nodeType) && ( + + {t(($) => $.run.attempts, { + used: node.attempts.length, + max: node.maxAttempts + node.grantedAttempts, + })} + + )} + {node.iteration && ( + + {t(($) => $.production.iteration)} {node.iteration} + + )} + {node.selectedPort && ( + + {node.selectedPort === "true" + ? t(($) => $.production.true_path) + : t(($) => $.production.false_path)} + + )}
    - {["agent", "script"].includes(node.nodeType) && ( -

    - {t(($) => $.run.attempts, { - used: node.attempts.length, - max: node.maxAttempts, - })} -

    - )} {node.blockReason && (

    {node.blockReason === "execution_unknown" @@ -108,27 +204,89 @@ function NodeTimeline({ : node.blockReason}

    )} - {node.iteration && ( -

    - {t(($) => $.production.iteration)} {node.iteration} -

    - )} - {node.selectedPort && ( -

    - {node.selectedPort === "true" - ? t(($) => $.production.true_path) - : t(($) => $.production.false_path)} -

    + {node.output && ( +
    +

    + {t(($) => $.production.output)} +

    + +
    )} - {Object.keys(node.input).length > 0 && ( -
    - - {t(($) => $.run.input)} - -
    - + {node.submissions.map((submission, index) => { + const result = asSchema(submission.result); + return ( +
    +
    +

    + {t(($) => $.production.results)} +

    + {typeof result.outcome === "string" && ( + + )} +
    + {!node.output && result.output !== undefined && ( + + )} + {result.diagnostics !== undefined && ( + + )} + {Array.isArray(result.artifacts) && ( + + )} + $.production.raw_details)} + value={submission} + /> +
    + ); + })} + {canManage && node.state === "blocked" && ( +
    + $.run.reason)}> + {(id) => ( +