Workbench Phases 3 and 4: Flow 3.0.1 UI Upgrade and Workbench UI Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Upgrade the UI to @apitomy/flow-ui 3.0.1 and build the Workflow Workbench page. The page has a
three-pane layout:
- a composition tree with revisions and publish;
- tabbed editors for the workflow, action types and tools;
- an AI chat.
You and the AI take turns editing the same live drafts. AI workflow changes arrive as Flow change sets that the editor applies (live) or stages (review), and an AI turn can be undone.
Architecture:
- Data: a thin API layer (config/compositions.ts) over the Phase 2 REST routes, plus a typed SSE
helper.
- Pure logic in testable modules (pages/workbench/*.ts):
- tree status
- publish selection
- turn and lock state
- undo decision
- preferences
- Page: WorkbenchPage assembles a resizable three-pane layout from small components.
- Workflow tab: the Flow WorkflowEditor, driven through its imperative handle.
- Action type and tool tabs: controlled editors extracted from the existing detail pages, which are
refactored to use them, so there is one editor implementation.
- Chat: the existing AssistantChatPanel, with send and interrupt redirected to the composition routes.
Tech Stack: React 19, TypeScript, PatternFly 6, react-router 7, @apitomy/flow-ui 3.0.1, Vitest with
jsdom and Testing Library. The backend is Java/Quarkus, contract-first; this plan adds one endpoint.
Spec: docs/superpowers/specs/2026-10-08-workflow-workbench-design.md, especially §2 (UI), §3 (REST)
and §4 (errors). Flow reference: apitomy-flow/docs/user-guide/ai-assisted-editing.md.
Global Constraints
- UI commands, run in
ui/:npx tsc -b --noEmit,npx vitest run <file>,npx vitest run. - Node modules: in a git worktree, run
npm ciin the worktree's ownui/. Never symlink to, or modify, the main checkout'snode_modules. Only Task 1 changes dependencies. - Backend: never run
mvn clean,./build.shor anymvn install. Usemvn -q -pl app -am test -Dtest=<Class> -Dsurefire.failIfNoSpecifiedTests=false. REST changes start inopenapi.json. - Style: TypeScript and Java use 4-space indentation. Add JSDoc or Javadoc on exported or public
functions and components. Use explicit types for exported APIs. Use
git grep, notrg. - Commits: conventional commit messages, no AI attribution, no push.
- localStorage keys: prefix with
axiom.workbench.. Wrap every access in try/catch, asuseTheme.tsdoes. - Flow handle use:
- Pending change set, apply mode
live: callhandle.apply(cs). - Pending change set, apply mode
review: callhandle.propose(cs). - A
stalerejection oronProposalResolved(id, 'stale')resolves asstale. - Accept or apply resolves as
applied. Reject resolves asrejected. - Resolving change sets: every pending change set the UI receives must be resolved exactly once with
POST /compositions/{id}/changeset-resolution {turnId, outcome, fromPending}. fromPendingistruewhen the set came fromGET /compositions/{id}while the workbench was opening.- It is
falsewhen the set came from the live SSEcomposition-changesetevent. - Saving the workflow:
- Debounce workflow saves by 500 ms.
- Flush any pending save before sending a chat message.
- A save caused by an agent-origin change (
meta.originstarts withagent:) carriesturnId= the turn that produced the change set. - SSE event types:
composition-item-changed{compositionId, itemId, contentRevision, author, turnId}composition-turn{compositionId, turnId, state}composition-changeset{compositionId, turnId, changeSet}
All three are top-level events. Consume them with sseClient.subscribe.
- Mocking flow-ui in tests: component tests that render the editor mock @apitomy/flow-ui with
vi.mock, as WorkflowRunDetailPage.test.tsx does. jsdom cannot render React Flow.
File Map
| File | Change |
|---|---|
ui/package.json, ui/package-lock.json |
@apitomy/flow-ui ^3.0.1 |
ui/src/hooks/useWorkflowEditorSpi.ts |
openActionType support; buildActionTypeCatalog |
ui/src/pages/WorkflowDefinitionDetailPage.tsx |
onChange meta, Open/Create action types, Open in workbench |
ui/src/config/compositions.ts (+ test) |
Composition API client and SSE helper |
ui/src/pages/workbench/workbenchLogic.ts (+ test) |
Pure logic |
ui/src/pages/workbench/workbenchPrefs.ts (+ test) |
localStorage preferences |
ui/src/components/ActionTypeEditor.tsx (+ test), ui/src/pages/ActionTypeDetailPage.tsx |
Extract the controlled editor |
ui/src/components/ToolEditor.tsx (+ test), ui/src/pages/ToolDetailPage.tsx |
Extract the controlled editor |
ui/src/components/assistant/AssistantChatPanel.tsx |
Optional onSend / onInterrupt |
ui/src/pages/WorkbenchListPage.tsx (+ test) |
/workbench |
ui/src/pages/workbench/WorkbenchPage.tsx, .css (+ test) |
/workbench/:id shell, panes, focus, break-out |
ui/src/pages/workbench/CompositionTree.tsx, PublishDialog.tsx, RevisionList.tsx (+ tests) |
Left pane |
ui/src/pages/workbench/WorkflowTab.tsx (+ test) |
Workflow editor tab |
ui/src/pages/workbench/ItemTab.tsx (+ test) |
Action type and tool tabs |
ui/src/pages/workbench/ChatPane.tsx (+ test) |
Right pane with Undo AI turn |
ui/src/pages/workbench/ProblemsStrip.tsx |
Bottom strip |
ui/src/App.tsx, ui/src/components/AppSidebar.tsx |
Routes, ?chrome=none, nav |
common/api/.../openapi.json, CompositionsResourceImpl.java (+ test) |
POST /compositions/{id}/session |
Task 1: Upgrade to @apitomy/flow-ui 3.0.1 (Phase 3)
Files:
- Modify: ui/package.json and ui/package-lock.json
- Modify: ui/src/hooks/useWorkflowEditorSpi.ts
- Modify: ui/src/pages/WorkflowDefinitionDetailPage.tsx
- Test: ui/src/hooks/useWorkflowEditorSpi.test.ts
Interfaces:
- Produces:
- useWorkflowEditorSpi(options?: { openActionType?: (request: { value: string; resolved: boolean }) => void }): EditorSpi.
It stays memoized on openActionType.
- findActionTypeIdByName(name: string): Promise<number | undefined>. It looks the name up through
fetchActionTypes(1, 1000).
- [ ] Step 1: Upgrade the dependency
In the worktree's ui/, run npm install @apitomy/flow-ui@^3.0.1. Commit the lock file with the change.
Then run npx tsc -b --noEmit and fix any type errors at the existing usage sites listed below. The 3.0
onChange signature (workflow, meta) is backwards compatible.
- WorkflowDefinitionDetailPage.tsx:467 and :797
- components/assistant/WorkflowDetailModal.tsx:91
- components/WorkflowTab.tsx:381
- pages/WorkflowRunDetailPage.tsx:254
- [ ] Step 2: Write the failing test
import { describe, expect, it, vi, beforeEach } from "vitest";
import { renderHook } from "@testing-library/react";
import { findActionTypeIdByName, useWorkflowEditorSpi } from "./useWorkflowEditorSpi";
import * as api from "../config/api";
describe("useWorkflowEditorSpi", () => {
beforeEach(() => vi.restoreAllMocks());
it("exposes openActionType only when given", () => {
const open = vi.fn();
expect(renderHook(() => useWorkflowEditorSpi()).result.current.openActionType).toBeUndefined();
const spi = renderHook(() => useWorkflowEditorSpi({ openActionType: open })).result.current;
spi.openActionType?.({ value: "x", resolved: true });
expect(open).toHaveBeenCalledWith({ value: "x", resolved: true });
});
it("finds action type ids by name", async () => {
vi.spyOn(api, "fetchActionTypes").mockResolvedValue({
items: [{ id: 7, name: "triage", executionMode: "agent" }], totalCount: 1,
} as never);
expect(await findActionTypeIdByName("triage")).toBe(7);
expect(await findActionTypeIdByName("nope")).toBeUndefined();
});
});
- [ ] Step 3: Run the test and confirm it fails
Run: npx vitest run src/hooks/useWorkflowEditorSpi.test.ts. Expected: FAIL, because
findActionTypeIdByName does not exist yet.
-
[ ] Step 4: Implement
-
useWorkflowEditorSpi(options)returnsuseMemo(() => ({ actionTypes: loadEditorActionTypes, ...(options?.openActionType ? { openActionType: options.openActionType } : {}) }), [options?.openActionType]). - In
WorkflowDefinitionDetailPage: - Change
handleEditorChangeto accept(updated: Workflow, _meta?: ChangeMeta). ImportChangeMetafrom@apitomy/flow-ui. -
Pass
openActionTypeto the hook:- For a resolved value,
findActionTypeIdByName(value)and thennavigate(\/action-types/${id}`)`. - For an unresolved value,
navigate("/action-types"). - Each navigation must respect the existing unsaved-changes guard. If
dirty, ask withwindow.confirm("Leave without saving workflow changes?") before navigating.
- For a resolved value,
-
[ ] Step 5: Verify
Run npx tsc -b --noEmit && npx vitest run. Expected: no type errors, and all tests pass, including the
existing ones.
- [ ] Step 6: Commit
git add ui/package.json ui/package-lock.json ui/src/hooks ui/src/pages/WorkflowDefinitionDetailPage.tsx
git commit -m "build(ui): upgrade @apitomy/flow-ui to 3.0.1 and open action types from the editor"
Task 2: Backend POST /compositions/{id}/session, and the composition API client
Files:
- Modify: common/api/src/main/resources/openapi.json and
app/src/main/java/io/apitomy/axiom/app/rest/CompositionsResourceImpl.java
- Test: app/src/test/java/io/apitomy/axiom/app/composition/CompositionTurnsResourceTest.java (extend)
- Create: ui/src/config/compositions.ts and ui/src/config/compositions.test.ts
Interfaces:
- Backend:
- POST /compositions/{compositionId}/session (operationId ensureCompositionSession) returns 200
Composition after calling WorkbenchSessions.ensure(id), which starts or restores the agent session.
- It returns 503 (AgentUnavailableException) if the agent cannot start.
- It is not @Transactional, because ensure refuses active transactions.
- UI (compositions.ts):
- Types: Composition, CompositionItem, CompositionItemType = "workflow" | "action-type" | "tool",
CompositionRevision, ConfigProblem, ChangeSet (re-exported from @apitomy/flow-ui),
UndoResult, PublishResult, and ApiError extends Error { status: number; body: unknown }.
- Functions: each throws ApiError on a non-2xx response, using extractErrorMessage for the
message and keeping the parsed body.
- listCompositions()
- createComposition({name, goal?, seedWorkflowId?})
- fetchComposition(id)
- updateComposition(id, {name?, applyMode?})
- deleteComposition(id)
- ensureCompositionSession(id)
- listItems(id)
- addItem(id, {type, name?, existingItemId?})
- fetchItem(id, itemId)
- saveItem(id, itemId, {content, baseRevision, turnId?})
- deleteItem(id, itemId)
- listRevisions(id, itemId)
- restoreRevision(id, itemId, revisionId)
- validateItemContent(id, itemId, content), returning {valid, problems}
- validateComposition(id)
- publishComposition(id, itemIds, force?)
- sendCompositionMessage(id, {message, nodeIds?, edgeIds?})
- interruptComposition(id)
- resolveChangeSet(id, {turnId, outcome, fromPending})
- undoTurn(id, turnId)
- SSE: subscribeComposition(compositionId: number, handlers: { itemChanged?, turn?, changeSet? }): () => void
wraps sseClient.subscribe, filters by data.compositionId === compositionId, and returns the
unsubscribe function.
- [ ] Step 1: Backend, write the failing test (append to
CompositionTurnsResourceTest):
@Test
void ensureSessionStartsTheAgent() {
given().post(BASE + "/{id}/session", compositionId).then().statusCode(200).body("id", equalTo((int) compositionId));
org.mockito.Mockito.verify(sessions).ensure(compositionId);
}
- [ ] Step 2: Run it:
mvn -q -pl app -am test -Dtest=CompositionTurnsResourceTest -Dsurefire.failIfNoSpecifiedTests=false. Expected: FAIL with 404 or 405. - [ ] Step 3: Add the path to
openapi.json: - tag
Compositions, the fullcompositionIdparameter; - responses
200(Composition),404and503(CompositionConflictErrorshape).
Regenerate with mvn -q -pl app -am compile. Implement the method as
workbenchSessions.ensure(id); return toBean(service.get(id));, without @Transactional.
- [ ] Step 4: Run the test again. Expected: PASS.
- [ ] Step 5: UI, write the failing test ui/src/config/compositions.test.ts:
import { afterEach, describe, expect, it, vi } from "vitest";
import { ApiError, resolveChangeSet, saveItem, subscribeComposition } from "./compositions";
import { sseClient } from "./sse";
function mockFetch(status: number, body: unknown) {
return vi.spyOn(globalThis, "fetch").mockResolvedValue(new Response(JSON.stringify(body), {
status, headers: { "Content-Type": "application/json" },
}));
}
describe("compositions api", () => {
afterEach(() => vi.restoreAllMocks());
it("saves an item with base revision and turn id", async () => {
const fetchSpy = mockFetch(200, { id: 2, name: "t", type: "tool", contentRevision: "sha256:b", lastAuthor: "ai", changed: true });
await saveItem(1, 2, { content: { name: "t" }, baseRevision: "sha256:a", turnId: "turn-1" });
const [url, init] = fetchSpy.mock.calls[0];
expect(String(url)).toMatch(/\/api\/v1\/compositions\/1\/items\/2$/);
expect((init as RequestInit).method).toBe("PUT");
expect(JSON.parse(String((init as RequestInit).body))).toEqual({ content: { name: "t" }, baseRevision: "sha256:a", turnId: "turn-1" });
});
it("throws ApiError with status and body on 409", async () => {
mockFetch(409, { message: "stale", currentRevision: "sha256:z" });
const error = await saveItem(1, 2, { content: {}, baseRevision: "x" }).catch((e) => e);
expect(error).toBeInstanceOf(ApiError);
expect(error.status).toBe(409);
expect((error.body as { currentRevision: string }).currentRevision).toBe("sha256:z");
});
it("posts resolutions with fromPending", async () => {
const fetchSpy = mockFetch(204, {});
await resolveChangeSet(1, { turnId: "t", outcome: "stale", fromPending: true });
expect(JSON.parse(String((fetchSpy.mock.calls[0][1] as RequestInit).body)))
.toEqual({ turnId: "t", outcome: "stale", fromPending: true });
});
it("routes SSE events by composition", () => {
let listener: ((e: { type: string; data: Record<string, unknown> }) => void) | undefined;
vi.spyOn(sseClient, "subscribe").mockImplementation((l) => { listener = l; return () => {}; });
const changeSet = vi.fn();
subscribeComposition(5, { changeSet });
listener!({ type: "composition-changeset", data: { compositionId: 6, turnId: "x", changeSet: {} } });
listener!({ type: "composition-changeset", data: { compositionId: 5, turnId: "y", changeSet: { id: "y" } } });
expect(changeSet).toHaveBeenCalledTimes(1);
expect(changeSet).toHaveBeenCalledWith({ compositionId: 5, turnId: "y", changeSet: { id: "y" } });
});
});
A 204 response with a Response body is fine for the mock. The client must not parse JSON on a 204
response.
- [ ] Step 6: Run
npx vitest run src/config/compositions.test.ts. Expected: FAIL (module missing). - [ ] Step 7: Implement
compositions.ts: - Follow the
api.tsconventions:const API = \${getApiBaseUrl()}/api/v1`, and useextractErrorMessage, exporting it fromapi.ts` if it isn't already. - Use one private
request<T>(method, path, body?)helper that throwsApiErrorand returnsundefinedfor 204. - Types mirror the OpenAPI schemas; read field names from
openapi.json. - Every export has JSDoc.
- [ ] Step 8: Run
npx tsc -b --noEmit && npx vitest run src/config/compositions.test.ts. Expected: PASS. - [ ] Step 9: Commit
git add common/api/src/main/resources/openapi.json app/src/main/java/io/apitomy/axiom/app/rest/CompositionsResourceImpl.java \
app/src/test/java/io/apitomy/axiom/app/composition/CompositionTurnsResourceTest.java ui/src/config
git commit -m "feat(workbench): session endpoint and UI composition client"
Task 3: Pure workbench logic and preferences
Files:
- Create: ui/src/pages/workbench/workbenchLogic.ts and workbenchLogic.test.ts
- Create: ui/src/pages/workbench/workbenchPrefs.ts and workbenchPrefs.test.ts
Interfaces (workbenchLogic.ts):
- itemStatus(problems: ConfigProblem[] | undefined): "valid" | "warnings" | "errors" | "unknown"
- isAiTouchedInTurn(item: CompositionItem, turnId: string | undefined): boolean returns
!!turnId && item.lastTurnId === turnId.
- isLockedForUser(item: CompositionItem, composition: Composition): boolean is true when
turnState === "ai-working", the item is not the workflow, and item.lastTurnId === composition.currentTurnId.
- referencedActionTypes(workflow: Workflow): string[] returns the config.actionType values of
action nodes, excluding sdk:, deduplicated and in order.
- referencedTools(actionType: Record<string, unknown>): string[] handles mcp__axiom-tools__<name>
entries, where allowedTools is either a comma- or whitespace-separated string or an array.
- publishSelection(items: CompositionItem[], contents: Record<number, Record<string, unknown>>, chosen: number[]): number[]
adds, transitively, the changed draft dependencies of chosen items: a workflow brings its action types,
and an action type brings its tools. Only items in the composition whose changed is true are added. The
result is sorted ascending.
- defaultPublishChoice(items: CompositionItem[], statuses: Record<number, ReturnType<typeof itemStatus>>): number[]
returns the ids of items that are changed and not in "errors".
- undoPlan(result: UndoResult, lastWorkflowSaveTurnId: string | undefined, turnId: string): { replaceWorkflow: Workflow | null; message: string }.
It returns replaceWorkflow = result.workflowBefore only when result.workflowRestorable &&
lastWorkflowSaveTurnId === turnId && result.workflowBefore. Otherwise it is null, with a message
explaining that the workflow was left alone.
- mergeCatalog(database: ActionTypeDescriptor[], drafts: { name: string; content: Record<string, unknown> }[]): ActionTypeDescriptor[].
Drafts override database entries by value. A draft's inputs and outputs come from
content.inputs / content.outputs, and label = name. Database order is kept, with new drafts
appended.
Interfaces (workbenchPrefs.ts):
- type PaneSizes = { left: number; right: number }, defaulting to { left: 260, right: 380 }.
- loadPrefs(): { applyLayout: "place" | "tidy"; focus: boolean; panes: PaneSizes; leftCollapsed: boolean; rightCollapsed: boolean }
- savePrefs(partial), which merges into the stored object.
- The key is axiom.workbench.prefs. Invalid JSON or values fall back to the defaults.
- Pane widths are clamped to [160, 800].
- [ ] Step 1: Write the failing tests
import { describe, expect, it } from "vitest";
import {
defaultPublishChoice, isLockedForUser, itemStatus, mergeCatalog, publishSelection, referencedActionTypes,
referencedTools, undoPlan,
} from "./workbenchLogic";
import type { Composition, CompositionItem } from "../../config/compositions";
const item = (id: number, type: CompositionItem["type"], name: string, extra: Partial<CompositionItem> = {}): CompositionItem =>
({ id, type, name, contentRevision: "r", lastAuthor: "user", changed: true, ...extra });
describe("workbenchLogic", () => {
it("derives item status from problems", () => {
expect(itemStatus(undefined)).toBe("unknown");
expect(itemStatus([])).toBe("valid");
expect(itemStatus([{ severity: "warning", code: "c", message: "m" }])).toBe("warnings");
expect(itemStatus([{ severity: "warning", code: "c", message: "m" }, { severity: "error", code: "c", message: "m" }])).toBe("errors");
});
it("locks only non-workflow items touched by the running turn", () => {
const composition = { turnState: "ai-working", currentTurnId: "t1" } as Composition;
expect(isLockedForUser(item(1, "tool", "a", { lastTurnId: "t1" }), composition)).toBe(true);
expect(isLockedForUser(item(2, "tool", "b", { lastTurnId: "t0" }), composition)).toBe(false);
expect(isLockedForUser(item(3, "workflow", "w", { lastTurnId: "t1" }), composition)).toBe(false);
expect(isLockedForUser(item(1, "tool", "a", { lastTurnId: "t1" }), { ...composition, turnState: "idle" })).toBe(false);
});
it("finds references", () => {
expect(referencedActionTypes({ id: "w", name: "w", edges: [], nodes: [
{ id: "a", type: "action", name: "A", config: { actionType: "triage" } },
{ id: "b", type: "action", name: "B", config: { actionType: "sdk:x" } },
{ id: "c", type: "action", name: "C", config: { actionType: "triage" } },
] } as never)).toEqual(["triage"]);
expect(referencedTools({ allowedTools: "Read, mcp__axiom-tools__gh mcp__axiom-tools__jq" })).toEqual(["gh", "jq"]);
expect(referencedTools({ allowedTools: ["mcp__axiom-tools__x"] })).toEqual(["x"]);
});
it("selects changed draft dependencies transitively", () => {
const items = [item(1, "workflow", "wf"), item(2, "action-type", "at"), item(3, "tool", "gh"),
item(4, "tool", "unchanged", { changed: false }), item(5, "action-type", "other")];
const contents = {
1: { nodes: [{ id: "a", type: "action", config: { actionType: "at" } }], edges: [] },
2: { name: "at", allowedTools: "mcp__axiom-tools__gh mcp__axiom-tools__unchanged" },
};
expect(publishSelection(items, contents, [1])).toEqual([1, 2, 3]);
expect(defaultPublishChoice(items, { 1: "valid", 2: "errors", 3: "valid", 4: "valid", 5: "warnings" })).toEqual([1, 3, 5]);
});
it("plans undo of the workflow only when the turn's change is the latest save", () => {
const before = { id: "w", name: "w", nodes: [], edges: [] };
const result = { restored: [], removed: [], skipped: [], workflowBefore: before, workflowRestorable: true };
expect(undoPlan(result as never, "t1", "t1").replaceWorkflow).toBe(before);
expect(undoPlan(result as never, "t2", "t1").replaceWorkflow).toBeNull();
expect(undoPlan({ ...result, workflowRestorable: false } as never, "t1", "t1").replaceWorkflow).toBeNull();
});
it("merges draft action types into the catalog", () => {
const merged = mergeCatalog(
[{ value: "a", label: "a" }, { value: "b", label: "b", description: "db" }],
[{ name: "b", content: { name: "b", inputs: [{ name: "x", type: "string" }] } }, { name: "c", content: { name: "c" } }]);
expect(merged.map((d) => d.value)).toEqual(["a", "b", "c"]);
expect(merged[1].inputs).toEqual([{ name: "x", type: "string" }]);
});
});
import { beforeEach, describe, expect, it } from "vitest";
import { loadPrefs, savePrefs } from "./workbenchPrefs";
describe("workbenchPrefs", () => {
beforeEach(() => localStorage.clear());
it("returns defaults and merges saved values", () => {
expect(loadPrefs()).toEqual({ applyLayout: "place", focus: false, panes: { left: 260, right: 380 },
leftCollapsed: false, rightCollapsed: false });
savePrefs({ applyLayout: "tidy", panes: { left: 5000, right: 10 } });
expect(loadPrefs().applyLayout).toBe("tidy");
expect(loadPrefs().panes).toEqual({ left: 800, right: 160 });
});
it("ignores corrupt storage", () => {
localStorage.setItem("axiom.workbench.prefs", "{nope");
expect(loadPrefs().focus).toBe(false);
});
});
- [ ] Step 2: Run
npx vitest run src/pages/workbench. Expected: FAIL (modules missing). - [ ] Step 3: Implement both modules as pure functions with JSDoc. They must not import React.
- [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run src/pages/workbench. Expected: PASS. - [ ] Step 5: Commit
git add ui/src/pages/workbench
git commit -m "feat(workbench): pure workbench logic and preferences"
Task 4: Extract ActionTypeEditor
Files:
- Create: ui/src/components/ActionTypeEditor.tsx and ui/src/components/ActionTypeEditor.test.tsx
- Modify: ui/src/pages/ActionTypeDetailPage.tsx (744 lines)
Interfaces:
- Produces ActionTypeEditor(props: ActionTypeEditorProps):
export interface ActionTypeEditorProps {
/** The action type as configuration-item JSON (NewActionType shape; allowedTools and environment included). */
value: NewActionType;
/** Called with the complete new value on every edit. */
onChange: (value: NewActionType) => void;
/** Disables every input and hides AI helpers and label editing. */
readOnly?: boolean;
/** Problems to show in the Problems tab instead of running the editor's own validation. */
problems?: ValidationMessage[];
}
- What moves into the component:
- all tabs (Info, Allowed Tools, Environment, Inputs, Outputs, Prompt Template, AI Config, Script, Code, Problems)
- the tab-fallback effect
ScriptAiModal,ActionTypeAiModal,EditLabelsModal- available models and engines loading
- debounced validation, but only when
problemsis not given - Folding the extra state into
value: toolsbecomesvalue.allowedTools, using the existing string format, so check howupdateActionTypesends it.envVarsbecomesvalue.environment.- The editor derives the arrays and records from
valueon every render, and writes them back throughonChange. - What stays in
ActionTypeDetailPage: loading, theactionTyperecord,dirty, saving,saveError, the page header, breadcrumbs and the Save button. It now renders<ActionTypeEditor value={form} onChange={(v) => { setForm(v); setDirty(true); }} />and savesformas is. - Local subcomponents:
InfoTab,PromptTemplateTabandScriptTabmove intoActionTypeEditor.tsxunchanged. If the file grows past about 600 lines, split them intocomponents/actionType/*.tsxand note it in the report. -
Behaviour must not change: existing behaviour on
/action-types/:idmust be identical, including that changingengineresetsmodel. -
[ ] Step 1: Write the failing test
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ActionTypeEditor } from "./ActionTypeEditor";
vi.mock("@patternfly/react-code-editor", () => ({
CodeEditor: ({ code }: { code: string }) => <pre data-testid="code-editor">{code}</pre>,
Language: { json: "json", javascript: "javascript", shell: "shell", markdown: "markdown" },
}));
vi.mock("../config/api", async (orig) => ({
...(await orig<typeof import("../config/api")>()),
fetchSystemConfig: vi.fn().mockResolvedValue({ engines: [] }),
validateActionType: vi.fn().mockResolvedValue({ valid: true, errors: [], warnings: [] }),
}));
describe("ActionTypeEditor", () => {
it("is controlled: edits call onChange with the whole value", () => {
const onChange = vi.fn();
render(<ActionTypeEditor value={{ name: "at", description: "old", executionMode: "node", inputs: [], outputs: [] }}
onChange={onChange} />);
fireEvent.change(screen.getByDisplayValue("old"), { target: { value: "new" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: "at", description: "new", executionMode: "node" }));
});
it("shows the Code tab for node action types and disables inputs when read-only", () => {
render(<ActionTypeEditor readOnly value={{ name: "at", description: "d", executionMode: "node",
codeTemplate: "export async function run() { return {}; }", inputs: [], outputs: [] }} onChange={() => {}} />);
expect(screen.getByText("Code")).toBeTruthy();
expect((screen.getByDisplayValue("d") as HTMLInputElement).disabled || (screen.getByDisplayValue("d") as HTMLInputElement).readOnly).toBe(true);
});
it("shows given problems", () => {
render(<ActionTypeEditor value={{ name: "at", executionMode: "node", inputs: [], outputs: [] }} onChange={() => {}}
problems={[{ severity: "error", message: "codeTemplate is required" }]} />);
expect(screen.getByText("Problems")).toBeTruthy();
});
});
ValidationMessage is the type that ValidationProblemsPanel already takes. Import it from where it is
defined (find it with git grep -n "ValidationMessage" ui/src). Adjust the test's problem literal to that
type's real fields. Adjust the fetchSystemConfig mock to the function the page actually uses to load
engines and models.
- [ ] Step 2: Run
npx vitest run src/components/ActionTypeEditor.test.tsx. Expected: FAIL. - [ ] Step 3: Extract the editor as described. Keep every behaviour.
- [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS, includingActionTypeDetailModal.test.tsxand the other existing tests. - [ ] Step 5: Commit
git add ui/src/components/ActionTypeEditor.tsx ui/src/components/ActionTypeEditor.test.tsx ui/src/pages/ActionTypeDetailPage.tsx
git commit -m "refactor(ui): extract a controlled ActionTypeEditor"
Task 5: Extract ToolEditor
Files:
- Create: ui/src/components/ToolEditor.tsx and ui/src/components/ToolEditor.test.tsx
- Modify: ui/src/pages/ToolDetailPage.tsx (635 lines)
Interfaces:
export interface ToolEditorProps {
/** The tool as configuration-item JSON: name, description, parameters, scriptTemplate, labels. */
value: NewToolDefinition;
onChange: (value: NewToolDefinition) => void;
readOnly?: boolean;
problems?: ValidationMessage[];
/** Saved tool id; the Test tab is shown only when set (testing drafts is Phase 5). */
toolId?: number;
}
- What moves into the component: the tabs Info, Parameters, Script Template, Test (only when
toolIdis set) and Problems; theToolAiModal; andEditLabelsModal. - Folding the extra state into
value:paramsbecomesvalue.parametersandlabelsbecomesvalue.labels. - What stays in
ToolDetailPage: loading,dirty, thebeforeunloadguard, save, and the header. It renders<ToolEditor value={form} onChange={...} toolId={id} />. -
Behaviour must not change on
/tools/:id. -
[ ] Step 1: Write the failing test
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen } from "@testing-library/react";
import { ToolEditor } from "./ToolEditor";
vi.mock("@patternfly/react-code-editor", () => ({
CodeEditor: ({ code }: { code: string }) => <pre data-testid="code-editor">{code}</pre>,
Language: { shell: "shell", json: "json" },
}));
vi.mock("../config/api", async (orig) => ({
...(await orig<typeof import("../config/api")>()),
validateTool: vi.fn().mockResolvedValue({ valid: true, errors: [], warnings: [] }),
}));
describe("ToolEditor", () => {
it("is controlled", () => {
const onChange = vi.fn();
render(<ToolEditor value={{ name: "t", description: "old", parameters: [], scriptTemplate: "echo" }} onChange={onChange} />);
fireEvent.change(screen.getByDisplayValue("old"), { target: { value: "new" } });
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ name: "t", description: "new" }));
});
it("hides the Test tab without a saved tool id", () => {
const { rerender } = render(<ToolEditor value={{ name: "t", parameters: [] }} onChange={() => {}} />);
expect(screen.queryByText("Test")).toBeNull();
rerender(<ToolEditor value={{ name: "t", parameters: [] }} onChange={() => {}} toolId={3} />);
expect(screen.getByText("Test")).toBeTruthy();
});
});
- [ ] Step 2: Run
npx vitest run src/components/ToolEditor.test.tsx. Expected: FAIL. - [ ] Step 3: Extract as described.
- [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
git add ui/src/components/ToolEditor.tsx ui/src/components/ToolEditor.test.tsx ui/src/pages/ToolDetailPage.tsx
git commit -m "refactor(ui): extract a controlled ToolEditor"
Task 6: Chat panel overrides for send and interrupt
Files:
- Modify: ui/src/components/assistant/AssistantChatPanel.tsx
- Test: ui/src/components/assistant/AssistantChatPanel.test.tsx (create)
Interfaces:
- New optional props on AssistantChatPanelProps:
- onSend?: (message: string) => Promise<void>. When given, it replaces
sendAssistantMessage(sessionId, message). The optimistic user message and the failure message work
as before; the failure text uses the thrown error's message.
- onInterrupt?: () => Promise<void>. When given, it replaces interruptAssistantSession(sessionId).
- disabledReason?: string. When set, the input is disabled and the reason is shown as its placeholder,
for example "The AI is working…".
- No other behaviour changes. /assistant/:sessionId passes none of these props.
- [ ] Step 1: Write the failing test
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { AssistantChatPanel } from "./AssistantChatPanel";
import * as api from "../../config/api";
vi.mock("../../config/sse", () => ({
sseClient: { connect: vi.fn(), subscribe: vi.fn(() => () => {}), subscribeSession: vi.fn(() => () => {}),
onReconnect: vi.fn(() => () => {}) },
}));
describe("AssistantChatPanel overrides", () => {
it("uses onSend instead of the generic endpoint", async () => {
vi.spyOn(api, "fetchAssistantSessionHistory").mockResolvedValue([]);
const generic = vi.spyOn(api, "sendAssistantMessage").mockResolvedValue();
const onSend = vi.fn().mockResolvedValue(undefined);
render(<AssistantChatPanel sessionId="s1" onSend={onSend} />);
const input = await screen.findByRole("textbox");
fireEvent.change(input, { target: { value: "hello" } });
fireEvent.keyDown(input, { key: "Enter" });
await waitFor(() => expect(onSend).toHaveBeenCalledWith("hello"));
expect(generic).not.toHaveBeenCalled();
});
it("disables input with a reason", async () => {
vi.spyOn(api, "fetchAssistantSessionHistory").mockResolvedValue([]);
render(<AssistantChatPanel sessionId="s1" disabledReason="The AI is working…" />);
const input = await screen.findByPlaceholderText("The AI is working…");
expect((input as HTMLTextAreaElement).disabled).toBe(true);
});
});
The submit gesture (Enter versus a send button) and the input role must match the real input component.
Read AssistantChatPanel and its input child, and adapt the two interaction lines if needed. Keep the
assertions.
- [ ] Step 2: Run
npx vitest run src/components/assistant/AssistantChatPanel.test.tsx. Expected: FAIL. - [ ] Step 3: Implement the three props.
- [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
git add ui/src/components/assistant
git commit -m "feat(ui): let the chat panel delegate send and interrupt"
Task 7: Workbench list page, routes, navigation, chrome-less mode and "Open in workbench"
Files:
- Create: ui/src/pages/WorkbenchListPage.tsx and ui/src/pages/WorkbenchListPage.test.tsx
- Create: ui/src/pages/workbench/WorkbenchPage.tsx. In this task it is a placeholder that renders the
composition name; Task 8 replaces it.
- Modify: ui/src/App.tsx, ui/src/components/AppSidebar.tsx, ui/src/components/AppSidebar.test.tsx,
ui/src/pages/WorkflowDefinitionDetailPage.tsx
Interfaces and behaviour:
- Routes: /workbench renders WorkbenchListPage, and /workbench/:compositionId renders
WorkbenchPage.
- App.tsx: treat ?chrome=none like ?breakout=true, so there is no masthead and no sidebar. Add
chrome === "none" to the existing isBreakout expression. Also hide the sidebar when the workbench is
in focus mode: WorkbenchPage adds ?focus=1 to the URL. Hide the masthead and sidebar when
location.pathname.startsWith("/workbench/") and the focus query parameter is "1".
- Sidebar: add a Workbench NavItem as the first item in the Components group, and add
"/workbench" to COMPONENT_PATHS.
- WorkbenchListPage:
- A table of compositions with columns Name, Goal, State (idle / AI working) and Updated. Each row
links to /workbench/:id.
- A New composition button opens a modal with Name (required) and Goal (optional). It calls
createComposition then addItem(id, { type: "workflow", name }), so every new composition starts with
a blank workflow, and navigates to the new workbench.
- A Delete action per row, with a confirmation.
- An empty state.
- WorkflowDefinitionDetailPage: add an Open in workbench button in the page header. It calls
createComposition({ name: definition.name, seedWorkflowId: id }) and navigates to /workbench/:newId.
It is disabled while dirty, with a tooltip "Save first".
- [ ] Step 1: Write the failing tests
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { MemoryRouter, Route, Routes } from "react-router-dom";
import { WorkbenchListPage } from "./WorkbenchListPage";
import * as compositions from "../config/compositions";
describe("WorkbenchListPage", () => {
it("lists compositions and creates one with a blank workflow", async () => {
vi.spyOn(compositions, "listCompositions").mockResolvedValue([
{ id: 1, name: "Triage", goal: "triage issues", turnState: "idle", applyMode: "live",
createdAt: "2026-10-12T00:00:00Z", updatedAt: "2026-10-12T00:00:00Z" },
]);
const create = vi.spyOn(compositions, "createComposition").mockResolvedValue({ id: 2, name: "New", turnState: "idle",
applyMode: "live", createdAt: "", updatedAt: "" });
const addItem = vi.spyOn(compositions, "addItem").mockResolvedValue({} as never);
render(<MemoryRouter initialEntries={["/workbench"]}><Routes>
<Route path="/workbench" element={<WorkbenchListPage />} />
<Route path="/workbench/:compositionId" element={<div>opened</div>} />
</Routes></MemoryRouter>);
expect(await screen.findByText("Triage")).toBeTruthy();
fireEvent.click(screen.getByRole("button", { name: /new composition/i }));
fireEvent.change(screen.getByLabelText(/name/i), { target: { value: "New" } });
fireEvent.click(screen.getByRole("button", { name: /^create$/i }));
await waitFor(() => expect(screen.getByText("opened")).toBeTruthy());
expect(create).toHaveBeenCalledWith(expect.objectContaining({ name: "New" }));
expect(addItem).toHaveBeenCalledWith(2, { type: "workflow", name: "New" });
});
});
Extend AppSidebar.test.tsx with a test that the Workbench item exists and navigates to /workbench,
in the style of that file's existing tests.
- [ ] Step 2: Run
npx vitest run src/pages/WorkbenchListPage.test.tsx src/components/AppSidebar.test.tsx. Expected: FAIL. - [ ] Step 3: Implement the pages, the routes, the sidebar item, the
App.tsxchrome rules and the button. - [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
Task 8: Workbench page shell (load, session, SSE, panes, focus, break-out, toolbar)
Files:
- Create: ui/src/pages/workbench/useWorkbench.ts, ui/src/pages/workbench/useWorkbench.test.tsx
- Replace: ui/src/pages/workbench/WorkbenchPage.tsx; create WorkbenchPage.css and WorkbenchPage.test.tsx
Interfaces:
- useWorkbench(compositionId: number) returns a WorkbenchState:
export interface WorkbenchState {
composition: Composition | null;
items: CompositionItem[];
/** Item content by item id, loaded on demand (the workflow is loaded eagerly). */
contents: Record<number, Record<string, unknown>>;
/** Problems by item id from POST /validate (refreshed on load, after saves and after turns; debounced 800 ms). */
problems: Record<number, ConfigProblem[]>;
/** Pending change set to apply, with where it came from. Cleared by markPendingHandled. */
pending: { turnId: string; changeSet: ChangeSet; fromPending: boolean } | null;
/** The last AI turn that completed in this browser session (for Undo AI turn and AI badges). */
lastCompletedTurnId: string | undefined;
sessionError: string | null;
loadContent(itemId: number): Promise<Record<string, unknown>>;
refreshItems(): Promise<void>;
refreshComposition(): Promise<void>;
setContent(itemId: number, content: Record<string, unknown>, revision: string): void;
markPendingHandled(): void;
retrySession(): Promise<void>;
}
Behaviour:
- On mount:
1. Fetch the composition and its items, and load the workflow's content.
2. Call ensureCompositionSession. A 503 sets sessionError and does not block editing.
3. Run validateComposition.
4. If composition.pendingChangeSet and pendingTurnId are set, set pending with fromPending: true.
- SSE, through subscribeComposition:
- itemChanged: refresh the items. If the item's content is loaded and the event's revision differs from
the loaded revision, reload that content. Then debounce a re-validation.
- turn: refresh the composition. When state is idle, set lastCompletedTurnId = turnId and
re-validate.
- changeSet: set pending with fromPending: false.
- On SSE reconnect (sseClient.onReconnect): refresh the composition and items, and adopt a pending
change set from the server if one has not already been handled.
WorkbenchPage layout (CSS grid):
- Toolbar:
- composition name
- apply mode toggle (Apply live / Review changes; updateComposition with applyMode)
- layout preference (Place new nodes / Tidy after AI changes; workbenchPrefs)
- Focus (Ctrl+Shift+F; toggles ?focus=1)
- Break out: opens /workbench/:id?chrome=none in a new window and shows an inline notice "Opened in
another window" with a Bring back here button that dismisses it
- Publish…
- Left pane: CompositionTree (Task 9). Centre: tabs (Tasks 10 and 11) with ProblemsStrip below
them (Task 12). Right: ChatPane (Task 12).
- Splitters: two draggable splitters, built like the resize handle in AssistantSubagentPanel.tsx:70-90.
Widths come from and are saved to workbenchPrefs. Each side pane has a collapse control and shrinks to a
32 px rail; the chat rail shows a pulsing dot while the AI is working. Maximize canvas collapses both.
- Tabs: the workflow tab is always open. Item tabs open on demand, close with ✕, and the open set is kept
in component state.
In this task the left, centre and right panes may render minimal placeholders, which Tasks 9 to 12 replace, except the workflow tab placeholder must show the workflow item's name.
- [ ] Step 1: Write the failing tests
import { describe, expect, it, vi, beforeEach } from "vitest";
import { act, renderHook, waitFor } from "@testing-library/react";
import { useWorkbench } from "./useWorkbench";
import * as c from "../../config/compositions";
let sse: Parameters<typeof c.subscribeComposition>[1] = {};
beforeEach(() => {
vi.restoreAllMocks();
vi.spyOn(c, "fetchComposition").mockResolvedValue({ id: 1, name: "C", turnState: "idle", applyMode: "live",
createdAt: "", updatedAt: "", pendingTurnId: "t0", pendingChangeSet: { id: "t0", baseRevision: "r", author: "agent:axiom-workbench", summary: "s", ops: [] } } as never);
vi.spyOn(c, "listItems").mockResolvedValue([{ id: 10, type: "workflow", name: "wf", contentRevision: "r", lastAuthor: "user", changed: true }]);
vi.spyOn(c, "fetchItem").mockResolvedValue({ id: 10, type: "workflow", name: "wf", contentRevision: "r", lastAuthor: "user",
changed: true, content: { id: "w", name: "wf", nodes: [], edges: [] } } as never);
vi.spyOn(c, "ensureCompositionSession").mockResolvedValue({ id: 1, assistantSessionId: "s1" } as never);
vi.spyOn(c, "validateComposition").mockResolvedValue({ items: [{ itemId: 10, type: "workflow", name: "wf", valid: true, problems: [] }] });
vi.spyOn(c, "subscribeComposition").mockImplementation((_id, handlers) => { sse = handlers; return () => {}; });
});
describe("useWorkbench", () => {
it("loads, ensures the session and adopts a stored pending change set", async () => {
const { result } = renderHook(() => useWorkbench(1));
await waitFor(() => expect(result.current.contents[10]).toBeTruthy());
expect(c.ensureCompositionSession).toHaveBeenCalledWith(1);
expect(result.current.pending).toEqual(expect.objectContaining({ turnId: "t0", fromPending: true }));
expect(result.current.problems[10]).toEqual([]);
});
it("takes live change sets from SSE and records completed turns", async () => {
const { result } = renderHook(() => useWorkbench(1));
await waitFor(() => expect(result.current.contents[10]).toBeTruthy());
act(() => result.current.markPendingHandled());
act(() => sse.changeSet?.({ compositionId: 1, turnId: "t1", changeSet: { id: "t1" } as never }));
expect(result.current.pending).toEqual(expect.objectContaining({ turnId: "t1", fromPending: false }));
await act(async () => sse.turn?.({ compositionId: 1, turnId: "t1", state: "idle" }));
expect(result.current.lastCompletedTurnId).toBe("t1");
});
it("reports a session start failure without blocking", async () => {
vi.spyOn(c, "ensureCompositionSession").mockRejectedValue(Object.assign(new c.ApiError("agent down", 503, {}), {}));
const { result } = renderHook(() => useWorkbench(1));
await waitFor(() => expect(result.current.sessionError).toContain("agent down"));
expect(result.current.items).toHaveLength(1);
});
});
WorkbenchPage.test.tsx mocks ./useWorkbench and @apitomy/flow-ui. It renders the page inside
MemoryRouter at /workbench/1 and asserts:
- the toolbar shows the composition name;
- clicking the apply mode toggle calls updateComposition(1, { applyMode: "review" });
- clicking Focus sets ?focus=1 (check with a useLocation probe);
- collapsing the left pane saves leftCollapsed: true (spy on savePrefs).
Match the ApiError constructor signature to Task 2's implementation.
- [ ] Step 2: Run
npx vitest run src/pages/workbench. Expected: the new tests FAIL. - [ ] Step 3: Implement
useWorkbenchand the page shell. - [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
git add ui/src/pages/workbench ui/src/App.tsx
git commit -m "feat(workbench): page shell with live composition state, panes and focus mode"
Task 9: Composition tree, revisions and publish dialog (left pane)
Files:
- Create in ui/src/pages/workbench/: CompositionTree.tsx, RevisionList.tsx, PublishDialog.tsx, and
CompositionTree.test.tsx, PublishDialog.test.tsx
- Modify: WorkbenchPage.tsx (wire them in)
Interfaces:
- CompositionTree props:
- items, problems, composition, lastCompletedTurnId, selectedItemId
- onOpen(itemId)
- onAdd(type: "action-type" | "tool"), which opens an add dialog: a New name, or Existing, a
select filled from fetchActionTypes(1, 1000) / fetchTools(...) (reuse existing api.ts functions)
- onRestored(item)
- Row rendering:
- Group order: Workflow, Action types, Tools.
- Each row shows a status icon from itemStatus (check, warning or error icon; nothing for unknown), an
AI label when isAiTouchedInTurn(item, lastCompletedTurnId), and a dot with the tooltip
"Unpublished changes" when item.changed.
- Items locked by isLockedForUser show a lock icon.
- Revisions: selecting an item shows RevisionList below the tree.
- It loads listRevisions and shows author (You / AI), turn and time, newest first.
- Restore calls restoreRevision, then onRestored(item).
- For the workflow, WorkbenchPage applies a restored workflow through the editor handle with
replace(content, 'host') (Task 10), so the canvas matches.
- PublishDialog props: compositionId, items, contents (it loads missing contents itself for
dependency checks), problems, onPublished(result) and onClose.
- Checkboxes start from defaultPublishChoice. Each change runs publishSelection to add dependencies,
and dependency-added rows show "required by …".
- Publish calls publishComposition(id, chosen, false):
- 409: show the message and a Publish anyway button, which calls again with force = true.
- 422: list problems by key.
- Success: show "Created N, updated M", call onPublished, and refresh items.
- [ ] Step 1: Write the failing tests
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import { PublishDialog } from "./PublishDialog";
import * as c from "../../config/compositions";
const items = [
{ id: 1, type: "workflow", name: "wf", contentRevision: "r", lastAuthor: "user", changed: true },
{ id: 2, type: "action-type", name: "at", contentRevision: "r", lastAuthor: "ai", changed: true },
] as c.CompositionItem[];
const contents = { 1: { nodes: [{ id: "a", type: "action", config: { actionType: "at" } }], edges: [] }, 2: { name: "at" } };
describe("PublishDialog", () => {
it("auto-selects dependencies and forces after a conflict", async () => {
const publish = vi.spyOn(c, "publishComposition")
.mockRejectedValueOnce(new c.ApiError("Changed outside the workbench since loaded: action-type/at", 409, {}))
.mockResolvedValueOnce({ created: 1, updated: 1, items: [] });
const onPublished = vi.fn();
render(<PublishDialog compositionId={9} items={items} contents={contents} problems={{ 1: [], 2: [] }}
onPublished={onPublished} onClose={() => {}} />);
fireEvent.click(screen.getByLabelText("at"));
fireEvent.click(screen.getByLabelText("at"));
expect((screen.getByLabelText("at") as HTMLInputElement).checked).toBe(true);
fireEvent.click(screen.getByRole("button", { name: /^publish$/i }));
await screen.findByText(/changed outside the workbench/i);
fireEvent.click(screen.getByRole("button", { name: /publish anyway/i }));
await waitFor(() => expect(onPublished).toHaveBeenCalled());
expect(publish).toHaveBeenLastCalledWith(9, [1, 2], true);
});
it("lists blocking problems on 422", async () => {
vi.spyOn(c, "publishComposition").mockRejectedValue(new c.ApiError("Cannot publish", 422,
{ message: "Cannot publish", problems: { "workflow/wf": [{ severity: "error", code: "x", message: "broken node" }] } }));
render(<PublishDialog compositionId={9} items={items} contents={contents} problems={{}} onPublished={() => {}} onClose={() => {}} />);
fireEvent.click(screen.getByRole("button", { name: /^publish$/i }));
expect(await screen.findByText(/broken node/)).toBeTruthy();
});
});
In the first test, "at" is unchecked and then checked again. Because the workflow (checked by default) requires it, it must stay checked (dependency-locked). Implement it so that a required dependency's checkbox is checked and disabled; then the double click is harmless.
Write CompositionTree.test.tsx along the same lines. It must assert:
- group order;
- the status icons (query by aria-label "valid", "warnings", "errors");
- the AI label for the last turn;
- the unpublished dot;
- that clicking a row calls onOpen.
- [ ] Step 2: Run
npx vitest run src/pages/workbench. Expected: FAIL. - [ ] Step 3: Implement the components and wire them into
WorkbenchPage. - [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
git add ui/src/pages/workbench
git commit -m "feat(workbench): composition tree, revisions and publish dialog"
Task 10: Workflow tab (Flow editor, saves, change sets, context actions)
Files:
- Create: ui/src/pages/workbench/WorkflowTab.tsx and WorkflowTab.test.tsx
- Modify: WorkbenchPage.tsx (wire it in)
Interfaces:
export interface WorkflowTabHandle {
/** Saves any pending debounced change immediately. */
flush(): Promise<void>;
/** Replaces the editor document (host origin), e.g. for Undo AI turn or a restored revision. */
replace(workflow: Workflow): void;
/** Current editor selection. */
selection(): { nodeIds: string[]; edgeIds: string[] };
}
export interface WorkflowTabProps {
compositionId: number;
item: CompositionItem;
initialContent: Workflow;
composition: Composition;
catalog: ActionTypeDescriptor[];
applyLayout: "place" | "tidy";
pending: { turnId: string; changeSet: ChangeSet; fromPending: boolean } | null;
onPendingHandled: () => void;
onSaved: (item: CompositionItem, content: Workflow, savedTurnId: string | undefined) => void;
onOpenActionType: (value: string, resolved: boolean) => void;
onAskAi: (prefill: string) => void;
ref?: React.Ref<WorkflowTabHandle>;
}
Behaviour:
- Editor: render WorkflowEditor with:
- ref = the editor handle;
- workflow={initialContent}, which is seed-only;
- theme from useEffectiveTheme();
- highlightApplied;
- appliedChangeLayout={applyLayout};
- spi = { actionTypes: catalog, openActionType, validate, contextActions } (memoized), where:
- validate(workflow) calls validateItemContent(compositionId, item.id, workflow) and maps the
problems to Flow ValidationProblem (severity, message, and node id from path when it matches
nodes[<id>] or nodes/<id>; otherwise no target);
- contextActions(context) returns one item, Ask AI about this… (or Ask AI to fix this for a
problem target):
- It is disabled when composition.turnState === "ai-working", and hidden when context.readOnly.
- onSelect builds a prefill and calls onAskAi.
- Prefill for a node: About node `<id>` (<name>, <type>):
- Prefill for an edge: About edge `<id>` (<source> → <target>):
- Prefill for a selection: About nodes <ids> and edges <ids>:
- Prefill for a problem: Please fix this problem: <message>
- Prefill for the canvas: an empty string.
- Saving: onChange(workflow, meta) records the content and schedules a save 500 ms later. If
meta.origin starts with agent:, it records turnIdForNextSave = the turn being applied.
- The save calls saveItem(compositionId, item.id, { content, baseRevision: lastSavedRevision, turnId }).
- On success: update lastSavedRevision and call onSaved(item, content, turnId).
- On 409: show an inline warning "This workflow was changed elsewhere." with Reload. Reload calls
fetchItem, then handle.replace(content, 'host'), sets lastSavedRevision, and suppresses that
replace's save.
- Other errors show an inline danger alert.
- flush() runs any scheduled save at once and awaits it.
- Pending change sets: when the pending prop changes to a set not yet seen (tracked by turnId):
1. Await flush().
2. If composition.applyMode === "review", call handle.propose(cs); otherwise call handle.apply(cs).
3. Resolve the change set according to this table, then call onPendingHandled():
| Handle result | Resolution |
|---|---|
apply returns applied |
resolveChangeSet(..., outcome "applied", fromPending) |
rejected with code stale |
outcome stale |
rejected with any other code |
outcome rejected, and show an alert "The AI's workflow changes could not be applied: |
propose returns staged |
wait for onProposalResolved(id, outcome) |
proposal accepted |
applied |
proposal rejected or withdrawn |
rejected |
proposal stale |
stale |
replace(workflow): callshandle.replace(workflow, 'host'). TheonChangethat follows saves as the user.-
selection(): returns the lastonSelectionChangevalue. -
[ ] Step 1: Write the failing test
WorkflowTab.test.tsx. Mock@apitomy/flow-uiwith a fake editor:
import { describe, expect, it, vi, beforeEach } from "vitest";
import { act, render, waitFor } from "@testing-library/react";
import { useImperativeHandle } from "react";
import * as c from "../../config/compositions";
import { WorkflowTab } from "./WorkflowTab";
const handle = { apply: vi.fn(), propose: vi.fn(), replace: vi.fn(), withdraw: vi.fn(), clearHighlights: vi.fn(),
getSnapshot: vi.fn() };
let editorProps: Record<string, any> = {};
vi.mock("@apitomy/flow-ui", () => ({
WorkflowEditor: (props: Record<string, any>) => {
editorProps = props;
useImperativeHandle(props.ref, () => handle);
return <div data-testid="editor" />;
},
}));
vi.mock("../../hooks/useTheme", () => ({ useEffectiveTheme: () => "light" }));
const base = {
compositionId: 1,
item: { id: 10, type: "workflow", name: "wf", contentRevision: "r0", lastAuthor: "user", changed: true } as c.CompositionItem,
initialContent: { id: "w", name: "wf", nodes: [], edges: [] },
composition: { id: 1, name: "C", turnState: "idle", applyMode: "live", createdAt: "", updatedAt: "" } as c.Composition,
catalog: [], applyLayout: "place" as const, onSaved: vi.fn(), onOpenActionType: vi.fn(), onAskAi: vi.fn(),
onPendingHandled: vi.fn(),
};
describe("WorkflowTab", () => {
beforeEach(() => { vi.restoreAllMocks(); Object.values(handle).forEach((f) => f.mockReset()); vi.useRealTimers(); });
it("applies a live pending change set and resolves it as applied", async () => {
handle.apply.mockReturnValue({ status: "applied" });
const resolve = vi.spyOn(c, "resolveChangeSet").mockResolvedValue(undefined);
const cs = { id: "t1", baseRevision: "r", author: "agent:axiom-workbench", summary: "s", ops: [] };
render(<WorkflowTab {...base} pending={{ turnId: "t1", changeSet: cs as never, fromPending: true }} />);
await waitFor(() => expect(resolve).toHaveBeenCalledWith(1, { turnId: "t1", outcome: "applied", fromPending: true }));
expect(handle.apply).toHaveBeenCalledWith(cs);
expect(base.onPendingHandled).toHaveBeenCalled();
});
it("resolves stale apply results as stale", async () => {
handle.apply.mockReturnValue({ status: "rejected", error: { code: "stale", reason: "old" } });
const resolve = vi.spyOn(c, "resolveChangeSet").mockResolvedValue(undefined);
render(<WorkflowTab {...base} pending={{ turnId: "t2", changeSet: { id: "t2" } as never, fromPending: false }} />);
await waitFor(() => expect(resolve).toHaveBeenCalledWith(1, { turnId: "t2", outcome: "stale", fromPending: false }));
});
it("proposes in review mode and maps the proposal outcome", async () => {
handle.propose.mockReturnValue({ status: "staged" });
const resolve = vi.spyOn(c, "resolveChangeSet").mockResolvedValue(undefined);
render(<WorkflowTab {...base} composition={{ ...base.composition, applyMode: "review" }}
pending={{ turnId: "t3", changeSet: { id: "t3" } as never, fromPending: false }} />);
await waitFor(() => expect(handle.propose).toHaveBeenCalled());
act(() => editorProps.onProposalResolved("t3", "accepted"));
await waitFor(() => expect(resolve).toHaveBeenCalledWith(1, { turnId: "t3", outcome: "applied", fromPending: false }));
});
it("saves agent-origin changes with the turn id after the debounce", async () => {
handle.apply.mockReturnValue({ status: "applied" });
vi.spyOn(c, "resolveChangeSet").mockResolvedValue(undefined);
const save = vi.spyOn(c, "saveItem").mockResolvedValue({ ...base.item, contentRevision: "r1" });
render(<WorkflowTab {...base} pending={{ turnId: "t4", changeSet: { id: "t4" } as never, fromPending: false }} />);
await waitFor(() => expect(handle.apply).toHaveBeenCalled());
const next = { id: "w", name: "wf", nodes: [{ id: "n" }], edges: [] };
act(() => editorProps.onChange(next, { contentRevision: "x", origin: "agent:axiom-workbench" }));
await waitFor(() => expect(save).toHaveBeenCalledWith(1, 10, { content: next, baseRevision: "r0", turnId: "t4" }), { timeout: 2000 });
});
it("offers a reload on a 409 save", async () => {
vi.spyOn(c, "saveItem").mockRejectedValue(new c.ApiError("stale", 409, { currentRevision: "r9" }));
const { findByRole } = render(<WorkflowTab {...base} pending={null} />);
act(() => editorProps.onChange({ id: "w", name: "wf", nodes: [], edges: [] }, { contentRevision: "x", origin: "user" }));
expect(await findByRole("button", { name: /reload/i }, { timeout: 2000 })).toBeTruthy();
});
it("context actions prefill the chat and disable during AI turns", () => {
render(<WorkflowTab {...base} pending={null} composition={{ ...base.composition, turnState: "ai-working" }} />);
const actions = editorProps.spi.contextActions({ target: { kind: "node", nodeId: "a" }, readOnly: false,
workflow: { nodes: [{ id: "a", name: "A", type: "action" }], edges: [] } });
expect(actions[0].disabled).toBe(true);
actions[0].onSelect({ target: { kind: "node", nodeId: "a" }, workflow: { nodes: [{ id: "a", name: "A", type: "action" }], edges: [] } });
expect(base.onAskAi).toHaveBeenCalledWith("About node `a` (A, action): ");
});
});
- [ ] Step 2: Run
npx vitest run src/pages/workbench/WorkflowTab.test.tsx. Expected: FAIL. - [ ] Step 3: Implement
WorkflowTab. - Use a
useRefforlastSavedRevision, the pending-save timer,turnIdForNextSaveand the set of handled turn ids. - Wire it into
WorkbenchPage:catalog=mergeCatalog(await loadEditorActionTypes(), draft action types from contents). Recompute it when an action-type item's content or name changes.onOpenActionType: if a draft item with that name exists, open its tab. Otherwise, if resolved, add the existing database item (addItemwithexistingItemIdfromfindActionTypeIdByName) and open it. If unresolved, create a blank draft with that name and open it.onSaved: recordlastWorkflowSaveTurnIdforundoPlan.
- [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
git add ui/src/pages/workbench
git commit -m "feat(workbench): workflow tab with AI change sets, saves and context actions"
Task 11: Action type and tool tabs
Files:
- Create: ui/src/pages/workbench/ItemTab.tsx and ItemTab.test.tsx
- Modify: WorkbenchPage.tsx (wire it in)
Interfaces:
export interface ItemTabProps {
compositionId: number;
item: CompositionItem;
content: Record<string, unknown>;
composition: Composition;
problems: ConfigProblem[] | undefined;
onSaved: (item: CompositionItem, content: Record<string, unknown>) => void;
}
Behaviour:
- Editor: render ActionTypeEditor for action-type items and ToolEditor for tool items, with:
- value = local state, initialised from content;
- readOnly = isLockedForUser(item, composition);
- problems = the mapped problems;
- toolId = item.baseItemId, for tools.
- Locked banner: while locked, show the inline banner "The AI is editing this item. It will be editable
when the turn ends."
- Saving: onChange updates the local value and schedules a save 500 ms later with
saveItem(compositionId, item.id, { content, baseRevision }). baseRevision starts at
item.contentRevision and is updated from each save response. On success, call onSaved.
- On 409: show the warning "Changed elsewhere" with Reload, which calls fetchItem and resets the
local value and base.
- External changes: when the content prop changes from outside (an SSE refresh), adopt it only if no
local save is pending. Otherwise keep the local value, and the next save's 409 offers the reload.
- Renames: the name comes from content.name. When a save renames an item, the tab title changes after
onSaved, because WorkbenchPage refreshes the items.
- [ ] Step 1: Write the failing test
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as c from "../../config/compositions";
import { ItemTab } from "./ItemTab";
vi.mock("../../components/ToolEditor", () => ({
ToolEditor: ({ value, onChange, readOnly }: { value: { description?: string }; onChange: (v: unknown) => void; readOnly?: boolean }) => (
<input aria-label="description" value={value.description ?? ""} disabled={readOnly}
onChange={(e) => onChange({ ...value, description: e.target.value })} />),
}));
vi.mock("../../components/ActionTypeEditor", () => ({ ActionTypeEditor: () => <div /> }));
const item = { id: 5, type: "tool", name: "t", contentRevision: "r0", lastAuthor: "user", changed: true, lastTurnId: "t9" } as c.CompositionItem;
const composition = { id: 1, name: "C", turnState: "idle", applyMode: "live", createdAt: "", updatedAt: "" } as c.Composition;
describe("ItemTab", () => {
it("saves edits with the base revision", async () => {
const save = vi.spyOn(c, "saveItem").mockResolvedValue({ ...item, contentRevision: "r1" });
render(<ItemTab compositionId={1} item={item} content={{ name: "t", description: "" }} composition={composition}
problems={[]} onSaved={() => {}} />);
fireEvent.change(screen.getByLabelText("description"), { target: { value: "new" } });
await waitFor(() => expect(save).toHaveBeenCalledWith(1, 5, { content: { name: "t", description: "new" }, baseRevision: "r0" }), { timeout: 2000 });
});
it("is read-only while the AI edits it in the running turn", () => {
render(<ItemTab compositionId={1} item={item} content={{ name: "t" }}
composition={{ ...composition, turnState: "ai-working", currentTurnId: "t9" }} problems={[]} onSaved={() => {}} />);
expect((screen.getByLabelText("description") as HTMLInputElement).disabled).toBe(true);
expect(screen.getByText(/the ai is editing this item/i)).toBeTruthy();
});
});
- [ ] Step 2: Run
npx vitest run src/pages/workbench/ItemTab.test.tsx. Expected: FAIL. - [ ] Step 3: Implement
ItemTaband wire item tabs intoWorkbenchPage. Opening an item loads its content withloadContent. - [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
Task 12: Chat pane, Undo AI turn and the Problems strip
Files:
- Create in ui/src/pages/workbench/: ChatPane.tsx, ChatPane.test.tsx, ProblemsStrip.tsx
- Modify: ui/src/components/assistant/AssistantChatPanel.tsx. Add the prop
prefill?: { text: string; nonce: number }: when nonce changes, put text into the input and focus it.
Extend AssistantChatPanel.test.tsx with a test.
- Modify: WorkbenchPage.tsx (wire them in)
Interfaces:
export interface ChatPaneProps {
composition: Composition;
sessionError: string | null;
lastCompletedTurnId: string | undefined;
prefill: { text: string; nonce: number } | undefined;
/** Flushes pending saves and returns the current canvas selection. */
beforeSend: () => Promise<{ nodeIds: string[]; edgeIds: string[] }>;
onRetrySession: () => void;
/** Applies the undo plan's workflow replacement in the editor. */
onReplaceWorkflow: (workflow: Workflow) => void;
lastWorkflowSaveTurnId: string | undefined;
onUndone: () => void;
}
Behaviour:
- No session: if sessionError is set, show a danger alert with Retry. If
composition.assistantSessionId is missing, show "Starting the AI…".
- Chat: otherwise render
<AssistantChatPanel sessionId={assistantSessionId} onSend=… onInterrupt=… disabledReason=… prefill=… />:
- onSend(message): const sel = await beforeSend(); await sendCompositionMessage(composition.id, { message, ...sel });.
On a 409, throw Error("The AI is already working on a turn.").
- onInterrupt: interruptComposition(composition.id).
- disabledReason: "The AI is working…" while turnState === "ai-working".
- Undo bar: when lastCompletedTurnId is set and the turn state is idle, show a bar above the chat:
"Last AI turn · Undo AI turn". Undo calls undoTurn(composition.id, turnId), then
undoPlan(result, lastWorkflowSaveTurnId, turnId):
- If the plan has replaceWorkflow, call onReplaceWorkflow with it.
- Then show an info alert summarising what was restored, removed and skipped, plus the plan's message, and
call onUndone().
- A 409 shows "Wait for the AI turn to finish before undoing."
- ProblemsStrip: a collapsible strip under the centre tabs with the title "Problems (N)". It lists the
active item's problems as severity · path · message. When there are none it shows "No problems". The
"Test output" area is deferred to Phase 5, so add no placeholder.
- [ ] Step 1: Write the failing tests
import { describe, expect, it, vi } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
import * as c from "../../config/compositions";
import { ChatPane } from "./ChatPane";
let chatProps: Record<string, any> = {};
vi.mock("../../components/assistant/AssistantChatPanel", () => ({
AssistantChatPanel: (props: Record<string, any>) => { chatProps = props; return <div data-testid="chat" />; },
}));
const composition = { id: 1, name: "C", turnState: "idle", applyMode: "live", createdAt: "", updatedAt: "",
assistantSessionId: "s1" } as c.Composition;
const props = { composition, sessionError: null, lastCompletedTurnId: "t1", prefill: undefined,
beforeSend: vi.fn().mockResolvedValue({ nodeIds: ["a"], edgeIds: [] }), onRetrySession: vi.fn(),
onReplaceWorkflow: vi.fn(), lastWorkflowSaveTurnId: "t1", onUndone: vi.fn() };
describe("ChatPane", () => {
it("sends through the composition with the selection after flushing", async () => {
const send = vi.spyOn(c, "sendCompositionMessage").mockResolvedValue(composition);
render(<ChatPane {...props} />);
await chatProps.onSend("add a log step");
expect(props.beforeSend).toHaveBeenCalled();
expect(send).toHaveBeenCalledWith(1, { message: "add a log step", nodeIds: ["a"], edgeIds: [] });
});
it("undoes the last turn and replaces the workflow when allowed", async () => {
const before = { id: "w", name: "wf", nodes: [], edges: [] };
vi.spyOn(c, "undoTurn").mockResolvedValue({ restored: ["tool/t"], removed: [], skipped: [],
workflowBefore: before, workflowRestorable: true } as never);
render(<ChatPane {...props} />);
fireEvent.click(screen.getByRole("button", { name: /undo ai turn/i }));
await waitFor(() => expect(props.onReplaceWorkflow).toHaveBeenCalledWith(before));
expect(await screen.findByText(/tool\/t/)).toBeTruthy();
});
it("disables the chat while the AI works and shows session errors", () => {
const { rerender } = render(<ChatPane {...props} composition={{ ...composition, turnState: "ai-working" }} />);
expect(chatProps.disabledReason).toBe("The AI is working…");
rerender(<ChatPane {...props} sessionError="agent down" />);
expect(screen.getByText(/agent down/)).toBeTruthy();
expect(screen.getByRole("button", { name: /retry/i })).toBeTruthy();
});
});
- [ ] Step 2: Run
npx vitest run src/pages/workbench/ChatPane.test.tsx src/components/assistant. Expected: FAIL. - [ ] Step 3: Implement and wire
ChatPaneandProblemsStripintoWorkbenchPage: beforeSendcalls the workflow tab'sflush()andselection().onReplaceWorkflowcalls the workflow tab'sreplace().- Context actions call
onAskAi, which setsprefill = { text, nonce: Date.now() }. - [ ] Step 4: Run
npx tsc -b --noEmit && npx vitest run. Expected: PASS. - [ ] Step 5: Commit
Task 13: Verification, including the first live end-to-end run
Files: none, apart from fixes found here (each in its own commit).
- [ ] Step 1: Automated
- Run
cd ui && npx tsc -b --noEmit && npx vitest run. Expected: everything passes. - Run
mvn -q packagefrom the repository root. Expected: exit 0, and no<failureor<errorin the Surefire reports. Report both totals. - [ ] Step 2: Live run. This step needs a running Axiom and a real engine. Ask the user to start
quarkus:devfrom this branch's checkout, or get their approval to start it. Never run./build.shormvn clean. Then use Chrome DevTools MCP againsthttp://localhost:9090. Record each check as passed or failed, with notes: - Create a new composition on
/workbench. The workbench opens with a blank workflow, and the chat starts with "Starting the AI…" followed by the welcome message. - Ask: "Add a node action type that returns {greeting: 'hi'} and call it from the workflow between Start
and End."
- The AI uses its tools: watch
get_composition,upsert_itemandpropose_workflow_changesin the chat. - When the turn ends, the canvas updates with highlights, the tree shows the AI badge, and the action type tab opens with its code.
- The AI uses its tools: watch
- Edit the workflow by hand (rename a node). Then ask the AI to change something else. The preamble
mentions your edit (check the session history or
raw-events), and the AI does not undo your rename. - Switch to Review changes, ask for another change, and Accept it in the review bar.
- Undo AI turn restores the previous state.
- Stale path: while the AI is working, rename a node that it is about to change. When the turn ends, the change set is stale, and the AI is re-prompted once.
- Publish…: publish the workflow together with its action type. It appears under Workflows and Action Types.
- Focus mode and Break out work, and the break-out window shows no masthead.
- Restart the dev server, reopen the composition, and send a message. The history is restored and the recap is sent.
- [ ] Step 3: Report. List what passed. List what failed, with screenshots or console errors. List what could not be checked, for example a missing engine. Do not claim a live pass that was not observed.