diff --git a/packages/core/graphs/definition.ts b/packages/core/graphs/definition.ts index c8330c00208..e6c1430fb4f 100644 --- a/packages/core/graphs/definition.ts +++ b/packages/core/graphs/definition.ts @@ -560,7 +560,7 @@ export function layoutGraph(definition: GraphDefinition): GraphDefinition { const depth = depths.get(node.nodeKey) ?? index, row = rowCounts.get(depth) ?? 0; rowCounts.set(depth, row + 1); - return [node.nodeKey, { x: depth * 300, y: row * 160 }]; + return [node.nodeKey, { x: depth * 340, y: row * 180 }]; }), ); return { ...definition, layout: { nodes } }; diff --git a/packages/core/graphs/draft-store.test.ts b/packages/core/graphs/draft-store.test.ts index 8ce3fb04465..e94f4a90a10 100644 --- a/packages/core/graphs/draft-store.test.ts +++ b/packages/core/graphs/draft-store.test.ts @@ -193,6 +193,19 @@ function stored(storage: StorageAdapter, draftKey: string) { } describe("Graph draft conversation binding", () => { + it("moves a newly chosen executor to the head of the scope", () => { + const store = createGraphDraftStore(memoryStorage()); + store.getState().open(key, definition, 1); + store.getState().bindAi(key, ai); + store + .getState() + .updateAi(key, { executorId: "agent-2", approverId: "member-2" }); + expect(store.getState().documents[key]?.ai).toMatchObject({ + executorId: "agent-2", + scopeIds: ["agent-2", "agent-1"], + approverId: "member-2", + }); + }); it("returns to the same conversation after a reload, with the reply it merged", () => { const storage = memoryStorage(), first = createGraphDraftStore(storage); diff --git a/packages/core/graphs/index.ts b/packages/core/graphs/index.ts index 9726587c76d..52494a7fbd4 100644 --- a/packages/core/graphs/index.ts +++ b/packages/core/graphs/index.ts @@ -7,3 +7,4 @@ export * from "./mutations"; export * from "./draft-store"; export * from "./input-schema"; export * from "./authoring-protocol"; +export * from "./studio"; diff --git a/packages/core/graphs/schemas.ts b/packages/core/graphs/schemas.ts index 44d6371b04c..5e7e47702ea 100644 --- a/packages/core/graphs/schemas.ts +++ b/packages/core/graphs/schemas.ts @@ -511,6 +511,10 @@ export const GraphRunSchema = z approval: GraphApprovalSchema.nullish(), block_reason: reason, archived_at: reason, + root_snapshot: z + .object({ title: z.string().catch("") }) + .nullish() + .catch(null), created_at: timestamp, updated_at: timestamp, }) @@ -518,6 +522,7 @@ export const GraphRunSchema = z id: value.id, workspaceId: value.workspace_id, rootIssueId: value.root_issue_id, + rootTitle: value.root_snapshot?.title || undefined, versionId: value.version_id, initiatorId: value.initiator_id, state: value.state, diff --git a/packages/core/graphs/studio.test.ts b/packages/core/graphs/studio.test.ts new file mode 100644 index 00000000000..5f76b737d1a --- /dev/null +++ b/packages/core/graphs/studio.test.ts @@ -0,0 +1,246 @@ +import { describe, expect, it } from "vitest"; +import { + graphArtifactProducer, + graphChangeMarks, + graphEventNode, + graphRunDurationMs, + graphRunProgress, + groupLatestRuns, + insertGraphNodeOnEdge, + nextGraphNodeKey, +} from "./studio"; +import type { GraphDefinition, GraphNodeRun, GraphRun } from "./types"; + +const base: GraphDefinition = { + schemaVersion: 2, + name: "Flow", + inputSchema: { type: "object" }, + nodes: [ + { nodeKey: "start", type: "start" }, + { nodeKey: "check", type: "branch" }, + { nodeKey: "write", type: "agent" }, + { nodeKey: "end", type: "end" }, + ], + edges: [ + { from: "start", to: "check" }, + { from: "check", to: "write", port: "true" }, + { from: "check", to: "end", port: "false" }, + { from: "write", to: "end" }, + ], + layout: { + nodes: { + start: { x: 0, y: 0 }, + check: { x: 300, y: 0 }, + write: { x: 600, y: 0 }, + end: { x: 900, y: 0 }, + }, + }, +}; + +describe("insertGraphNodeOnEdge", () => { + it("splits a plain connection and places the step between its ends", () => { + const next = insertGraphNodeOnEdge( + base, + { from: "start", to: "check" }, + { nodeKey: "agent_1", type: "agent" }, + ); + expect(next.edges.slice(0, 2)).toEqual([ + { from: "start", to: "agent_1" }, + { from: "agent_1", to: "check" }, + ]); + expect(next.edges).toHaveLength(5); + expect(next.layout?.nodes.agent_1).toEqual({ x: 150, y: 80 }); + }); + + it("keeps a branch outcome on the incoming side", () => { + const next = insertGraphNodeOnEdge( + base, + { from: "check", to: "write", port: "true" }, + { nodeKey: "review", type: "approval" }, + ); + expect(next.edges).toContainEqual({ + from: "check", + to: "review", + port: "true", + }); + expect(next.edges).toContainEqual({ from: "review", to: "write" }); + }); + + it("continues an inserted branch on its true outcome", () => { + const next = insertGraphNodeOnEdge( + base, + { from: "write", to: "end" }, + { nodeKey: "branch_2", type: "branch" }, + ); + expect(next.edges).toContainEqual({ + from: "branch_2", + to: "end", + port: "true", + }); + }); + + it("refuses missing connections, duplicate keys and structural steps", () => { + expect(() => + insertGraphNodeOnEdge( + base, + { from: "start", to: "end" }, + { nodeKey: "x", type: "agent" }, + ), + ).toThrow(); + expect(() => + insertGraphNodeOnEdge( + base, + { from: "start", to: "check" }, + { nodeKey: "write", type: "agent" }, + ), + ).toThrow(); + expect(() => + insertGraphNodeOnEdge( + base, + { from: "start", to: "check" }, + { nodeKey: "join_1", type: "join" }, + ), + ).toThrow(); + }); +}); + +describe("nextGraphNodeKey", () => { + it("skips keys already taken", () => { + const definition = { + ...base, + nodes: [...base.nodes, { nodeKey: "agent_2", type: "start" }], + }; + expect(nextGraphNodeKey(definition, "agent")).toEqual({ + key: "agent_3", + index: 3, + }); + }); +}); + +function nodeRun( + nodeKey: string, + nodeType: string, + state: string, + scopeId = "root", +): GraphNodeRun { + return { + id: nodeKey, + nodeKey, + nodeType, + state, + scopeId, + input: {}, + inputHash: "", + maxAttempts: 1, + grantedAttempts: 0, + canRetry: false, + canResolveBlock: false, + attempts: [], + submissions: [], + }; +} + +describe("graphRunProgress", () => { + it("counts root working steps only", () => { + expect( + graphRunProgress({ + scopes: [{ id: "root" }, { id: "loop", parent_node_run_id: "x" }], + nodes: [ + nodeRun("start", "start", "succeeded"), + nodeRun("a", "agent", "succeeded"), + nodeRun("b", "branch", "skipped"), + nodeRun("c", "agent", "failed"), + nodeRun("d", "approval", "waiting_approval"), + nodeRun("inner", "agent", "succeeded", "loop"), + nodeRun("end", "end", "pending"), + ], + }), + ).toEqual({ done: 2, total: 4, failed: 1 }); + }); +}); + +describe("groupLatestRuns", () => { + it("groups newest first and caps each graph", () => { + const run = (id: string, graphId: string | undefined, createdAt: string) => + ({ id, graphId, createdAt }) as GraphRun; + const grouped = groupLatestRuns( + [ + run("1", "g1", "2026-01-01T00:00:00Z"), + run("2", "g1", "2026-01-03T00:00:00Z"), + run("3", "g1", "2026-01-02T00:00:00Z"), + run("4", "g2", "2026-01-01T00:00:00Z"), + run("5", undefined, "2026-01-05T00:00:00Z"), + ], + 2, + ); + expect(grouped.get("g1")?.map((item) => item.id)).toEqual(["2", "3"]); + expect(grouped.get("g2")?.map((item) => item.id)).toEqual(["4"]); + expect(grouped.size).toBe(2); + }); +}); + +describe("graphRunDurationMs", () => { + it("measures to the finish, the last update, or now", () => { + const createdAt = "2026-01-01T00:00:00Z"; + expect( + graphRunDurationMs({ + state: "succeeded", + createdAt, + updatedAt: "2026-01-01T00:09:00Z", + finishedAt: "2026-01-01T00:05:00Z", + }), + ).toBe(300_000); + expect( + graphRunDurationMs({ + state: "failed", + createdAt, + updatedAt: "2026-01-01T00:02:00Z", + }), + ).toBe(120_000); + expect( + graphRunDurationMs( + { state: "active", createdAt, updatedAt: createdAt }, + Date.parse("2026-01-01T00:01:00Z"), + ), + ).toBe(60_000); + expect( + graphRunDurationMs({ state: "active", createdAt: "", updatedAt: "" }), + ).toBeNull(); + }); +}); + +describe("graphEventNode", () => { + const research = { + ...nodeRun("research", "agent", "succeeded"), + id: "node-run-1", + attempts: [ + { + taskId: "task-1", + attemptNo: 1, + generation: 0, + state: "succeeded", + manifest: {}, + }, + ], + }; + const run = { nodes: [research, nodeRun("review", "approval", "pending")] }; + it("resolves task ids, scoped keys and node run ids", () => { + expect(graphEventNode(run, "task-1")?.nodeKey).toBe("research"); + expect(graphEventNode(run, "root/review")?.nodeKey).toBe("review"); + expect(graphEventNode(run, "node-run-1")?.nodeKey).toBe("research"); + expect(graphArtifactProducer(run, "task-1")?.nodeKey).toBe("research"); + }); + it("ignores messages that are not references", () => { + expect(graphEventNode(run, "user_cancelled")).toBeUndefined(); + expect(graphEventNode(run, undefined)).toBeUndefined(); + }); +}); + +describe("graphChangeMarks", () => { + it("lets an added mark win over a changed one", () => { + expect(graphChangeMarks(["a"], ["a", "b"])).toEqual({ + a: "added", + b: "changed", + }); + }); +}); diff --git a/packages/core/graphs/studio.ts b/packages/core/graphs/studio.ts new file mode 100644 index 00000000000..b2ecbd499ca --- /dev/null +++ b/packages/core/graphs/studio.ts @@ -0,0 +1,229 @@ +import type { + GraphDefinition, + GraphEdge, + GraphNodeDefinition, + GraphNodeRun, + GraphRun, +} from "./types"; + +/** + * Pure helpers behind the AI-first Graph studio: inserting a step on an + * existing connection, reading a run's progress, and grouping recent runs by + * the graph they belong to. Kept free of React so they are testable on their + * own and reusable from mobile. + */ + +/** Node types that cannot sit in the middle of a single connection. */ +const NOT_INSERTABLE = new Set(["start", "end", "join"]); + +export function canInsertGraphNode(type: string): boolean { + return !NOT_INSERTABLE.has(type); +} + +/** The first free `_` key in a scope, and its ordinal. */ +export function nextGraphNodeKey( + definition: GraphDefinition, + type: string, +): { key: string; index: number } { + let index = definition.nodes.filter((node) => node.type === type).length + 1; + let key = `${type}_${index}`; + while (definition.nodes.some((node) => node.nodeKey === key)) + key = `${type}_${++index}`; + return { key, index }; +} + +/** + * Replaces the connection `edge` with `edge.from -> node -> edge.to`. + * + * The incoming side keeps the original port, so a step inserted on a branch's + * "true" path stays on that path. A branch inserted in the middle continues the + * original target on its own "true" outcome; its "false" outcome is left for + * the user to connect. + */ +export function insertGraphNodeOnEdge( + definition: GraphDefinition, + edge: GraphEdge, + node: GraphNodeDefinition, +): GraphDefinition { + if (!canInsertGraphNode(node.type)) + throw new Error("This step cannot be inserted on a connection."); + if (definition.nodes.some((item) => item.nodeKey === node.nodeKey)) + throw new Error("Node keys must be unique identifiers."); + const index = definition.edges.findIndex( + (item) => + item.from === edge.from && + item.to === edge.to && + (item.port ?? "") === (edge.port ?? ""), + ); + if (index < 0) throw new Error("The connection no longer exists."); + const original = definition.edges[index]!; + const incoming: GraphEdge = { + from: original.from, + to: node.nodeKey, + ...(original.port ? { port: original.port } : {}), + }; + const outgoing: GraphEdge = { + from: node.nodeKey, + to: original.to, + ...(node.type === "branch" ? { port: "true" } : {}), + }; + const positions = definition.layout?.nodes; + const from = positions?.[original.from]; + const to = positions?.[original.to]; + return { + ...definition, + schemaVersion: 2, + nodes: [...definition.nodes, node], + edges: [ + ...definition.edges.slice(0, index), + incoming, + outgoing, + ...definition.edges.slice(index + 1), + ], + ...(definition.layout + ? { + layout: { + ...definition.layout, + nodes: { + ...definition.layout.nodes, + ...(from && to + ? { + [node.nodeKey]: { + x: Math.round((from.x + to.x) / 2), + y: Math.round((from.y + to.y) / 2) + 80, + }, + } + : {}), + }, + }, + } + : {}), + }; +} + +/** Node states that count as finished for a run's progress bar. */ +const DONE_STATES = new Set(["succeeded", "skipped", "completed", "approved"]); +/** Synthetic nodes that do no work and would inflate the step count. */ +const STRUCTURAL = new Set(["start", "end"]); + +export interface GraphRunProgress { + done: number; + total: number; + failed: number; +} + +/** + * How far a run has come, counted over the root scope's working steps. Nested + * loop and subgraph iterations are left out: their counts change as the run + * advances, which would make the bar move backwards. + */ +export function graphRunProgress(run: Pick): GraphRunProgress { + const rootScope = run.scopes?.find((scope) => !scope.parent_node_run_id)?.id; + const nodes = (run.nodes ?? []).filter( + (node: GraphNodeRun) => + !STRUCTURAL.has(node.nodeType) && + (rootScope ? node.scopeId === rootScope || !node.scopeId : true), + ); + return { + done: nodes.filter((node) => DONE_STATES.has(node.state)).length, + total: nodes.length, + failed: nodes.filter((node) => node.state === "failed").length, + }; +} + +/** + * Recent runs by graph, newest first, at most `perGraph` each. The list page + * reads a graph's health from this instead of asking the server once per row. + */ +export function groupLatestRuns( + runs: GraphRun[], + perGraph = 7, +): Map { + const sorted = [...runs].sort( + (a, b) => Date.parse(b.createdAt || "") - Date.parse(a.createdAt || ""), + ); + const grouped = new Map(); + for (const run of sorted) { + if (!run.graphId) continue; + const list = grouped.get(run.graphId) ?? []; + if (list.length < perGraph) list.push(run); + grouped.set(run.graphId, list); + } + return grouped; +} + +const TERMINAL = new Set(["succeeded", "failed", "rejected", "cancelled"]); + +/** + * How long a run has taken: to its finish, or to `now` while it is live. A + * terminal run without `finishedAt` falls back to its last update, the same + * rule the server's duration statistics use. + */ +export function graphRunDurationMs( + run: Pick, + now = Date.now(), +): number | null { + const start = Date.parse(run.createdAt || ""); + if (Number.isNaN(start)) return null; + const end = run.finishedAt + ? Date.parse(run.finishedAt) + : TERMINAL.has(run.state) + ? Date.parse(run.updatedAt || "") + : now; + if (Number.isNaN(end)) return null; + return Math.max(0, end - start); +} + +/** + * The step a run event is about. Events carry a bare reference in `message`: + * a task id (attempt.created, node.submitted), a `/` pair + * (node.accepted, approval.requested), or a node run id. Anything else is not + * a reference and returns undefined. + */ +export function graphEventNode( + run: Pick, + message: unknown, +): GraphNodeRun | undefined { + if (typeof message !== "string" || !message) return undefined; + const nodes = run.nodes ?? []; + const slash = message.indexOf("/"); + if (slash > 0) { + const scope = message.slice(0, slash); + const key = message.slice(slash + 1); + return ( + nodes.find((node) => node.scopeId === scope && node.nodeKey === key) ?? + nodes.find((node) => node.nodeKey === key) + ); + } + return nodes.find( + (node) => + node.id === message || + node.attempts.some((attempt) => attempt.taskId === message) || + node.submissions.some( + (submission) => + submission.id === message || submission.task_id === message, + ), + ); +} + +/** The step whose attempt produced an artifact, from its producer task. */ +export function graphArtifactProducer( + run: Pick, + taskId: unknown, +): GraphNodeRun | undefined { + if (typeof taskId !== "string" || !taskId) return undefined; + return (run.nodes ?? []).find((node) => + node.attempts.some((attempt) => attempt.taskId === taskId), + ); +} + +/** Node keys an AI turn touched, for marking them on the canvas. */ +export function graphChangeMarks( + added: string[], + changed: string[], +): Record { + return { + ...Object.fromEntries(changed.map((key) => [key, "changed" as const])), + ...Object.fromEntries(added.map((key) => [key, "added" as const])), + }; +} diff --git a/packages/core/graphs/types.ts b/packages/core/graphs/types.ts index e827a107416..2b63d60cf5a 100644 --- a/packages/core/graphs/types.ts +++ b/packages/core/graphs/types.ts @@ -236,6 +236,8 @@ export interface GraphRun { id: string; workspaceId: string; rootIssueId: string; + /** Title of the parent issue when the run started. */ + rootTitle?: string; versionId: string; initiatorId: string; state: string; diff --git a/packages/core/stores/graph-draft-store.ts b/packages/core/stores/graph-draft-store.ts index cb2e0f964fc..532985310c4 100644 --- a/packages/core/stores/graph-draft-store.ts +++ b/packages/core/stores/graph-draft-store.ts @@ -73,6 +73,15 @@ interface GraphDraftState { bindAi: (key: string, ai: GraphDraftAi) => void; /** Records the assistant reply whose draft is now in `definition`. */ markAiApplied: (key: string, messageId: string) => void; + /** + * Changes who new work defaults to. Callers must not do this while a turn is + * in flight: aliases are positional, and a reply written against the old + * directory would merge into the wrong teammate. + */ + updateAi: ( + key: string, + patch: Partial>, + ) => void; /** Unbinds the conversation without touching the definition. */ clearAi: (key: string) => void; clearWorkspace: (slug: string, adapter?: StorageAdapter) => void; @@ -384,6 +393,17 @@ export function createGraphDraftStore(storage: StorageAdapter) { ai: { ...current.ai, appliedMessageId: messageId }, }); }, + updateAi: (key, patch) => { + const current = get().documents[key]; + if (!current?.ai) return; + const ai = { ...current.ai, ...patch }; + // The executor reads as `agent_default`, so it always leads the scope. + ai.scopeIds = [ + ai.executorId, + ...ai.scopeIds.filter((id) => id !== ai.executorId), + ]; + sync(key, { ...current, ai }); + }, clearAi: (key) => { const current = get().documents[key]; if (!current?.ai) return; diff --git a/packages/ui/styles/tokens.css b/packages/ui/styles/tokens.css index cba754f12c1..06c71c3a0fc 100644 --- a/packages/ui/styles/tokens.css +++ b/packages/ui/styles/tokens.css @@ -34,6 +34,10 @@ --color-success: var(--success); --color-warning: var(--warning); --color-info: var(--info); + --color-graph-agent: var(--graph-agent); + --color-graph-human: var(--graph-human); + --color-graph-logic: var(--graph-logic); + --color-graph-tool: var(--graph-tool); --color-brand: var(--brand); --color-brand-foreground: var(--brand-foreground); --color-accent-foreground: var(--accent-foreground); @@ -210,6 +214,11 @@ --success: oklch(0.55 0.16 145); --warning: oklch(0.75 0.16 85); --info: oklch(0.55 0.18 250); + /* Graph step categories: AI work, people, flow control, utilities. */ + --graph-agent: oklch(0.55 0.16 275); + --graph-human: oklch(0.70 0.15 65); + --graph-logic: oklch(0.60 0.11 200); + --graph-tool: oklch(0.55 0.02 260); --scrollbar-thumb: oklch(0 0 0 / 10%); --scrollbar-thumb-hover: oklch(0 0 0 / 18%); --scrollbar-track: transparent; @@ -280,6 +289,10 @@ --success: oklch(0.65 0.15 145); --warning: oklch(0.70 0.16 85); --info: oklch(0.65 0.18 250); + --graph-agent: oklch(0.68 0.15 275); + --graph-human: oklch(0.75 0.14 65); + --graph-logic: oklch(0.70 0.10 200); + --graph-tool: oklch(0.65 0.02 260); --scrollbar-thumb: oklch(1 0 0 / 8%); --scrollbar-thumb-hover: oklch(1 0 0 / 18%); --scrollbar-track: transparent; diff --git a/packages/views/graphs/graph-ai-composer.test.tsx b/packages/views/graphs/graph-ai-composer.test.tsx deleted file mode 100644 index 52c32d7f187..00000000000 --- a/packages/views/graphs/graph-ai-composer.test.tsx +++ /dev/null @@ -1,165 +0,0 @@ -import { describe, expect, it, vi } from "vitest"; -import { fireEvent, render, screen } from "@testing-library/react"; -import { ApiError } from "@multica/core/api/client"; -import type { GraphGenerationMode } from "@multica/core/graphs"; -import labels from "../locales/en/graphs.json"; -import issueLabels from "../locales/en/issues.json"; -import { GraphAiComposer } from "./graph-ai-composer"; - -vi.mock("../i18n", () => ({ - useT: (namespace: string) => ({ - t: ( - selector: (value: unknown) => string, - options?: Record, - ) => { - let text = selector(namespace === "issues" ? issueLabels : labels); - for (const [key, value] of Object.entries(options ?? {})) - text = text.replace(`{{${key}}}`, String(value)); - return text; - }, - }), -})); - -const agents = [ - { - id: "agent-1", - name: "Builder", - }, - { - id: "agent-2", - name: "Reviewer", - }, -] as never; -const members = [{ id: "member-1", name: "Ada", email: "ada@example.com" }] as never; - -function composer(overrides: Record = {}) { - const handlers = { onGenerate: vi.fn(), onManual: vi.fn(), onPromptChange: vi.fn() }; - const props = { - prompt: "Research our release", - defaultAgentId: "agent-1", - onDefaultAgentChange: vi.fn(), - approverId: "member-1", - onApproverChange: vi.fn(), - mode: "legacy" as GraphGenerationMode, - agents, - members, - loading: false, - pending: false, - error: undefined as unknown, - hasDraft: false, - ...handlers, - ...overrides, - }; - render(); - return { ...handlers, props }; -} - -const generate = () => screen.getByRole("button", { name: "Generate workflow" }); -// A locked composer swaps the label for the in-flight one, so the button is -// still the same affordance — just not the one the idle state advertises. -const busy = () => screen.getByRole("button", { name: "Generating…" }); - -describe("GraphAiComposer one-shot form", () => { - it("asks for one agent and one approver", () => { - composer(); - expect( - screen.getByRole("combobox", { name: "Workflow agent" }), - ).toHaveTextContent("Builder"); - expect( - screen.getByRole("combobox", { name: "Final approver" }), - ).toHaveTextContent("Ada"); - }); - - it("no longer asks for a participating scope or a runtime quota", () => { - // Both belonged to the runtime pipeline: it needed a scope to hand the - // model, and it spent a submission budget that this path never had. - composer(); - expect( - screen.queryByRole("combobox", { name: "Participating agents" }), - ).toBeNull(); - expect(screen.queryByText(/uses its quota/)).toBeNull(); - expect(screen.queryByText(/may use only these agents/)).toBeNull(); - }); - - it.each([ - ["no agent", { defaultAgentId: "" }], - ["no approver", { approverId: "" }], - ["an empty description", { prompt: "" }], - ["AI authoring this server cannot serve", { mode: "unavailable" }], - ["the editor withholding permission", { disabled: true }], - ["generation options still loading", { loading: true }], - ])("withholds Generate with %s", (_label, overrides) => { - composer(overrides); - expect(generate()).toBeDisabled(); - }); - - it("swaps Generate for an in-flight label while a request is pending", () => { - composer({ pending: true }); - expect(busy()).toBeDisabled(); - expect(screen.queryByRole("button", { name: "Generate workflow" })).toBeNull(); - expect(screen.getByLabelText("What should this workflow do?")).toBeDisabled(); - }); - - it("fills the description from an example instead of sending it", () => { - const { onPromptChange, onGenerate } = composer({ prompt: "" }); - fireEvent.click( - screen.getByRole("button", { - name: "Research a topic, summarize findings, then request my approval.", - }), - ); - expect(onPromptChange).toHaveBeenCalledWith( - "Research a topic, summarize findings, then request my approval.", - ); - expect(onGenerate).not.toHaveBeenCalled(); - }); - - it("still offers manual authoring while AI authoring is unavailable", () => { - const { onManual } = composer({ mode: "unavailable" }); - expect(generate()).toBeDisabled(); - expect( - screen.getByText(/has not been configured on this server/), - ).toBeVisible(); - fireEvent.click(screen.getByRole("button", { name: "Build manually" })); - expect(onManual).toHaveBeenCalledOnce(); - }); -}); - -describe("GraphAiComposer failure reporting", () => { - it.each([ - ["generation_timeout", /Generation took too long/], - ["generation_invalid", /could not produce a valid workflow/], - ["generation_failed", /AI generation failed/], - ["capability_missing", /cannot draft workflows yet/], - ["agent_unavailable", /selected agent is unavailable/], - ["forbidden", /do not have permission/], - ])("explains a %s rejection", (code, copy) => { - composer({ error: new ApiError("raw", 400, "Bad Request", { code }) }); - expect(screen.getByRole("alert")).toHaveTextContent(copy); - expect(screen.getByLabelText("What should this workflow do?")).toBeEnabled(); - }); - - it("adds the retry hint when the server is busy", () => { - composer({ - error: new ApiError("Busy", 429, "Too Many Requests", { - code: "generation_busy", - }), - }); - const alert = screen.getByRole("alert"); - expect(alert).toHaveTextContent(/Another draft is being generated/); - expect(alert).toHaveTextContent(/Your description is kept/); - }); - - it("falls back to the server's own message for a code this build does not know", () => { - composer({ - error: new ApiError("Out of quota", 400, "Bad Request", { - code: "generation_quota", - }), - }); - expect(screen.getByRole("alert")).toHaveTextContent("Out of quota"); - }); - - it("says nothing about failures while everything is quiet", () => { - composer(); - expect(screen.queryByRole("alert")).toBeNull(); - }); -}); diff --git a/packages/views/graphs/graph-ai-composer.tsx b/packages/views/graphs/graph-ai-composer.tsx deleted file mode 100644 index 5da8be4a5e5..00000000000 --- a/packages/views/graphs/graph-ai-composer.tsx +++ /dev/null @@ -1,314 +0,0 @@ -"use client"; - -import { - ArrowRight, - Bot, - CheckCheck, - GitBranch, - Loader2, - PenLine, - Search, - Sparkles, -} from "lucide-react"; -import type { Agent, MemberWithUser } from "@multica/core/types"; -import type { GraphGenerationMode } from "@multica/core/graphs"; -import { errorCode } from "@multica/core/api/client"; -import { Button } from "@multica/ui/components/ui/button"; -import { Textarea } from "@multica/ui/components/ui/textarea"; -import { useT } from "../i18n"; -import { Field } from "./shared"; -import { GraphActorPicker } from "./graph-actor-picker"; - -/** - * The one-shot form: describe the workflow, hand it to one agent, get one - * proposal back. - * - * This is the path a deployment without a builder still uses — the server - * generates from its own LLM client and the result arrives as a proposal to - * apply or discard. Where a builder exists the editor never renders this, and - * the conversation replaces both the form and the apply step. - */ -export function GraphAiComposer({ - prompt, - onPromptChange, - mode, - defaultAgentId, - onDefaultAgentChange, - approverId, - onApproverChange, - agents, - members, - loading, - disabled, - pending, - error, - hasDraft, - onGenerate, - onManual, -}: { - prompt: string; - onPromptChange: (value: string) => void; - mode: GraphGenerationMode; - defaultAgentId: string; - onDefaultAgentChange: (value: string) => void; - approverId: string; - onApproverChange: (value: string) => void; - agents: Agent[]; - members: MemberWithUser[]; - /** The generation-options query is still in flight. */ - loading: boolean; - /** The editor cannot start authoring right now. */ - disabled?: boolean; - /** The request is in flight. */ - pending: boolean; - /** The last rejected request, from before a task existed. */ - error: unknown; - hasDraft: boolean; - onGenerate: () => void; - onManual: () => void; -}) { - const { t } = useT("graphs"); - const examples = [ - t(($) => $.studio.example_research), - t(($) => $.studio.example_release), - t(($) => $.studio.example_content), - ]; - const agentOptions = agents.map((agent) => ({ - value: agent.id, - label: agent.name, - avatarUrl: agent.avatar_url, - })); - const locked = pending; - // The request is the only source of failure on this path: one synchronous - // call, so there is no task whose status could also report one. - const requestCode = errorCode(error); - const failureCode = requestCode; - const failureText = - error - ? (() => { - switch (failureCode) { - case "generation_timeout": - return t(($) => $.studio.error_timeout); - case "generation_invalid": - return t(($) => $.studio.error_invalid); - case "generation_busy": - return t(($) => $.studio.error_busy); - case "generation_unavailable": - return t(($) => $.studio.unavailable); - case "capability_missing": - return t(($) => $.studio.error_capability); - case "agent_unavailable": - return t(($) => $.studio.error_agent); - case "forbidden": - return t(($) => $.studio.error_forbidden); - case "generation_failed": - return t(($) => $.studio.error_failed); - default: - // A newer backend can report a code this build does not know. - // Show what the server said rather than inventing copy for it. - return ( - (error instanceof Error ? error.message : "") || - t(($) => $.studio.error_failed) - ); - } - })() - : ""; - return ( -
-
-
- - {t(($) => $.studio.eyebrow)} -
-

- {t(($) => $.studio.title)} -

-

- {t(($) => $.studio.description)} -

-
-
-
-
-
- $.studio.prompt)}> - {(id) => ( -