diff --git a/apps/web/src/components/ChatView.tsx b/apps/web/src/components/ChatView.tsx index 5487a7ae..9ec2ab32 100644 --- a/apps/web/src/components/ChatView.tsx +++ b/apps/web/src/components/ChatView.tsx @@ -108,6 +108,7 @@ import { shouldSurfaceProviderAccountRateLimits } from "../lib/codexRateLimits"; import { BranchToolbar } from "./BranchToolbar"; import { resolveShortcutCommand } from "../keybindings"; import PlanSidebar from "./PlanSidebar"; +import { WorkflowObservatoryDialog } from "./workflow/WorkflowObservatoryDialog"; import { SessionRail } from "./chat/SessionRail"; import { ComposerAsyncQuestionsPanel } from "./chat/ComposerAsyncQuestionsPanel"; import { persistExactAsyncQuestionAnswer } from "./chat/asyncQuestions"; @@ -3476,6 +3477,17 @@ export default function ChatView(props: ChatViewProps) { const closePlanSidebar = useCallback(() => { setPlanSidebarOpenForCurrentThread(false); }, [setPlanSidebarOpenForCurrentThread]); + + // The workflow panel is a read-only overlay. It keeps its own open state so + // it cannot change the plan sidebar layout, and it closes on a thread or + // environment switch so no stale projection stays on screen. + const [workflowObservatoryOpen, setWorkflowObservatoryOpen] = useState(false); + const openWorkflowObservatory = useCallback(() => { + setWorkflowObservatoryOpen(true); + }, []); + useEffect(() => { + setWorkflowObservatoryOpen(false); + }, [environmentId, routeThreadKey]); const showSessionRail = useCallback(() => { setSessionRailDocked(true); }, [setSessionRailDocked]); @@ -6569,6 +6581,7 @@ export default function ChatView(props: ChatViewProps) { handleRuntimeModeChange={handleRuntimeModeChange} handleInteractionModeChange={handleInteractionModeChange} togglePlanSidebar={togglePlanSidebar} + {...(activeThread ? { onOpenWorkflowObservatory: openWorkflowObservatory } : {})} onOpenGoalDialog={openThreadGoalDialog} focusComposer={focusComposer} scheduleComposerFocus={scheduleComposerFocus} @@ -6670,6 +6683,25 @@ export default function ChatView(props: ChatViewProps) { ) : null} + {activeThread ? ( + + ) : null} + {expandedImage && ( )} diff --git a/apps/web/src/components/chat/ChatComposer.tsx b/apps/web/src/components/chat/ChatComposer.tsx index 86d2113e..9e6548ec 100644 --- a/apps/web/src/components/chat/ChatComposer.tsx +++ b/apps/web/src/components/chat/ChatComposer.tsx @@ -96,6 +96,7 @@ import { ImageIcon, LoaderCircleIcon, ListTodoIcon, + NetworkIcon, FileIcon, PencilIcon, Trash2Icon, @@ -181,12 +182,13 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop planSidebarOpen: boolean; onToggleInteractionMode: () => void; onTogglePlanSidebar: () => void; + onOpenWorkflowObservatory?: (() => void) | undefined; }) { const usesNativePermissionModes = props.provider === "claudeAgent" || props.provider === "grok"; const showStandaloneInteractionMode = props.showInteractionModeToggle && !usesNativePermissionModes; - if (!showStandaloneInteractionMode && !props.showPlanToggle) { + if (!showStandaloneInteractionMode && !props.showPlanToggle && !props.onOpenWorkflowObservatory) { return null; } @@ -240,6 +242,24 @@ const ComposerFooterModeControls = memo(function ComposerFooterModeControls(prop ) : null} + + {props.onOpenWorkflowObservatory ? ( + <> + + + + ) : null} ); }); @@ -829,6 +849,8 @@ export interface ChatComposerProps extends ComposerInteractionCallbacks { handleRuntimeModeChange: (mode: RuntimeMode) => void; handleInteractionModeChange: (mode: ProviderInteractionMode) => void; togglePlanSidebar: () => void; + /** Opens the read-only workflow panel. Absent for draft threads. */ + onOpenWorkflowObservatory?: (() => void) | undefined; onOpenGoalDialog: () => void; onOpenSubagentDetail?: (workEntry: WorkLogEntry, trigger: HTMLButtonElement) => void; @@ -920,6 +942,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) handleRuntimeModeChange, handleInteractionModeChange, togglePlanSidebar, + onOpenWorkflowObservatory, onOpenGoalDialog, onOpenSubagentDetail, focusComposer, @@ -3445,6 +3468,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) onToggleInteractionMode={cycleComposerInteractionMode} onNativePermissionModeChange={handleClaudePermissionModeChange} onTogglePlanSidebar={togglePlanSidebar} + onOpenWorkflowObservatory={onOpenWorkflowObservatory} onRuntimeModeChange={handleRuntimeModeChange} onOpenGoal={onOpenGoalDialog} /> @@ -3477,6 +3501,7 @@ export const ChatComposer = memo(function ChatComposer(props: ChatComposerProps) planSidebarOpen={planSidebarOpen} onToggleInteractionMode={cycleComposerInteractionMode} onTogglePlanSidebar={togglePlanSidebar} + onOpenWorkflowObservatory={onOpenWorkflowObservatory} /> {goalControlsSupported && interactionMode !== "plan" ? ( <> diff --git a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx index 29a0db23..9cf1ea26 100644 --- a/apps/web/src/components/chat/CompactComposerControlsMenu.tsx +++ b/apps/web/src/components/chat/CompactComposerControlsMenu.tsx @@ -12,6 +12,7 @@ import { ListTodoIcon, LockIcon, LockOpenIcon, + NetworkIcon, PenLineIcon, ShieldCheckIcon, TargetIcon, @@ -98,6 +99,8 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls onToggleInteractionMode: () => void; onNativePermissionModeChange: (mode: ClaudePermissionMode) => void; onTogglePlanSidebar: () => void; + /** Opens the read-only workflow panel. Absent for draft threads. */ + onOpenWorkflowObservatory?: (() => void) | undefined; onRuntimeModeChange: (mode: RuntimeMode) => void; onOpenGoal?: () => void; }) { @@ -232,6 +235,24 @@ export const CompactComposerControlsMenu = memo(function CompactComposerControls ) : null} + {props.onOpenWorkflowObservatory ? ( + <> + {hasTraits || + props.showInteractionModeToggle || + showAccessControls || + props.showGoalControl || + props.showPlanSidebar ? ( + + ) : null} + + + Show workflow + + + ) : null} ); diff --git a/apps/web/src/components/workflow/WorkflowGraph.tsx b/apps/web/src/components/workflow/WorkflowGraph.tsx new file mode 100644 index 00000000..b8e26e85 --- /dev/null +++ b/apps/web/src/components/workflow/WorkflowGraph.tsx @@ -0,0 +1,176 @@ +import { memo, useEffect, useId, useMemo, useState } from "react"; + +import { deriveWorkflowGraphLayout } from "../../workflowGraph"; +import type { WorkflowNode, WorkflowNodeStatus } from "../../workflowProjection"; + +const STATUS_COLOR: Readonly> = { + queued: "#f59e0b", + running: "#60a5fa", + waiting: "#a78bfa", + completed: "#34d399", + failed: "#f87171", + interrupted: "#fb923c", + unknown: "#94a3b8", +}; + +const STATUS_LABEL: Readonly> = { + queued: "Queued", + running: "Running", + waiting: "Waiting", + completed: "Completed", + failed: "Failed", + interrupted: "Interrupted", + unknown: "Not reported", +}; + +/** + * Draws the workflow nodes and the edges the projection reports. + * + * An edge means that the thread recorded the agent. It is not a claim that one + * agent started another: no current Cafe provider reports that relationship. + */ +export const WorkflowGraph = memo(function WorkflowGraph({ + nodes, +}: { + readonly nodes: readonly WorkflowNode[]; +}) { + const layout = useMemo(() => deriveWorkflowGraphLayout(nodes), [nodes]); + const markerId = useId().replaceAll(":", ""); + const [selectedId, setSelectedId] = useState(() => nodes[0]?.id ?? null); + + useEffect(() => { + if (selectedId && nodes.some((node) => node.id === selectedId)) return; + setSelectedId(nodes[0]?.id ?? null); + }, [nodes, selectedId]); + + const selected = nodes.find((node) => node.id === selectedId) ?? null; + const selectedParent = selected?.parentId + ? (nodes.find((node) => node.id === selected.parentId) ?? null) + : null; + + return ( +
+
+
+ + + {layout.nodes.map((entry) => { + const active = entry.node.id === selectedId; + const color = STATUS_COLOR[entry.node.status]; + return ( + + ); + })} +
+
+ + {layout.unknownParentCount > 0 ? ( +

+ {layout.unknownParentCount} relationship + {layout.unknownParentCount === 1 ? " is" : "s are"} unknown or cyclic. Cafe does not draw + them. +

+ ) : null} + {layout.duplicateNodeIdCount > 0 ? ( +

+ {layout.duplicateNodeIdCount} repeated node identifier + {layout.duplicateNodeIdCount === 1 ? " was" : "s were"} dropped. +

+ ) : null} + + {selected ? ( +
+

{selected.title}

+

+ {selected.objective ?? selected.detail ?? "Objective not reported"} +

+

+ Recorded by:{" "} + {selected.parentId === null + ? "This node is the thread." + : (selectedParent?.title ?? "The source reported a thread that is not available.")} +

+
+ ) : null} + +
    + {nodes.map((node) => ( +
  1. + {node.title}, {node.kind}, status {STATUS_LABEL[node.status]},{" "} + {node.parentId + ? `recorded by ${nodes.find((candidate) => candidate.id === node.parentId)?.title ?? "an unavailable thread"}` + : "no parent reported"} +
  2. + ))} +
+
+ ); +}); diff --git a/apps/web/src/components/workflow/WorkflowObservatory.browser.tsx b/apps/web/src/components/workflow/WorkflowObservatory.browser.tsx new file mode 100644 index 00000000..a67e9da4 --- /dev/null +++ b/apps/web/src/components/workflow/WorkflowObservatory.browser.tsx @@ -0,0 +1,324 @@ +import "../../index.css"; + +import type { OrchestrationThreadActivity } from "@cafecode/contracts"; +import { EventId, ProviderDriverKind, ThreadId, TurnId } from "@cafecode/contracts"; +import { userEvent } from "vitest/browser"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { render } from "vitest-browser-react"; + +import type { WorkflowLatestTurn } from "../../workflowProjection"; +import { CompactComposerControlsMenu } from "../chat/CompactComposerControlsMenu"; +import { WorkflowObservatoryDialog } from "./WorkflowObservatoryDialog"; + +const TURN = TurnId.make("turn-1"); + +let sequence = 0; + +function activity( + kind: string, + payload: unknown, + overrides: Partial = {}, +): OrchestrationThreadActivity { + sequence += 1; + return { + id: EventId.make(`event-${sequence}`), + tone: "tool", + kind, + summary: `${kind} for ${String((payload as { taskId?: string }).taskId ?? "thread")}`, + payload, + turnId: TURN, + sequence, + createdAt: new Date(Date.UTC(2026, 8, 11, 12, 0, sequence)).toISOString(), + ...overrides, + } as OrchestrationThreadActivity; +} + +const RUNNING_TURN: WorkflowLatestTurn = { + turnId: TURN, + state: "running", + requestedAt: "2026-09-11T12:00:00.000Z", + startedAt: "2026-09-11T12:00:01.000Z", + completedAt: null, +}; + +/** Fixture events. These are written here, not captured from a provider. */ +function fixtureActivities(prefix: string): OrchestrationThreadActivity[] { + return [ + activity("task.started", { + taskId: `${prefix}-audit`, + taskType: "subagent", + subagent: { + threadId: `${prefix}-audit`, + label: `${prefix} audit`, + objective: "Check the release notes", + status: "active", + }, + }), + activity("task.progress", { + taskId: `${prefix}-audit`, + description: "Reading the changelog", + subagent: { threadId: `${prefix}-audit`, status: "active" }, + }), + activity("task.started", { + taskId: `${prefix}-docs`, + taskType: "subagent", + subagent: { + threadId: `${prefix}-docs`, + label: `${prefix} docs`, + objective: "Update the guide", + status: "active", + }, + }), + activity("task.completed", { + taskId: `${prefix}-docs`, + status: "completed", + subagent: { threadId: `${prefix}-docs`, status: "completed" }, + }), + ]; +} + +async function mountDialog( + overrides: Partial[0]> = {}, +) { + const host = document.createElement("div"); + document.body.append(host); + const props = { + activePlan: null, + activities: fixtureActivities("alpha"), + environmentId: "environment-local", + latestTurn: RUNNING_TURN, + modelLabel: "gpt-5.2", + onOpenChange: vi.fn(), + open: true, + providerLabel: "codex", + threadId: ThreadId.make("thread-1") as string, + threadTitle: "Ship the release", + timestampFormat: "24-hour" as const, + ...overrides, + }; + const screen = await render(, { container: host }); + + return { + props, + screen, + rerender: (next: Partial) => + screen.rerender(), + cleanup: async () => { + await screen.unmount(); + host.remove(); + }, + }; +} + +const dialogRoot = () => document.querySelector('[data-testid="workflow-observatory-dialog"]'); + +describe("WorkflowObservatoryDialog", () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + it("opens without a proposed plan and states that no plan is reported", async () => { + const mounted = await mountDialog({ activePlan: null }); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + expect(dialogRoot()?.textContent).toContain("No plan reported for this thread."); + expect(dialogRoot()?.textContent).toContain("Ship the release"); + + await mounted.cleanup(); + }); + + it("shows the reported agents, the fidelity, and the source summary", async () => { + const mounted = await mountDialog(); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + expect(document.querySelector('[data-testid="workflow-fidelity"]')?.textContent).toBe( + "Live progress", + ); + const summary = document.querySelector('[data-testid="workflow-source-summary"]')?.textContent; + expect(summary).toContain("Provider: codex"); + expect(summary).toContain("Model: gpt-5.2"); + expect(summary).toContain("Agents reported: 2"); + expect(dialogRoot()?.textContent).toContain("alpha audit"); + expect(dialogRoot()?.textContent).toContain("alpha docs"); + + await mounted.cleanup(); + }); + + it("expands and collapses one node detail", async () => { + const mounted = await mountDialog(); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + const completed = document.querySelector( + '[data-testid^="workflow-node-agent:"] button[aria-expanded="false"]', + ); + expect(completed).not.toBeNull(); + await userEvent.click(completed!); + await vi.waitFor(() => expect(completed!.getAttribute("aria-expanded")).toBe("true")); + expect(completed!.closest("li")?.textContent).toContain("Observed span:"); + + await userEvent.click(completed!); + await vi.waitFor(() => expect(completed!.getAttribute("aria-expanded")).toBe("false")); + + await mounted.cleanup(); + }); + + it("switches between the list view and the graph view", async () => { + const mounted = await mountDialog(); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + expect(document.querySelector('[data-testid="workflow-graph"]')).toBeNull(); + + const graphButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Graph", + ); + await userEvent.click(graphButton!); + await vi.waitFor(() => + expect(document.querySelector('[data-testid="workflow-graph"]')).not.toBeNull(), + ); + + const nodeButtons = document.querySelectorAll('[data-testid^="workflow-graph-node-"]'); + expect(nodeButtons).toHaveLength(3); + await userEvent.click(nodeButtons[2] as HTMLElement); + await vi.waitFor(() => + expect( + document.querySelector('[data-testid="workflow-graph-selection"]')?.textContent, + ).toContain("Recorded by: Ship the release"), + ); + + await mounted.cleanup(); + }); + + it("replaces the projection and the view state on a thread switch", async () => { + const mounted = await mountDialog(); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + const graphButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Graph", + ); + await userEvent.click(graphButton!); + await vi.waitFor(() => + expect(document.querySelector('[data-testid="workflow-graph"]')).not.toBeNull(), + ); + + await mounted.rerender({ + activities: fixtureActivities("beta"), + threadId: ThreadId.make("thread-2") as string, + threadTitle: "Second thread", + }); + + await vi.waitFor(() => expect(dialogRoot()?.textContent).toContain("Second thread")); + expect(dialogRoot()?.textContent).not.toContain("alpha audit"); + expect(dialogRoot()?.textContent).toContain("beta audit"); + // The graph selection belongs to the previous thread, so the switch returns + // to the list view. + expect(document.querySelector('[data-testid="workflow-graph"]')).toBeNull(); + + await mounted.cleanup(); + }); + + it("resets view state across delimiter-colliding environment and thread identifiers", async () => { + const mounted = await mountDialog({ environmentId: "environment:remote", threadId: "thread" }); + try { + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + const graphButton = [...document.querySelectorAll("button")].find( + (button) => button.textContent?.trim() === "Graph", + ); + await userEvent.click(graphButton!); + await vi.waitFor(() => + expect(document.querySelector('[data-testid="workflow-graph"]')).not.toBeNull(), + ); + await mounted.rerender({ environmentId: "environment", threadId: "remote:thread" }); + expect(document.querySelector('[data-testid="workflow-graph"]')).toBeNull(); + } finally { + await mounted.cleanup(); + } + }); + + it("keeps an environment switch from showing the previous environment data", async () => { + const mounted = await mountDialog(); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + await mounted.rerender({ + activities: fixtureActivities("gamma"), + environmentId: "environment-remote", + threadTitle: "Remote thread", + }); + + await vi.waitFor(() => expect(dialogRoot()?.textContent).toContain("Remote thread")); + expect(dialogRoot()?.textContent).not.toContain("alpha audit"); + expect(dialogRoot()?.textContent).toContain("gamma audit"); + + await mounted.cleanup(); + }); + + it("states that the provider reported no agent lifecycle", async () => { + const mounted = await mountDialog({ + activities: [activity("message.delta", { text: "hello" })], + }); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + expect(document.querySelector('[data-testid="workflow-fidelity"]')?.textContent).toBe( + "Not reported", + ); + expect(document.querySelector('[data-testid="workflow-no-agents"]')?.textContent).toContain( + "reported no agent lifecycle", + ); + + await mounted.cleanup(); + }); + + it("always states the read-only limits", async () => { + const mounted = await mountDialog(); + + await vi.waitFor(() => expect(dialogRoot()).not.toBeNull()); + const limits = document.querySelector('[data-testid="workflow-limits"]')?.textContent ?? ""; + expect(limits).toContain("does not poll or prompt the provider"); + expect(limits).toContain("Providers do not report which agent started another agent"); + expect(limits).toContain("Providers report no task duration"); + + await mounted.cleanup(); + }); + + it("shows nothing while the panel is closed", async () => { + const mounted = await mountDialog({ open: false }); + + expect(dialogRoot()).toBeNull(); + + await mounted.cleanup(); + }); +}); + +describe("composer workflow control", () => { + it("offers the workflow control when no plan sidebar is available", async () => { + const onOpenWorkflowObservatory = vi.fn(); + const host = document.createElement("div"); + document.body.append(host); + const screen = await render( + , + { container: host }, + ); + + await userEvent.click(host.querySelector("button")!); + const item = await vi.waitUntil(() => + document.querySelector('[data-testid="compact-open-workflow-observatory"]'), + ); + await userEvent.click(item); + expect(onOpenWorkflowObservatory).toHaveBeenCalledTimes(1); + + await screen.unmount(); + host.remove(); + }); +}); diff --git a/apps/web/src/components/workflow/WorkflowObservatory.tsx b/apps/web/src/components/workflow/WorkflowObservatory.tsx new file mode 100644 index 00000000..0ca3e1a0 --- /dev/null +++ b/apps/web/src/components/workflow/WorkflowObservatory.tsx @@ -0,0 +1,452 @@ +import { + BotIcon, + CheckIcon, + CircleAlertIcon, + CircleDashedIcon, + Clock3Icon, + ListTreeIcon, + NetworkIcon, + PauseIcon, + StopCircleIcon, +} from "lucide-react"; +import { memo, type ReactNode, useEffect, useState } from "react"; + +import type { ActivePlanState } from "../../session-logic"; +import type { TimestampFormat } from "@cafecode/contracts/settings"; + +import { formatTimestamp } from "../../timestampFormat"; +import type { + WorkflowNode, + WorkflowNodeStatus, + WorkflowProjectionSnapshot, +} from "../../workflowProjection"; +import { Badge } from "../ui/badge"; +import { Button } from "../ui/button"; +import { WorkflowGraph } from "./WorkflowGraph"; + +/** + * Read-only view of the workflow that the selected thread recorded. + * + * The component renders one projection snapshot. It starts no request, and it + * never asks a provider for state, so opening the view cannot change a run. + */ + +/** Seconds without a recorded update before the view shows a quiet-time note. */ +const QUIET_NOTE_SECONDS = 90; + +const NON_TERMINAL_STATUSES: ReadonlySet = new Set([ + "queued", + "running", + "waiting", +]); + +function statusPresentation(status: WorkflowNodeStatus): { + readonly icon: ReactNode; + readonly label: string; + readonly className: string; +} { + switch (status) { + case "queued": + return { + icon: