From dbe26f7d8590b1d88becaa308c6b39036964d597 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:40:32 +0000 Subject: [PATCH 1/5] feat(workflow): add UI snapshot projections --- package.json | 2 +- src/workflow-ui.test.ts | 64 ++++++++++++++++++ src/workflow-ui.ts | 142 ++++++++++++++++++++++++++++++++++++++++ 3 files changed, 207 insertions(+), 1 deletion(-) create mode 100644 src/workflow-ui.test.ts create mode 100644 src/workflow-ui.ts diff --git a/package.json b/package.json index 77f4d982..3f17654a 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,7 @@ "dev": "node scripts/dev-server.mjs", "postinstall": "node scripts/fix-node-pty-permissions.mjs", "start": "node dist/cli.js serve", - "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", + "test": "tsx src/config.test.ts && tsx src/ui/card-types.test.ts && tsx src/ui/patch-display.test.ts && tsx src/ui/tool-display.test.ts && tsx src/apply-patch.test.ts && tsx src/process-platform.test.ts && tsx src/process-sessions.test.ts && tsx src/mcp-sessions.test.ts && tsx src/server-shutdown.test.ts && tsx src/local-agent-runtime.test.ts && tsx src/local-agent-adapters.test.ts && tsx src/local-agent-availability.test.ts && tsx src/local-agent-profiles.test.ts && tsx src/local-agent-targets.test.ts && tsx src/local-agent-store.test.ts && tsx src/roots.test.ts && tsx src/skills.test.ts && tsx src/workspaces.test.ts && tsx src/review-checkpoints.test.ts && tsx src/oauth-store.test.ts && tsx src/cli.test.ts && tsx src/workflow-contracts.test.ts && tsx src/workflow-errors.test.ts && tsx src/workflow-types.test.ts && tsx src/workflow-store.test.ts && tsx src/workflow-view.test.ts && tsx src/workflow-ui.test.ts && tsx src/workflow-tui.test.ts && tsx src/workflow-script.test.ts && tsx src/workflow-sandbox.test.ts && tsx src/workflow-engine.test.ts && tsx src/workflow-files.test.ts && tsx src/workflow-replay.test.ts && tsx src/workflow-schema.test.ts", "typecheck": "tsc -p tsconfig.json --noEmit" }, "keywords": [], diff --git a/src/workflow-ui.test.ts b/src/workflow-ui.test.ts new file mode 100644 index 00000000..1621ce48 --- /dev/null +++ b/src/workflow-ui.test.ts @@ -0,0 +1,64 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { WorkflowStore } from "./workflow-store.js"; +import { + loadActiveWorkflowSummaries, + loadWorkflowUiCallDetail, + loadWorkflowUiProject, + loadWorkflowUiRun, +} from "./workflow-ui.js"; + +const root = mkdtempSync(join(tmpdir(), "devspace-workflow-ui-test-")); +const store = new WorkflowStore(root); + +try { + const workspaceRoot = join(root, "project"); + const run = store.createRun({ + name: "UI run", + source: "named", + scriptPath: join(root, "run.js"), + scriptHash: "abc", + workspaceRoot, + }); + store.claimRun(run.id, process.pid); + store.appendEvent({ + runId: run.id, + type: "phase_started", + phase: "Review", + data: { title: "Review" }, + }); + store.beginAgentCall({ + runId: run.id, + callIndex: 0, + cacheKey: "key", + prompt: "Review auth", + schemaJson: JSON.stringify({ type: "object" }), + provider: "codex", + label: "Auth review", + phase: "Review", + isolation: "worktree", + worktreePath: join(root, "wt"), + }); + + const summaries = loadActiveWorkflowSummaries(store, workspaceRoot); + assert.equal(summaries[0]?.id, run.id); + assert.equal(summaries[0]?.currentPhase, "Review"); + assert.equal(summaries[0]?.calls.running, 1); + + const project = loadWorkflowUiProject(store, workspaceRoot); + assert.equal(project.runs[0]?.phases[0]?.title, "Review"); + assert.equal(loadWorkflowUiRun(store, run.id)?.name, "UI run"); + + const detail = loadWorkflowUiCallDetail(store, run.id, 0); + assert.equal(detail?.prompt, "Review auth"); + assert.deepEqual(detail?.schema, { type: "object" }); + assert.equal(detail?.worktreePath, join(root, "wt")); + assert.equal(loadWorkflowUiCallDetail(store, run.id, 99), undefined); +} finally { + store.close(); + rmSync(root, { recursive: true, force: true }); +} + +console.log("workflow-ui.test.ts: ok"); diff --git a/src/workflow-ui.ts b/src/workflow-ui.ts new file mode 100644 index 00000000..b97b526a --- /dev/null +++ b/src/workflow-ui.ts @@ -0,0 +1,142 @@ +import type { JsonValue } from "./json-types.js"; +import type { WorkflowStore } from "./workflow-store.js"; +import { + ACTIVE_WORKFLOW_STATUSES, + buildWorkflowRunView, + loadWorkflowProjectView, + type WorkflowCallCounts, + type WorkflowProjectView, + type WorkflowRunView, +} from "./workflow-view.js"; + +export interface WorkflowRunSummaryView { + id: string; + name: string; + status: WorkflowRunView["status"]; + currentPhase?: string; + calls: WorkflowCallCounts; + updatedAt: string; +} + +export interface WorkflowCallDetailView { + runId: string; + callIndex: number; + status: string; + provider: string; + model?: string; + effort?: string; + label?: string; + phase?: string; + prompt: string; + schema?: JsonValue | string; + responseText?: string; + structured?: JsonValue | string; + error?: string; + errorKind?: string; + providerSessionId?: string; + isolation: "shared" | "worktree"; + worktreePath?: string; + dirty?: boolean; + fromCache: boolean; + replayMatch?: "same_index" | "compatible_key"; + replayedFromRunId?: string; + replayedFromCallIndex?: number; + replayReason?: string; + createdAt: string; + startedAt?: string; + completedAt?: string; + updatedAt: string; +} + +export function loadActiveWorkflowSummaries( + store: WorkflowStore, + workspaceRoot: string, +): WorkflowRunSummaryView[] { + return loadWorkflowProjectView(store, workspaceRoot, { + statuses: [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 50, + }).runs.map(summarizeWorkflowRun); +} + +export function loadWorkflowUiProject( + store: WorkflowStore, + workspaceRoot: string, +): WorkflowProjectView { + return loadWorkflowProjectView(store, workspaceRoot, { + statuses: [...ACTIVE_WORKFLOW_STATUSES], + limit: 50, + eventLimit: 100, + }); +} + +export function loadWorkflowUiRun( + store: WorkflowStore, + runId: string, +): WorkflowRunView | undefined { + const run = store.getRun(runId); + if (!run) return undefined; + return buildWorkflowRunView( + run, + store.listAgentCalls(run.id), + store.listEvents(run.id, 100), + ); +} + +export function loadWorkflowUiCallDetail( + store: WorkflowStore, + runId: string, + callIndex: number, +): WorkflowCallDetailView | undefined { + const call = store.getAgentCall(runId, callIndex); + if (!call) return undefined; + return { + runId, + callIndex, + status: call.status, + provider: call.provider, + model: call.model, + effort: call.effort, + label: call.label, + phase: call.phase, + prompt: call.prompt, + schema: parseStoredJson(call.schemaJson), + responseText: call.responseText, + structured: parseStoredJson(call.structuredJson), + error: call.error, + errorKind: call.errorKind, + providerSessionId: call.providerSessionId, + isolation: call.isolation, + worktreePath: call.worktreePath, + dirty: call.dirty, + fromCache: call.fromCache, + replayMatch: call.replayMatch, + replayedFromRunId: call.replayedFromRunId, + replayedFromCallIndex: call.replayedFromCallIndex, + replayReason: call.replayReason, + createdAt: call.createdAt, + startedAt: call.startedAt, + completedAt: call.completedAt, + updatedAt: call.updatedAt, + }; +} + +export function summarizeWorkflowRun(run: WorkflowRunView): WorkflowRunSummaryView { + return { + id: run.id, + name: run.name, + status: run.status, + currentPhase: run.currentPhase, + calls: run.calls, + updatedAt: run.updatedAt, + }; +} + +function parseStoredJson(value: string | undefined): JsonValue | string | undefined { + if (value === undefined) return undefined; + try { + return JSON.parse(value) as JsonValue; + } catch { + return value; + } +} From d3643e8b40c323e436dde97663d8d084439ea6b8 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:43:46 +0000 Subject: [PATCH 2/5] feat(workflow): expose read-only app snapshots --- src/server.ts | 33 ++++++++ src/workflow-tools.ts | 180 ++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 208 insertions(+), 5 deletions(-) diff --git a/src/server.ts b/src/server.ts index c44f5032..4f4d2bab 100644 --- a/src/server.ts +++ b/src/server.ts @@ -48,6 +48,8 @@ import { createWorkspaceStore } from "./workspace-store.js"; import { formatAgentsPath, WorkspaceRegistry } from "./workspaces.js"; import { summarizeLocalAgentProfile } from "./local-agent-profiles.js"; import { registerWorkflowTools } from "./workflow-tools.js"; +import { createWorkflowStore } from "./workflow-store.js"; +import { loadActiveWorkflowSummaries } from "./workflow-ui.js"; import { formatLocalAgentProviderAvailabilitySummary, getLocalAgentProviderAvailabilitySnapshot, @@ -264,6 +266,24 @@ const workspaceAvailableAgentsFileOutputSchema = z.object({ path: z.string(), }); +const workflowCallCountsOutputSchema = z.object({ + running: z.number(), + completed: z.number(), + cached: z.number(), + failed: z.number(), + cancelled: z.number(), + observed: z.number(), +}); + +const workflowRunSummaryOutputSchema = z.object({ + id: z.string(), + name: z.string(), + status: z.enum(["starting", "running", "completed", "failed", "cancelled"]), + currentPhase: z.string().optional(), + calls: workflowCallCountsOutputSchema, + updatedAt: z.string(), +}); + const reviewFileOutputSchema = z.object({ path: z.string(), previousPath: z.string().optional(), @@ -779,6 +799,7 @@ function createMcpServer( agentProviders: z.array(workspaceLocalAgentProviderOutputSchema), agents: z.array(workspaceLocalAgentOutputSchema), skillDiagnostics: z.array(z.unknown()), + activeWorkflows: z.array(workflowRunSummaryOutputSchema), instruction: z.string(), }, ...toolWidgetDescriptorMeta(config, "workspace"), @@ -817,6 +838,16 @@ function createMcpServer( const availableAgentsFileOutputs = availableAgentsFiles.map((file) => ({ path: formatAgentsPath(file.path, workspace.root), })); + const activeWorkflows = config.subagents + ? (() => { + const workflowStore = createWorkflowStore(config); + try { + return loadActiveWorkflowSummaries(workflowStore, workspace.root); + } finally { + workflowStore.close(); + } + })() + : []; const instruction = config.skillsEnabled ? "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file. When a task matches an available skill in skills, read its path before proceeding." : "Use this workspaceId in all subsequent tool calls for this project. Do not call open_workspace again for this same folder unless this workspaceId stops working, the user asks to reopen, or you switch to a different folder/worktree. Follow loaded agentsFiles instructions. Before working under a path listed in availableAgentsFiles, read that instruction file."; @@ -870,6 +901,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles.length, availableAgentsFiles: availableAgentsFileOutputs.length, skills: visibleSkills.length, + activeWorkflows: activeWorkflows.length, agentProviders: visibleAgentProviders.length, agents: visibleAgents.length, skillDiagnostics: workspace.skillDiagnostics.length, @@ -885,6 +917,7 @@ function createMcpServer( agentsFiles: loadedAgentsFiles, availableAgentsFiles: availableAgentsFileOutputs, skills: visibleSkills, + activeWorkflows, agentProviders: visibleAgentProviders, agents: visibleAgents, skillDiagnostics: workspace.skillDiagnostics, diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 5f8c2c6c..1a329aca 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -34,6 +34,14 @@ import { WorkflowNotFoundError, WorkflowStoredDataError, } from "./workflow-errors.js"; +import { + loadWorkflowUiCallDetail, + loadWorkflowUiProject, + loadWorkflowUiRun, +} from "./workflow-ui.js"; + +const WORKSPACE_APP_URI = "ui://devspace/workspace-app.html"; +const WORKFLOW_UI_WAIT_MAX_MS = 30_000; const WORKFLOW_API_CHEATSHEET = ` Workflow scripts (JS only): @@ -84,7 +92,7 @@ export function registerWorkflowTools( .describe(`Ms to wait for early completion (default 2000, max ${WORKFLOW_MCP_YIELD_MS}).`), }, annotations: { readOnlyHint: false }, - _meta: {}, + _meta: workflowWidgetMeta(config), }, async ({ workspaceId, script, name, scriptPath, resumeFromRunId, args, yieldTimeMs }) => { const workspace = workspaces.getWorkspace(workspaceId); @@ -200,7 +208,7 @@ export function registerWorkflowTools( const yieldMs = yieldTimeMs ?? 2_000; const page = await yieldEvents(store, run.id, 0, yieldMs); - return toolResult(page); + return toolResult(page, "run_workflow"); } catch (error) { if (isWorkflowOperationError(error)) return workflowToolError(error); throw error; @@ -228,14 +236,14 @@ export function registerWorkflowTools( .describe(`Long-poll ms (default 0, max ${WORKFLOW_MCP_YIELD_MS}).`), }, annotations: { readOnlyHint: true }, - _meta: {}, + _meta: workflowWidgetMeta(config), }, async ({ runId, sinceSeq, yieldTimeMs }) => { const store = createWorkflowStore(config); try { if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); const page = await yieldEvents(store, runId, sinceSeq ?? 0, yieldTimeMs ?? 0); - return toolResult(page); + return toolResult(page, "workflow_status"); } catch (error) { if (isWorkflowOperationError(error)) return workflowToolError(error); throw error; @@ -284,7 +292,104 @@ export function registerWorkflowTools( }, ); + if (config.widgets !== "off") { + registerWorkflowUiTools(server, config, workspaces); } +} + +function registerWorkflowUiTools( + server: McpServer, + config: ServerConfig, + workspaces: WorkspaceRegistry, +): void { + registerAppTool( + server, + "workspace_workflow_activity", + { + title: "Workspace workflow activity", + description: "Read-only workflow activity for the DevSpace app.", + inputSchema: { + workspaceId: z.string(), + knownVersion: z.string().optional(), + waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(), + }, + annotations: { readOnlyHint: true }, + _meta: appOnlyToolMeta(), + }, + async ({ workspaceId, knownVersion, waitMs }) => { + const workspace = workspaces.getWorkspace(workspaceId); + const store = createWorkflowStore(config); + try { + const project = await waitForProjectSnapshot( + store, + workspace.root, + knownVersion, + waitMs ?? 0, + ); + return appToolResult({ workspaceId, project }); + } finally { + store.close(); + } + }, + ); + + registerAppTool( + server, + "workflow_ui_snapshot", + { + title: "Workflow UI snapshot", + description: "Read-only workflow snapshot for the DevSpace app.", + inputSchema: { + runId: z.string(), + knownVersion: z.string().optional(), + waitMs: z.number().int().min(0).max(WORKFLOW_UI_WAIT_MAX_MS).optional(), + }, + annotations: { readOnlyHint: true }, + _meta: appOnlyToolMeta(), + }, + async ({ runId, knownVersion, waitMs }) => { + const store = createWorkflowStore(config); + try { + const run = await waitForRunSnapshot(store, runId, knownVersion, waitMs ?? 0); + if (!run) throw new WorkflowNotFoundError(runId); + return appToolResult({ run }); + } finally { + store.close(); + } + }, + ); + + registerAppTool( + server, + "workflow_ui_call_detail", + { + title: "Workflow call detail", + description: "Read-only workflow call detail for the DevSpace app.", + inputSchema: { + runId: z.string(), + callIndex: z.number().int().min(0), + }, + annotations: { readOnlyHint: true }, + _meta: appOnlyToolMeta(), + }, + async ({ runId, callIndex }) => { + const store = createWorkflowStore(config); + try { + if (!store.getRun(runId)) throw new WorkflowNotFoundError(runId); + const call = loadWorkflowUiCallDetail(store, runId, callIndex); + if (!call) { + throw new InvalidWorkflowInputError({ + code: "invalid_argument", + message: `Unknown workflow agent call: ${runId}#${callIndex}`, + }); + } + return appToolResult({ call }); + } finally { + store.close(); + } + }, + ); +} async function yieldEvents( store: ReturnType, @@ -329,7 +434,7 @@ function toolResult(page: { nextSeq: number; terminal: boolean; callSummary: ReturnType; -}) { +}, tool: "run_workflow" | "workflow_status") { const payload = { runId: page.run.id, status: page.run.status, @@ -354,9 +459,74 @@ function toolResult(page: { return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], structuredContent: payload, + _meta: { + tool, + card: { + runId: page.run.id, + status: page.run.status, + name: page.run.name, + }, + }, + }; +} + +function workflowWidgetMeta(config: ServerConfig) { + if (config.widgets !== "full") return {}; + return { + ui: { + resourceUri: WORKSPACE_APP_URI, + visibility: ["model"] as const, + }, + }; +} + +function appOnlyToolMeta() { + return { + ui: { + visibility: ["app"] as const, + }, }; } +function appToolResult(structuredContent: Record) { + return { + content: [{ type: "text" as const, text: JSON.stringify(structuredContent) }], + structuredContent, + }; +} + +async function waitForProjectSnapshot( + store: ReturnType, + workspaceRoot: string, + knownVersion: string | undefined, + waitMs: number, +) { + const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); + for (;;) { + const project = loadWorkflowUiProject(store, workspaceRoot); + if (!knownVersion || project.version !== knownVersion || Date.now() >= deadline) { + return project; + } + await sleep(250); + } +} + +async function waitForRunSnapshot( + store: ReturnType, + runId: string, + knownVersion: string | undefined, + waitMs: number, +) { + const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); + for (;;) { + const run = loadWorkflowUiRun(store, runId); + if (!run || !knownVersion || run.version !== knownVersion || Date.now() >= deadline) { + return run; + } + await sleep(250); + } +} + function summarizeCalls(calls: ReturnType["listAgentCalls"]>) { return { reused: calls.filter((call) => call.fromCache).length, From 57296a6c48aeed2f4af47932980fdcaf3527b441 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:44:47 +0000 Subject: [PATCH 3/5] feat(ui): recognize workflow result cards --- src/ui/card-types.ts | 35 +++++++++++++++++++++++++++++++++++ src/ui/icons.ts | 6 ++++++ src/ui/tool-display.test.ts | 11 +++++++++++ src/ui/tool-display.ts | 24 ++++++++++++++++++++++++ 4 files changed, 76 insertions(+) diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 1e3c9409..2f8cb209 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -1,7 +1,10 @@ import type { App } from "@modelcontextprotocol/ext-apps"; +import type { WorkflowRunSummaryView } from "../workflow-ui.js"; export type ToolName = | "open_workspace" + | "run_workflow" + | "workflow_status" | "show_changes" | "apply_patch" | "exec_command" @@ -24,6 +27,8 @@ export interface ToolResultCard { path?: string; root?: string; status?: string; + name?: string; + runId?: string; summary?: Record; files?: Array<{ path?: string; @@ -46,6 +51,28 @@ export interface ToolResultCard { description?: string; path?: string; }>; + activeWorkflows?: WorkflowRunSummaryView[]; + callSummary?: { + reused?: number; + live?: number; + failed?: number; + running?: number; + total?: number; + }; + agentProviders?: Array<{ + name?: string; + available?: boolean; + reason?: string; + }>; + agents?: Array<{ + name?: string; + description?: string; + provider?: string; + model?: string; + effort?: string; + providerAvailable?: boolean; + providerUnavailableReason?: string; + }>; skillDiagnostics?: unknown[]; instruction?: string; } @@ -66,6 +93,8 @@ export interface ToolPayload { export function isToolName(value: unknown): value is ToolName { return ( value === "open_workspace" || + value === "run_workflow" || + value === "workflow_status" || value === "show_changes" || value === "apply_patch" || value === "exec_command" || @@ -108,6 +137,10 @@ export function isReviewTool(tool: ToolName): boolean { return tool === "show_changes"; } +export function isWorkflowTool(tool: ToolName): boolean { + return tool === "run_workflow" || tool === "workflow_status"; +} + export function isToolResultCard(value: unknown): value is Omit { return Boolean(value && typeof value === "object"); } @@ -145,6 +178,8 @@ export function isExpandableCard(card: ToolResultCard): boolean { ); } + if (isWorkflowTool(card.tool)) return Boolean(card.runId); + if (isReviewTool(card.tool)) return Boolean(card.files?.length || card.payload?.patch); if (isPatchTool(card.tool)) return Boolean(card.payload?.patch); diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 22f86002..11d67b7f 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -9,9 +9,12 @@ import { FolderOpen, FolderTree, LoaderCircle, + Maximize2, + Minimize2, Search, SquareTerminal, Terminal, + Workflow, createElement, type IconNode, } from "lucide"; @@ -25,10 +28,13 @@ export const toolIcons = { folderOpen: FolderOpen, folderTree: FolderTree, loading: LoaderCircle, + maximize: Maximize2, + minimize: Minimize2, readFile: FileText, search: Search, terminal: Terminal, terminalSquare: SquareTerminal, + workflow: Workflow, writeFile: FilePlus, } as const satisfies Record; diff --git a/src/ui/tool-display.test.ts b/src/ui/tool-display.test.ts index b9977ac8..0b8c0883 100644 --- a/src/ui/tool-display.test.ts +++ b/src/ui/tool-display.test.ts @@ -5,6 +5,8 @@ import { getToolDisplay, getToolHeaderSummary } from "./tool-display.js"; const displayCases: Array<[ToolResultCard, { title: string; tone: string }]> = [ [{ tool: "open_workspace", root: "/tmp/project" }, { title: "Opened workspace", tone: "workspace" }], + [{ tool: "run_workflow", runId: "wfr_1", name: "Review" }, { title: "Started workflow", tone: "workflow" }], + [{ tool: "workflow_status", runId: "wfr_1", name: "Review" }, { title: "Workflow status", tone: "workflow" }], [{ tool: "read", path: "src/read.ts" }, { title: "Read file", tone: "read" }], [{ tool: "write", path: "src/write.ts" }, { title: "Wrote file", tone: "write" }], [{ tool: "edit", path: "src/edit.ts" }, { title: "Edited file", tone: "edit" }], @@ -25,6 +27,7 @@ for (const [card, expected] of displayCases) { } assert.equal(getToolDisplay({ tool: "open_workspace", root: "/tmp/project" }).label, "/tmp/project"); +assert.equal(getToolDisplay({ tool: "run_workflow", runId: "wfr_1" }).label, "wfr_1"); assert.equal( getToolDisplay({ tool: "grep", summary: { pattern: "needle", scope: "src" } }).label, "needle in src", @@ -120,6 +123,14 @@ assert.deepEqual( getToolHeaderSummary({ tool: "open_workspace" }), { kind: "empty" }, ); +assert.deepEqual( + getToolHeaderSummary({ + tool: "workflow_status", + status: "running", + callSummary: { running: 2, failed: 1 }, + }), + { kind: "text", text: "running · 2 running · 1 failed" }, +); function pickDisplay(display: ReturnType) { return { diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index 7a847631..d14d746d 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -3,6 +3,7 @@ import { isPatchTool, isReviewTool, isShellTool, + isWorkflowTool, isWriteTool, summaryNumber, type ToolResultCard, @@ -31,6 +32,20 @@ export function getToolDisplay(card: ToolResultCard): ToolDisplay { label: card.root ?? card.path, tone: "workspace", }; + case "run_workflow": + return { + icon: toolIcons.workflow, + title: card.status === "completed" ? "Workflow completed" : "Started workflow", + label: card.name ?? card.runId, + tone: "workflow", + }; + case "workflow_status": + return { + icon: toolIcons.workflow, + title: "Workflow status", + label: card.name ?? card.runId, + tone: "workflow", + }; case "read": return { icon: toolIcons.readFile, @@ -129,6 +144,15 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; } + if (isWorkflowTool(card.tool)) { + const parts = [ + card.status, + card.callSummary?.running ? `${card.callSummary.running} running` : undefined, + card.callSummary?.failed ? `${card.callSummary.failed} failed` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? { kind: "text", text: parts.join(" · ") } : { kind: "empty" }; + } + if (isShellTool(card.tool)) { const parts = [ countLabel(summaryNumber(summary, "lines"), "line"), From 4bc9558df1b453544fc6ea2f21b6886a1635c80e Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:52:09 +0000 Subject: [PATCH 4/5] feat(ui): add live workspace and workflow dashboards --- src/server.ts | 4 +- src/ui/card-types.test.ts | 31 ++- src/ui/card-types.ts | 13 ++ src/ui/tool-display.ts | 1 + src/ui/workflow-dashboard.ts | 400 +++++++++++++++++++++++++++++++++++ src/ui/workspace-app.css | 297 ++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 196 +++++++++++++---- src/workflow-tools.ts | 4 +- 8 files changed, 905 insertions(+), 41 deletions(-) create mode 100644 src/ui/workflow-dashboard.ts diff --git a/src/server.ts b/src/server.ts index 4f4d2bab..4b7300b6 100644 --- a/src/server.ts +++ b/src/server.ts @@ -725,10 +725,10 @@ function createMcpServer( registerAppResource( server, - "DevSpace Diff Card", + "DevSpace App", WORKSPACE_APP_URI, { - description: "Interactive card for viewing DevSpace file diffs.", + description: "Interactive DevSpace workspace, workflow, and file-change views.", _meta: { ui: { csp: appCsp(config), diff --git a/src/ui/card-types.test.ts b/src/ui/card-types.test.ts index eb47e9a0..7e261ace 100644 --- a/src/ui/card-types.test.ts +++ b/src/ui/card-types.test.ts @@ -7,7 +7,13 @@ import { isToolName, } from "./card-types.js"; -for (const tool of ["apply_patch", "exec_command", "write_stdin"]) { +for (const tool of [ + "apply_patch", + "exec_command", + "write_stdin", + "run_workflow", + "workflow_status", +]) { assert.equal(isToolName(tool), true, `${tool} should be a recognized card tool`); } @@ -23,3 +29,26 @@ assert.equal( true, ); assert.equal(isExpandableCard({ tool: "apply_patch" }), false); +assert.equal(isExpandableCard({ tool: "run_workflow", runId: "wfr_1" }), true); +assert.equal( + isExpandableCard({ + tool: "open_workspace", + activeWorkflows: [ + { + id: "wfr_1", + name: "Review", + status: "running", + calls: { + running: 1, + completed: 0, + cached: 0, + failed: 0, + cancelled: 0, + observed: 1, + }, + updatedAt: "2026-07-26T00:00:00.000Z", + }, + ], + }), + true, +); diff --git a/src/ui/card-types.ts b/src/ui/card-types.ts index 2f8cb209..67b19fb8 100644 --- a/src/ui/card-types.ts +++ b/src/ui/card-types.ts @@ -26,6 +26,16 @@ export interface ToolResultCard { workspaceId?: string; path?: string; root?: string; + mode?: "checkout" | "worktree"; + sourceRoot?: string; + worktree?: { + path?: string; + baseRef?: string; + baseSha?: string; + dirtySource?: boolean; + detached?: boolean; + managed?: boolean; + }; status?: string; name?: string; runId?: string; @@ -174,6 +184,9 @@ export function isExpandableCard(card: ToolResultCard): boolean { Boolean(card.agentsFiles?.length) || Boolean(card.availableAgentsFiles?.length) || Boolean(card.skills?.length) || + Boolean(card.activeWorkflows?.length) || + Boolean(card.agentProviders?.length) || + Boolean(card.agents?.length) || Boolean(card.skillDiagnostics?.length) ); } diff --git a/src/ui/tool-display.ts b/src/ui/tool-display.ts index d14d746d..d989a3f8 100644 --- a/src/ui/tool-display.ts +++ b/src/ui/tool-display.ts @@ -138,6 +138,7 @@ export function getToolHeaderSummary(card: ToolResultCard): ToolHeaderSummary { if (card.tool === "open_workspace") { const parts = [ typeof summary.mode === "string" ? summary.mode : undefined, + countLabel(summaryNumber(summary, "activeWorkflows"), "workflow"), countLabel(summaryNumber(summary, "agentsFiles"), "instruction"), countLabel(summaryNumber(summary, "skills"), "skill"), ].filter((part): part is string => Boolean(part)); diff --git a/src/ui/workflow-dashboard.ts b/src/ui/workflow-dashboard.ts new file mode 100644 index 00000000..2503fa8d --- /dev/null +++ b/src/ui/workflow-dashboard.ts @@ -0,0 +1,400 @@ +import type { WorkflowRunSummaryView } from "../workflow-ui.js"; +import type { + WorkflowCallView, + WorkflowProjectView, + WorkflowRunView, +} from "../workflow-view.js"; +import type { ToolResultCard } from "./card-types.js"; +import { renderIcon, toolIcons } from "./icons.js"; + +export interface DashboardDisplayOptions { + canFullscreen: boolean; + fullscreen: boolean; + onToggleFullscreen(): void; +} + +export function renderWorkspaceDashboard( + container: HTMLElement, + card: ToolResultCard, + project: WorkflowProjectView | null, + display: DashboardDisplayOptions, +): void { + const root = node("div", { + className: `workspace-dashboard ${display.fullscreen ? "fullscreen" : "inline"}`, + }); + const runs = project?.runs ?? card.activeWorkflows ?? []; + + root.append( + renderDashboardToolbar("Workspace overview", display), + renderWorkflowSummarySection(runs), + renderAccordion( + "Workspace", + true, + renderKeyValues([ + ["Root", card.root ?? card.path ?? "Unknown"], + ["Workspace", card.workspaceId ?? "Unknown"], + ["Mode", card.mode ?? stringValue(card.summary?.mode) ?? "checkout"], + ...(card.sourceRoot ? [["Source root", card.sourceRoot] as [string, string]] : []), + ...(card.worktree?.baseRef ? [["Base ref", card.worktree.baseRef] as [string, string]] : []), + ...(card.worktree?.baseSha ? [["Base SHA", card.worktree.baseSha] as [string, string]] : []), + ]), + ), + renderAccordion( + `Loaded skills · ${card.skills?.length ?? 0}`, + false, + renderList( + card.skills?.map((skill) => ({ + title: skill.name ?? "Unnamed skill", + description: skill.description ?? skill.path, + meta: skill.path, + })) ?? [], + "No skills loaded.", + ), + ), + renderAccordion( + `Project instructions · ${card.agentsFiles?.length ?? 0}`, + false, + renderList( + card.agentsFiles?.map((file) => ({ + title: file.path ?? "AGENTS.md", + description: summarizeText(file.content), + })) ?? [], + "No project instructions loaded.", + ), + ), + renderAccordion( + `Nested instructions · ${card.availableAgentsFiles?.length ?? 0}`, + false, + renderList( + card.availableAgentsFiles?.map((file) => ({ title: file.path ?? "Unknown path" })) ?? [], + "No nested instruction files discovered.", + ), + ), + renderAccordion( + `Agent providers · ${card.agentProviders?.filter((provider) => provider.available).length ?? 0} available`, + false, + renderProviderList(card), + ), + renderAccordion( + `Agent profiles · ${card.agents?.length ?? 0}`, + false, + renderList( + card.agents?.map((agent) => ({ + title: agent.name ?? "Unnamed profile", + description: [ + agent.provider, + agent.model, + agent.effort, + agent.providerAvailable === false ? agent.providerUnavailableReason ?? "Unavailable" : undefined, + ].filter(Boolean).join(" · "), + meta: agent.description, + })) ?? [], + "No agent profiles loaded.", + ), + ), + renderAccordion( + `Warnings · ${card.skillDiagnostics?.length ?? 0}`, + false, + renderList( + card.skillDiagnostics?.map((diagnostic, index) => ({ + title: `Diagnostic ${index + 1}`, + description: summarizeDiagnostic(diagnostic), + })) ?? [], + "No workspace warnings.", + ), + ), + renderAccordion( + "Model handoff", + false, + node("div", { className: "workspace-handoff", text: card.instruction ?? "No handoff instruction." }), + ), + ); + + container.replaceChildren(root); +} + +export function renderWorkflowDashboard( + container: HTMLElement, + run: WorkflowRunView | null, + fallback: ToolResultCard, + display: DashboardDisplayOptions, +): void { + const root = node("div", { + className: `workflow-dashboard ${display.fullscreen ? "fullscreen" : "inline"}`, + }); + root.append(renderDashboardToolbar("Workflow monitor", display)); + + if (!run) { + root.append( + node("div", { + className: "dashboard-empty", + text: fallback.runId ? "Loading workflow activity…" : "No workflow run selected.", + }), + ); + container.replaceChildren(root); + return; + } + + const heading = node("section", { className: "workflow-heading" }); + const titleRow = node("div", { className: "workflow-title-row" }); + titleRow.append( + node("span", { className: `workflow-status-dot ${run.status}`, ariaHidden: "true" }), + node("div", { className: "workflow-title-copy" }, [ + node("strong", { text: run.name }), + node("span", { + className: "workflow-subtitle", + text: `${run.status}${run.currentPhase ? ` · ${run.currentPhase}` : ""}`, + }), + ]), + ); + heading.append(titleRow, renderCallCounts(run)); + root.append(heading); + + const phases = node("section", { className: "workflow-phases" }); + for (const phase of run.phases) { + const phaseSection = node("section", { className: "workflow-phase" }); + phaseSection.append(node("h3", { text: phase.title })); + const calls = node("div", { className: "workflow-call-list" }); + for (const call of phase.calls) calls.append(renderCall(call)); + if (phase.calls.length === 0) { + calls.append(node("div", { className: "dashboard-empty", text: "No observed calls in this phase." })); + } + phaseSection.append(calls); + phases.append(phaseSection); + } + if (run.unphasedCalls.length > 0) { + const unphased = node("section", { className: "workflow-phase" }); + unphased.append(node("h3", { text: "Other calls" })); + const calls = node("div", { className: "workflow-call-list" }); + for (const call of run.unphasedCalls) calls.append(renderCall(call)); + unphased.append(calls); + phases.append(unphased); + } + if (run.phases.length === 0 && run.unphasedCalls.length === 0) { + phases.append(node("div", { className: "dashboard-empty", text: "No agent calls observed yet." })); + } + root.append(phases); + + if (run.recentActivity.length > 0) { + const activity = node("section", { className: "workflow-activity" }); + activity.append(node("h3", { text: "Recent activity" })); + for (const event of run.recentActivity.slice(-8).reverse()) { + activity.append( + node("div", { className: "workflow-event" }, [ + node("time", { text: formatTime(event.createdAt) }), + node("span", { + text: `${event.label ?? event.phase ?? event.type.replaceAll("_", " ")}${event.detail ? ` · ${event.detail}` : ""}`, + }), + ]), + ); + } + root.append(activity); + } + + if (run.error) { + root.append( + node("section", { className: "workflow-error" }, [ + node("strong", { text: run.errorKind ?? "Workflow error" }), + node("p", { text: run.error }), + ]), + ); + } + + container.replaceChildren(root); +} + +function renderDashboardToolbar( + title: string, + display: DashboardDisplayOptions, +): HTMLElement { + const toolbar = node("div", { className: "dashboard-toolbar" }); + toolbar.append(node("strong", { text: title })); + if (display.canFullscreen || display.fullscreen) { + const button = node("button", { + className: "display-mode-button", + type: "button", + text: display.fullscreen ? "Exit fullscreen" : "Open dashboard", + }); + button.prepend(renderIcon(display.fullscreen ? toolIcons.minimize : toolIcons.maximize)); + button.addEventListener("click", display.onToggleFullscreen); + toolbar.append(button); + } + return toolbar; +} + +function renderWorkflowSummarySection( + runs: Array, +): HTMLElement { + const section = node("section", { className: "active-workflows" }); + section.append(node("h3", { text: `Active workflows · ${runs.length}` })); + if (runs.length === 0) { + section.append(node("div", { className: "dashboard-empty", text: "No active workflows." })); + return section; + } + for (const run of runs) { + const row = node("div", { className: "active-workflow-row" }); + row.append( + node("span", { className: `workflow-status-dot ${run.status}`, ariaHidden: "true" }), + node("div", { className: "active-workflow-copy" }, [ + node("strong", { text: run.name }), + node("span", { + text: `${run.currentPhase ?? run.status} · ${summaryCounts(run.calls)}`, + }), + ]), + ); + section.append(row); + } + return section; +} + +function renderCallCounts(run: WorkflowRunView): HTMLElement { + const counts = node("div", { className: "workflow-counts" }); + const values = [ + ["Completed", run.calls.completed], + ["Replayed", run.calls.cached], + ["Running", run.calls.running], + ["Failed", run.calls.failed], + ] as const; + for (const [label, value] of values) { + if (!value) continue; + counts.append(node("span", { text: `${value} ${label.toLowerCase()}` })); + } + if (counts.childElementCount === 0) { + counts.append(node("span", { text: "No agent calls yet" })); + } + return counts; +} + +function renderCall(call: WorkflowCallView): HTMLElement { + const row = node("article", { className: `workflow-call ${call.status}` }); + const main = node("div", { className: "workflow-call-main" }); + main.append( + node("span", { className: `call-status ${call.status}`, text: callGlyph(call.status) }), + node("div", { className: "workflow-call-copy" }, [ + node("strong", { text: call.label ?? `Agent #${call.callIndex}` }), + node("span", { + text: [ + call.model ? `${call.provider}/${call.model}` : call.provider, + call.isolation === "worktree" ? "worktree" : undefined, + call.fromCache ? "replayed" : undefined, + ].filter(Boolean).join(" · "), + }), + ]), + ); + row.append(main); + if (call.error) { + row.append(node("p", { className: "workflow-call-error", text: `${call.errorKind ?? "error"}: ${call.error}` })); + } + return row; +} + +function renderAccordion(title: string, open: boolean, content: HTMLElement): HTMLElement { + const details = node("details", { className: "workspace-accordion" }) as HTMLDetailsElement; + details.open = open; + details.append(node("summary", { text: title }), content); + return details; +} + +function renderKeyValues(entries: Array<[string, string]>): HTMLElement { + const list = node("dl", { className: "workspace-key-values" }); + for (const [label, value] of entries) { + list.append(node("dt", { text: label }), node("dd", { text: value, title: value })); + } + return list; +} + +function renderProviderList(card: ToolResultCard): HTMLElement { + return renderList( + card.agentProviders?.map((provider) => ({ + title: provider.name ?? "Unknown provider", + description: provider.available ? "Available" : provider.reason ?? "Unavailable", + })) ?? [], + "No subagent providers exposed.", + ); +} + +function renderList( + items: Array<{ title: string; description?: string; meta?: string }>, + emptyText: string, +): HTMLElement { + const list = node("div", { className: "workspace-list" }); + if (items.length === 0) { + list.append(node("div", { className: "dashboard-empty", text: emptyText })); + return list; + } + for (const item of items) { + list.append( + node("div", { className: "workspace-list-row" }, [ + node("strong", { text: item.title }), + item.description ? node("span", { text: item.description }) : undefined, + item.meta ? node("code", { text: item.meta, title: item.meta }) : undefined, + ].filter((child): child is HTMLElement => Boolean(child))), + ); + } + return list; +} + +function summaryCounts(calls: WorkflowRunSummaryView["calls"]): string { + const parts = [ + calls.completed ? `${calls.completed} done` : undefined, + calls.cached ? `${calls.cached} replayed` : undefined, + calls.running ? `${calls.running} running` : undefined, + calls.failed ? `${calls.failed} failed` : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.join(" · ") || "no calls yet"; +} + +function callGlyph(status: WorkflowCallView["status"]): string { + if (status === "completed" || status === "from_cache") return "✓"; + if (status === "failed") return "✕"; + if (status === "cancelled") return "−"; + return "●"; +} + +function formatTime(value: string): string { + return new Date(value).toLocaleTimeString([], { + hour: "2-digit", + minute: "2-digit", + second: "2-digit", + }); +} + +function summarizeText(value: string | undefined): string | undefined { + if (!value) return undefined; + const compact = value.replace(/\s+/g, " ").trim(); + return compact.length > 140 ? `${compact.slice(0, 139)}…` : compact; +} + +function summarizeDiagnostic(value: unknown): string { + if (typeof value === "string") return value; + try { + return JSON.stringify(value); + } catch { + return "Unserializable diagnostic"; + } +} + +function stringValue(value: unknown): string | undefined { + return typeof value === "string" ? value : undefined; +} + +function node( + tag: K, + options: { + className?: string; + text?: string; + type?: string; + title?: string; + ariaHidden?: string; + } = {}, + children: HTMLElement[] = [], +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + if (options.className) element.className = options.className; + if (options.text !== undefined) element.textContent = options.text; + if (options.type !== undefined) element.setAttribute("type", options.type); + if (options.title !== undefined) element.title = options.title; + if (options.ariaHidden !== undefined) element.setAttribute("aria-hidden", options.ariaHidden); + element.append(...children); + return element; +} diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index ed81685c..db1503ff 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -371,3 +371,300 @@ body { grid-template-columns: 42px minmax(0, 1fr) auto 18px; } } + +html[data-display-mode="fullscreen"], +html[data-display-mode="fullscreen"] body { + min-height: 100%; + overflow: auto; +} + +html[data-display-mode="fullscreen"] .shell, +html[data-display-mode="fullscreen"] .tool-card { + min-height: 100vh; +} + +html[data-display-mode="fullscreen"] .tool-card { + border: 0; + border-radius: 0; +} + +.workspace-dashboard, +.workflow-dashboard { + display: grid; + gap: 0; + color: var(--color-text-primary, #f5f5f6); +} + +.workspace-dashboard.fullscreen, +.workflow-dashboard.fullscreen { + min-height: calc(100vh - 83px); + align-content: start; +} + +.dashboard-toolbar { + display: flex; + min-height: 52px; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 0 16px; + border-bottom: 1px solid var(--tool-card-divider); +} + +.display-mode-button { + display: inline-flex; + min-height: 34px; + align-items: center; + gap: 7px; + padding: 0 10px; + border: 0; + border-radius: 8px; + background: var(--tool-card-hover-bg); + color: var(--color-text-secondary, #d6d6dc); + cursor: pointer; + font: inherit; + font-size: var(--font-text-sm-size, 13px); +} + +.display-mode-button:hover { + color: var(--color-text-primary, #f5f5f6); +} + +.display-mode-button .icon-svg { + width: 15px; + height: 15px; +} + +.active-workflows, +.workflow-heading, +.workflow-phases, +.workflow-activity, +.workflow-error { + padding: 16px; +} + +.active-workflows, +.workflow-heading, +.workflow-phases, +.workflow-activity { + border-bottom: 1px solid var(--tool-card-divider); +} + +.active-workflows h3, +.workflow-phase h3, +.workflow-activity h3 { + margin: 0 0 12px; + font-size: var(--font-text-sm-size, 13px); + font-weight: 600; +} + +.active-workflow-row, +.workflow-title-row, +.workflow-call-main { + display: flex; + align-items: flex-start; + gap: 10px; +} + +.active-workflow-row + .active-workflow-row { + margin-top: 12px; +} + +.workflow-status-dot { + width: 9px; + height: 9px; + flex: 0 0 auto; + margin-top: 5px; + border-radius: 999px; + background: var(--color-text-tertiary, #a3a3aa); +} + +.workflow-status-dot.running, +.workflow-status-dot.starting { + background: var(--color-info-text, #72a7ff); +} + +.workflow-status-dot.completed { + background: var(--color-success-text, #6fda83); +} + +.workflow-status-dot.failed, +.workflow-status-dot.cancelled { + background: var(--color-danger-text, #ee7676); +} + +.active-workflow-copy, +.workflow-title-copy, +.workflow-call-copy, +.workspace-list-row { + display: grid; + min-width: 0; + gap: 3px; +} + +.active-workflow-copy span, +.workflow-subtitle, +.workflow-call-copy span, +.workspace-list-row span, +.workspace-list-row code { + overflow: hidden; + color: var(--color-text-secondary, #b7b7bf); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; + text-overflow: ellipsis; +} + +.workspace-list-row code { + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + white-space: nowrap; +} + +.workspace-accordion { + border-bottom: 1px solid var(--tool-card-divider); +} + +.workspace-accordion summary { + min-height: 46px; + padding: 14px 16px; + cursor: pointer; + color: var(--color-text-primary, #f5f5f6); + font-size: var(--font-text-sm-size, 13px); + font-weight: 500; +} + +.workspace-accordion summary:hover { + background: var(--tool-card-hover-bg); +} + +.workspace-key-values { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 8px 16px; + margin: 0; + padding: 0 16px 16px; + font-size: var(--font-text-sm-size, 12px); +} + +.workspace-key-values dt { + color: var(--color-text-tertiary, #a3a3aa); +} + +.workspace-key-values dd { + min-width: 0; + margin: 0; + overflow: hidden; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + text-overflow: ellipsis; + white-space: nowrap; +} + +.workspace-list { + display: grid; + gap: 12px; + padding: 0 16px 16px; +} + +.workspace-handoff { + padding: 0 16px 16px; + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.55; +} + +.workflow-heading { + display: grid; + gap: 12px; +} + +.workflow-counts { + display: flex; + flex-wrap: wrap; + gap: 7px; +} + +.workflow-counts span { + padding: 4px 8px; + border-radius: 999px; + background: var(--tool-card-hover-bg); + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); +} + +.workflow-phases { + display: grid; + gap: 20px; +} + +.workflow-call-list { + display: grid; + gap: 8px; +} + +.workflow-call { + padding: 10px 12px; + border-radius: 9px; + background: color-mix(in srgb, var(--tool-card-hover-bg) 54%, transparent); +} + +.call-status { + width: 16px; + flex: 0 0 auto; + color: var(--color-text-tertiary, #a3a3aa); + text-align: center; +} + +.call-status.completed, +.call-status.from_cache { + color: var(--color-success-text, #6fda83); +} + +.call-status.failed { + color: var(--color-danger-text, #ee7676); +} + +.workflow-call-error, +.workflow-error p { + margin: 8px 0 0 26px; + color: var(--color-danger-text, #ee7676); + font-size: var(--font-text-sm-size, 12px); + line-height: 1.45; +} + +.workflow-activity { + display: grid; + gap: 8px; +} + +.workflow-event { + display: grid; + grid-template-columns: max-content minmax(0, 1fr); + gap: 10px; + color: var(--color-text-secondary, #d6d6dc); + font-size: var(--font-text-sm-size, 12px); +} + +.workflow-event time { + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.workflow-error { + color: var(--color-danger-text, #ee7676); +} + +.dashboard-empty { + color: var(--color-text-secondary, #b7b7bf); + font-size: var(--font-text-sm-size, 13px); +} + +@media (min-width: 860px) { + .workspace-dashboard.fullscreen, + .workflow-dashboard.fullscreen { + width: min(1120px, 100%); + margin: 0 auto; + } + + .workflow-dashboard.fullscreen .workflow-phases { + grid-template-columns: repeat(2, minmax(0, 1fr)); + } +} diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index 78723864..84787372 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -5,6 +5,7 @@ import { applyHostStyleVariables, } from "@modelcontextprotocol/ext-apps"; import type { CallToolResult } from "@modelcontextprotocol/sdk/types.js"; +import type { WorkflowProjectView, WorkflowRunView } from "../workflow-view.js"; import { isEditTool, isExpandableCard, @@ -13,6 +14,7 @@ import { isReviewTool, isToolName, isToolResultCard, + isWorkflowTool, isWriteTool, payloadText, type HostContext, @@ -25,6 +27,10 @@ import { getToolHeaderSummary, type ToolDisplay, } from "./tool-display.js"; +import { + renderWorkflowDashboard, + renderWorkspaceDashboard, +} from "./workflow-dashboard.js"; import "./workspace-app.css"; interface MountedPayload { @@ -47,6 +53,10 @@ let reviewFilesExpanded = false; let errorMessage: string | null = null; let currentPayload: MountedPayload | null = null; let currentPayloadContainer: HTMLElement | null = null; +let workflowProject: WorkflowProjectView | null = null; +let workflowRun: WorkflowRunView | null = null; +let workflowRefreshKey: string | null = null; +let workflowRefreshGeneration = 0; const maybeAppRoot = document.querySelector("#app"); @@ -75,6 +85,7 @@ async function boot(): Promise { const tool = toolNameFromMeta(result); if (!tool || !isToolResultCard(structured)) { + stopWorkflowRefresh(); card = null; expanded = false; reviewFilesExpanded = false; @@ -84,8 +95,11 @@ async function boot(): Promise { } const nextCard = { ...structured, tool }; + stopWorkflowRefresh(); + workflowProject = null; + workflowRun = null; card = nextCard; - expanded = isReviewTool(tool) && isExpandableCard(nextCard); + expanded = (isReviewTool(tool) || isWorkflowTool(tool)) && isExpandableCard(nextCard); reviewFilesExpanded = false; errorMessage = null; render(); @@ -97,10 +111,11 @@ async function boot(): Promise { ...ctx, }; applyHostContext(); - renderPayloadIfNeeded(); + render(); }; app.onteardown = async () => { + stopWorkflowRefresh(); unmountPayload(); return {}; }; @@ -121,6 +136,7 @@ async function boot(): Promise { } function applyHostContext(): void { + document.documentElement.dataset.displayMode = hostContext?.displayMode ?? "inline"; if (hostContext?.theme) applyDocumentTheme(hostContext.theme); if (hostContext?.styles?.variables) { applyHostStyleVariables(hostContext.styles.variables); @@ -172,6 +188,7 @@ function render(): void { if (expandable) { button.addEventListener("click", () => { expanded = !expanded; + if (!expanded) stopWorkflowRefresh(); render(); }); } @@ -226,7 +243,14 @@ async function renderPayloadIfNeeded(): Promise { } if (card.tool === "open_workspace") { - renderPrePayload(target, workspacePayloadText(card), "open_workspace"); + renderWorkspaceDashboard(target, card, workflowProject, dashboardDisplayOptions()); + ensureWorkflowRefresh(); + return; + } + + if (isWorkflowTool(card.tool)) { + renderWorkflowDashboard(target, workflowRun, card, dashboardDisplayOptions()); + ensureWorkflowRefresh(); return; } @@ -298,6 +322,139 @@ function shouldUseHeavyPayload(card: ToolResultCard): boolean { return isReadTool(card.tool) || isEditTool(card.tool) || isWriteTool(card.tool); } +function dashboardDisplayOptions() { + const fullscreen = hostContext?.displayMode === "fullscreen"; + return { + canFullscreen: Boolean(hostContext?.availableDisplayModes?.includes("fullscreen")), + fullscreen, + onToggleFullscreen: () => { + void toggleFullscreen(); + }, + }; +} + +async function toggleFullscreen(): Promise { + if (!app) return; + const fullscreen = hostContext?.displayMode === "fullscreen"; + const mode = fullscreen ? "inline" : "fullscreen"; + if (!fullscreen && !hostContext?.availableDisplayModes?.includes("fullscreen")) return; + + try { + const result = await app.requestDisplayMode({ mode }); + hostContext = { + ...hostContext, + displayMode: result.mode, + }; + applyHostContext(); + render(); + } catch (displayError) { + errorMessage = displayError instanceof Error + ? displayError.message + : "Unable to change display mode."; + renderPayloadIfNeeded(); + } +} + +function ensureWorkflowRefresh(): void { + if (!app || !card || !expanded) return; + + const request = card.tool === "open_workspace" && card.workspaceId + ? { + key: `workspace:${card.workspaceId}`, + name: "workspace_workflow_activity", + args: { workspaceId: card.workspaceId }, + kind: "project" as const, + } + : isWorkflowTool(card.tool) && card.runId + ? { + key: `run:${card.runId}`, + name: "workflow_ui_snapshot", + args: { runId: card.runId }, + kind: "run" as const, + } + : null; + + if (!request || workflowRefreshKey === request.key) return; + workflowRefreshKey = request.key; + const generation = ++workflowRefreshGeneration; + void refreshWorkflowLoop(request, generation); +} + +async function refreshWorkflowLoop( + request: { + key: string; + name: string; + args: Record; + kind: "project" | "run"; + }, + generation: number, +): Promise { + let knownVersion = request.kind === "project" + ? workflowProject?.version + : workflowRun?.version; + + while ( + app && + expanded && + workflowRefreshGeneration === generation && + workflowRefreshKey === request.key + ) { + try { + const result = await app.callServerTool({ + name: request.name, + arguments: { + ...request.args, + knownVersion, + waitMs: 20_000, + }, + }); + if ( + workflowRefreshGeneration !== generation || + workflowRefreshKey !== request.key + ) { + return; + } + + if (request.kind === "project") { + const structured = getStructuredContent<{ project?: WorkflowProjectView }>(result); + if (structured?.project) { + workflowProject = structured.project; + knownVersion = structured.project.version; + } + } else { + const structured = getStructuredContent<{ run?: WorkflowRunView }>(result); + if (structured?.run) { + workflowRun = structured.run; + knownVersion = structured.run.version; + } + } + + await renderPayloadIfNeeded(); + if ( + request.kind === "run" && + workflowRun && + ["completed", "failed", "cancelled"].includes(workflowRun.status) + ) { + workflowRefreshKey = null; + return; + } + } catch (refreshError) { + if (workflowRefreshGeneration !== generation) return; + errorMessage = refreshError instanceof Error + ? refreshError.message + : "Unable to refresh workflow activity."; + workflowRefreshKey = null; + await renderPayloadIfNeeded(); + return; + } + } +} + +function stopWorkflowRefresh(): void { + workflowRefreshKey = null; + workflowRefreshGeneration += 1; +} + function unmountPayload(): void { unmountCurrentPayload(); currentPayload = null; @@ -445,39 +602,6 @@ function setPayloadLoading(container: HTMLElement, loading: boolean): void { if (button) button.setAttribute("aria-busy", String(loading)); } -function workspacePayloadText(card: ToolResultCard): string { - const agentsFiles = card.agentsFiles ?? []; - const availableAgentsFiles = card.availableAgentsFiles ?? []; - const skills = card.skills ?? []; - const lines = [ - card.workspaceId ? `Workspace: ${card.workspaceId}` : undefined, - card.root ? `Root: ${card.root}` : undefined, - skills.length > 0 - ? `Skills: ${skills.map((skill) => skill.name ?? skill.path ?? "unnamed").join(", ")}` - : "Skills: none", - availableAgentsFiles.length > 0 - ? `Nested instructions: ${availableAgentsFiles.map((file) => file.path ?? "unknown").join(", ")}` - : undefined, - agentsFiles.length > 0 - ? `\n${formatAgentsFilesForPayload(agentsFiles)}` - : "\nAGENTS.md: none loaded", - ].filter((line): line is string => typeof line === "string"); - - return lines.join("\n"); -} - -function formatAgentsFilesForPayload( - agentsFiles: NonNullable, -): string { - return agentsFiles - .map((file) => { - const path = file.path ?? "AGENTS.md"; - const content = file.content?.trim(); - return content ? `${path}\n\n${content}` : `${path}\n\nNo content loaded.`; - }) - .join("\n\n"); -} - function toolNameFromMeta(result: CallToolResult): ToolName | undefined { const meta = result._meta as Record | undefined; const tool = meta?.tool; diff --git a/src/workflow-tools.ts b/src/workflow-tools.ts index 1a329aca..b3255d9d 100644 --- a/src/workflow-tools.ts +++ b/src/workflow-tools.ts @@ -504,7 +504,7 @@ async function waitForProjectSnapshot( const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); for (;;) { const project = loadWorkflowUiProject(store, workspaceRoot); - if (!knownVersion || project.version !== knownVersion || Date.now() >= deadline) { + if (knownVersion === undefined || project.version !== knownVersion || Date.now() >= deadline) { return project; } await sleep(250); @@ -520,7 +520,7 @@ async function waitForRunSnapshot( const deadline = Date.now() + Math.min(waitMs, WORKFLOW_UI_WAIT_MAX_MS); for (;;) { const run = loadWorkflowUiRun(store, runId); - if (!run || !knownVersion || run.version !== knownVersion || Date.now() >= deadline) { + if (!run || knownVersion === undefined || run.version !== knownVersion || Date.now() >= deadline) { return run; } await sleep(250); From 4777bab526ba9213bb2335706638e6cfbb21424a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Sat, 25 Jul 2026 20:54:04 +0000 Subject: [PATCH 5/5] docs(ui): document read-only workflow dashboards --- docs/chatgpt-coding-workflow.md | 16 +++++++++++++--- docs/configuration.md | 2 +- skills/dynamic-workflows/SKILL.md | 2 +- 3 files changed, 15 insertions(+), 5 deletions(-) diff --git a/docs/chatgpt-coding-workflow.md b/docs/chatgpt-coding-workflow.md index 66062621..1ab0b90f 100644 --- a/docs/chatgpt-coding-workflow.md +++ b/docs/chatgpt-coding-workflow.md @@ -148,12 +148,22 @@ registered. `exec_command` returns a process session ID when a command is still running after its yield window. Use `write_stdin` to poll it, send input, resize a PTY, or send Ctrl-C. Set `tty: true` only for commands that need a terminal. -## Show Changes +## Widget UI and Show Changes By default, `DEVSPACE_WIDGETS=full`. -In that mode, DevSpace attaches widget UI to the exposed workspace, file, edit, -and shell tools. The aggregate `show_changes` tool is not exposed by default. +In that mode, DevSpace attaches widget UI to the exposed workspace, workflow, +file, edit, and shell tools. The `open_workspace` dropdown presents the opened +root, loaded skills and instructions, available agent providers/profiles, and +currently active workflows for that workspace. + +Dynamic Workflow views are read-only. They refresh through app-only MCP tools +and show observed phases, agent calls, replay state, worktree isolation, errors, +and recent activity. When the host supports MCP Apps fullscreen display mode, +the card offers an **Open dashboard** presentation control. It does not add +cancel, resume, apply, or cleanup actions. + +The aggregate `show_changes` tool is not exposed by default. Use `DEVSPACE_WIDGETS=off` to disable widget UI, or `DEVSPACE_WIDGETS=changes` to expose the aggregate show-changes flow. diff --git a/docs/configuration.md b/docs/configuration.md index 4c02eb1a..14bc2a54 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -83,7 +83,7 @@ sessions. | Value | Behavior | | --- | --- | -| `full` | Default. Widget UI is attached to exposed workspace, file, edit, and shell tools. | +| `full` | Default. Widget UI is attached to exposed workspace, workflow, file, edit, and shell tools, including read-only live workflow dashboards. | | `changes` | Enables the aggregate `show_changes` tool and attaches widget UI to `open_workspace` and `show_changes`. | | `off` | Disables widget UI. | diff --git a/skills/dynamic-workflows/SKILL.md b/skills/dynamic-workflows/SKILL.md index d0ba9562..433f8b69 100644 --- a/skills/dynamic-workflows/SKILL.md +++ b/skills/dynamic-workflows/SKILL.md @@ -117,7 +117,7 @@ depending on a replayed mutating call. - **CLI**: host agent can shell; prefer for long runs + `--follow`. - **TUI**: `devspace workflow tui` opens a read-only live view for workflows associated with the current working directory. -- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. Disconnecting MCP does **not** kill the worker. +- **MCP**: ChatGPT plans; call `run_workflow`, then `workflow_status` until terminal. With full widgets enabled, workflow tool cards and the `open_workspace` dashboard show read-only live activity, including workflows launched through the CLI. Disconnecting MCP does **not** kill the worker. ## Worked mini-examples