From 288a211daff2822e35ee068ce0959040aa7972f2 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:27:11 +0530 Subject: [PATCH 1/5] build(ui): add inspector query and routing dependencies --- package.json | 2 + pnpm-lock.yaml | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 110 insertions(+) diff --git a/package.json b/package.json index 202834dd..0bbe3b4b 100644 --- a/package.json +++ b/package.json @@ -59,6 +59,8 @@ "@modelcontextprotocol/server": "^2.0.0", "@opencode-ai/sdk": "^1.17.13", "@pierre/diffs": "^1.3.6", + "@tanstack/react-query": "^5.102.8", + "@tanstack/react-router": "^1.170.33", "better-result": "^2.10.0", "better-sqlite3": "^12.10.0", "cross-spawn": "^7.0.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 7045eecd..da6d7700 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -41,6 +41,12 @@ importers: '@pierre/diffs': specifier: ^1.3.6 version: 1.3.6(@shikijs/themes@3.23.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/react-query': + specifier: ^5.102.8 + version: 5.102.8(react@19.2.6) + '@tanstack/react-router': + specifier: ^1.170.33 + version: 1.170.33(react-dom@19.2.6(react@19.2.6))(react@19.2.6) better-result: specifier: ^2.10.0 version: 2.10.0 @@ -874,6 +880,38 @@ packages: '@standard-schema/spec@1.1.0': resolution: {integrity: sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==} + '@tanstack/history@1.162.2': + resolution: {integrity: sha512-Lemp3DJbzNqcin/nZpWxycDaEqySDbnIshDbyHJMMCapD4ZQMe57szRpBXOfzfP6fyWAtHNrLrcBUyANJ6Vlow==} + engines: {node: '>=20.19'} + + '@tanstack/query-core@5.102.8': + resolution: {integrity: sha512-ZNjkJ33CqvPNec/6lZBnHqLc3EVGPZ9ySLhYahU9TcuRFdmwXewuj0c4hwSWcGHqEUwcSrKeZ+oGcvPBqXcQcg==} + + '@tanstack/react-query@5.102.8': + resolution: {integrity: sha512-TYBea4OuXWD7MhaSHq069TWbFe7rcwWN6kzT7JF0OKi1K6c1gTv2IzD6A6ExJsCMozdkqBWeuIUZmu4KQg0O5A==} + peerDependencies: + react: ^18 || ^19 + + '@tanstack/react-router@1.170.33': + resolution: {integrity: sha512-iNnI98vH3kO/V4dy6YM0CInhqwWBddU0G5wZK5jiMvr3HsK2avDQSRx3RY/y6v+6zQAqb2kD6hUPHIenrJBTSw==} + engines: {node: '>=20.19'} + peerDependencies: + react: '>=18.0.0 || >=19.0.0' + react-dom: '>=18.0.0 || >=19.0.0' + + '@tanstack/react-store@0.9.3': + resolution: {integrity: sha512-y2iHd/N9OkoQbFJLUX1T9vbc2O9tjH0pQRgTcx1/Nz4IlwLvkgpuglXUx+mXt0g5ZDFrEeDnONPqkbfxXJKwRg==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + + '@tanstack/router-core@1.171.28': + resolution: {integrity: sha512-PvPWSklhw6i9b0rzScVh0btQsK5u/gBYN3mBHyDzhC/U4LrB3WzPXPkUunQUKvQOGXCp16UEb7Htc/ITGm5DkQ==} + engines: {node: '>=20.19'} + + '@tanstack/store@0.9.3': + resolution: {integrity: sha512-8reSzl/qGWGGVKhBoxXPMWzATSbZLZFWhwBAFO9NAyp0TxzfBP0mIrGb8CP8KrQTmvzXlR/vFPPUrHTLBGyFyw==} + '@tybys/wasm-util@0.10.2': resolution: {integrity: sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==} @@ -1055,6 +1093,9 @@ packages: resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} engines: {node: '>=18'} + cookie-es@3.1.1: + resolution: {integrity: sha512-UaXxwISYJPTr9hwQxMFYZ7kNhSXboMXP+Z3TRX6f1/NyaGPfuNUZOWP1pUEb75B2HjfklIYLVRfWiFZJyC6Npg==} + cookie-signature@1.2.2: resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} engines: {node: '>=6.6.0'} @@ -1458,6 +1499,10 @@ packages: is-promise@4.0.0: resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + isbot@5.2.2: + resolution: {integrity: sha512-iQcBXcd+Rv/pkubRyGh2utW2j1oPG5hZY6TUhVPpqK4G+o3IbxpJNx04hgksjc/N7GK5pEorUxDeg31cFgEk/w==} + engines: {node: '>=18'} + isexe@2.0.0: resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} @@ -1855,6 +1900,16 @@ packages: resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} engines: {node: '>= 18'} + seroval-plugins@1.6.7: + resolution: {integrity: sha512-4Nk35ttD3DTDJW4hgw5StsVAPeU6qnDFnULAouw6tQ7oLTV/ICXrWpsXo2EE52eSP2joUMazbVf52mFEcADqRw==} + engines: {node: '>=10'} + peerDependencies: + seroval: ^1.0 + + seroval@1.6.7: + resolution: {integrity: sha512-AeDcLh0yO2SFm9W71essgnSzLV9DI8ZH0x0knXn2DMnUZj728mpLbxjlbB6IqKCmqh8JA3cEqRyGoNkt584JcQ==} + engines: {node: '>=10'} + serve-static@2.2.1: resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} engines: {node: '>= 18'} @@ -1998,6 +2053,11 @@ packages: resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} engines: {node: '>= 0.8'} + use-sync-external-store@1.7.0: + resolution: {integrity: sha512-6L+EeigHMQhdaIPNIFUKwfWJSwWFQ8gJbJ2DLOs5sDIegTwR9fRxvnM3uciHKjIZhFz+KAv2emhWMRvDmMcY8A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 + util-deprecate@1.0.2: resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} @@ -2888,6 +2948,40 @@ snapshots: '@standard-schema/spec@1.1.0': {} + '@tanstack/history@1.162.2': {} + + '@tanstack/query-core@5.102.8': {} + + '@tanstack/react-query@5.102.8(react@19.2.6)': + dependencies: + '@tanstack/query-core': 5.102.8 + react: 19.2.6 + + '@tanstack/react-router@1.170.33(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/history': 1.162.2 + '@tanstack/react-store': 0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + '@tanstack/router-core': 1.171.28 + isbot: 5.2.2 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + + '@tanstack/react-store@0.9.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@tanstack/store': 0.9.3 + react: 19.2.6 + react-dom: 19.2.6(react@19.2.6) + use-sync-external-store: 1.7.0(react@19.2.6) + + '@tanstack/router-core@1.171.28': + dependencies: + '@tanstack/history': 1.162.2 + cookie-es: 3.1.1 + seroval: 1.6.7 + seroval-plugins: 1.6.7(seroval@1.6.7) + + '@tanstack/store@0.9.3': {} + '@tybys/wasm-util@0.10.2': dependencies: tslib: 2.8.1 @@ -3067,6 +3161,8 @@ snapshots: content-type@2.0.0: {} + cookie-es@3.1.1: {} + cookie-signature@1.2.2: {} cookie@0.7.2: {} @@ -3425,6 +3521,8 @@ snapshots: is-promise@4.0.0: {} + isbot@5.2.2: {} + isexe@2.0.0: {} jiti@2.7.0: {} @@ -3809,6 +3907,12 @@ snapshots: transitivePeerDependencies: - supports-color + seroval-plugins@1.6.7(seroval@1.6.7): + dependencies: + seroval: 1.6.7 + + seroval@1.6.7: {} + serve-static@2.2.1: dependencies: encodeurl: 2.0.0 @@ -3978,6 +4082,10 @@ snapshots: unpipe@1.0.0: {} + use-sync-external-store@1.7.0(react@19.2.6): + dependencies: + react: 19.2.6 + util-deprecate@1.0.2: {} vary@1.1.2: {} From 113b7501e81b29d2778aeb67549e674d951a41b2 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:29:08 +0530 Subject: [PATCH 2/5] feat(inspector): expose activity and diff data services --- src/review-checkpoints.ts | 4 +- src/server.ts | 247 ++++++++++++++++++++++++++++++ src/workspace-activity-service.ts | 50 ++++++ src/workspace-activity-store.ts | 27 ++++ src/workspace-diff.test.ts | 72 +++++++++ src/workspace-diff.ts | 121 +++++++++++++++ 6 files changed, 519 insertions(+), 2 deletions(-) create mode 100644 src/workspace-activity-service.ts create mode 100644 src/workspace-diff.test.ts create mode 100644 src/workspace-diff.ts diff --git a/src/review-checkpoints.ts b/src/review-checkpoints.ts index 6f687275..1e84f43a 100644 --- a/src/review-checkpoints.ts +++ b/src/review-checkpoints.ts @@ -280,7 +280,7 @@ function reviewRefs( }; } -async function createWorkingTreeSnapshot(gitRoot: string, parent: string): Promise { +export async function createWorkingTreeSnapshot(gitRoot: string, parent: string): Promise { const tempDir = await mkdtemp(join(tmpdir(), "devspace-review-index-")); const indexPath = join(tempDir, "index"); const env = checkpointEnv(indexPath); @@ -305,7 +305,7 @@ async function readReviewCommit(gitRoot: string, reviewRef: string): Promise, + activity: WorkspaceActivityService, +): void { + const appOnlyMeta = { + _meta: { + ui: { + visibility: ["app"] as const, + }, + }, + }; + + registerAppTool( + server, + "get_workspace_activity", + { + title: "Get workspace activity", + description: "Read recent persisted tool activity for a workspace.", + inputSchema: { + workspace_id: z.string(), + review_ref: z.string().optional(), + }, + outputSchema: { + groups: z.array(z.object({ + id: z.string(), + kind: z.enum(["review", "inferred"]), + started_at: z.string(), + completed_at: z.string().optional(), + review_ref: z.string().optional(), + calls: z.array(z.object({ + id: z.number().int(), + tool_name: z.string(), + started_at: z.string(), + completed_at: z.string().optional(), + duration_ms: z.number().int().optional(), + })), + })), + }, + ...appOnlyMeta, + annotations: { readOnlyHint: true }, + }, + async ({ workspace_id, review_ref }) => { + await workspaces.getWorkspace(workspace_id); + const groups = review_ref + ? [activity.findReviewGroup(workspace_id, review_ref)].filter(Boolean) + : activity.listActivity(workspace_id).groups; + const outputGroups = groups.map((group) => ({ + id: group!.id, + kind: group!.kind, + started_at: group!.startedAt, + completed_at: group!.completedAt, + review_ref: group!.reviewRef, + calls: group!.calls.map((call) => ({ + id: call.id, + tool_name: call.toolName, + started_at: call.startedAt, + completed_at: call.completedAt, + duration_ms: call.durationMs, + })), + })); + return { + content: [textBlock(`Loaded ${outputGroups.length} activity groups.`)], + structuredContent: { groups: outputGroups }, + }; + }, + ); + + registerAppTool( + server, + "get_workspace_tool_call", + { + title: "Get workspace tool call", + description: "Read the raw persisted input and result for one workspace tool call.", + inputSchema: { + workspace_id: z.string(), + call_id: z.number().int().positive(), + }, + outputSchema: { + call: z.object({ + id: z.number().int(), + tool_name: z.string(), + arguments: z.unknown(), + result: z.unknown().optional(), + error: z.unknown().optional(), + started_at: z.string(), + completed_at: z.string().optional(), + duration_ms: z.number().int().optional(), + review_ref: z.string().optional(), + }), + }, + ...appOnlyMeta, + annotations: { readOnlyHint: true }, + }, + async ({ workspace_id, call_id }) => { + await workspaces.getWorkspace(workspace_id); + const call = activity.getToolCall(workspace_id, call_id); + if (!call) throw new Error(`Unknown tool call ${call_id} for workspace ${workspace_id}.`); + return { + content: [textBlock(`Loaded ${call.toolName} tool call.`)], + structuredContent: { + call: { + id: call.id, + tool_name: call.toolName, + arguments: call.arguments, + result: call.result, + error: call.error, + started_at: call.startedAt, + completed_at: call.completedAt, + duration_ms: call.durationMs, + review_ref: call.reviewRef, + }, + }, + }; + }, + ); + + registerAppTool( + server, + "get_workspace_diff", + { + title: "Get workspace diff", + description: "Read a workspace diff for a review, working tree, branch, or exact ref comparison.", + inputSchema: { + workspace_id: z.string(), + scope: z.discriminatedUnion("kind", [ + z.object({ kind: z.literal("review"), review_ref: z.string() }), + z.object({ kind: z.literal("working-tree") }), + z.object({ kind: z.literal("branch"), base_ref: z.string().optional() }), + z.object({ kind: z.literal("compare"), from_ref: z.string(), to_ref: z.string() }), + ]), + }, + outputSchema: { + scope: z.unknown(), + summary: z.object({ + files: z.number().int(), + additions: z.number().int(), + removals: z.number().int(), + }), + files: z.array(z.unknown()), + patch: z.string(), + }, + ...appOnlyMeta, + annotations: { readOnlyHint: true }, + }, + async ({ workspace_id, scope }) => { + const workspace = await workspaces.getWorkspace(workspace_id); + const internalScope = workspaceDiffScopeFromInput(scope); + const diff = await readWorkspaceDiff(workspace, reviewCheckpoints, internalScope); + return { + content: [textBlock(`Loaded workspace diff for ${diff.scope.kind}.`)], + structuredContent: { + scope: workspaceDiffScopeToOutput(diff.scope), + summary: diff.summary, + files: diff.files, + patch: diff.patch, + }, + }; + }, + ); + + registerAppTool( + server, + "get_workspace_refs", + { + title: "Get workspace refs", + description: "List Git refs available for workspace comparisons.", + inputSchema: { workspace_id: z.string() }, + outputSchema: { + current_ref: z.string().optional(), + default_base_ref: z.string().optional(), + refs: z.array(z.string()), + }, + ...appOnlyMeta, + annotations: { readOnlyHint: true }, + }, + async ({ workspace_id }) => { + const workspace = await workspaces.getWorkspace(workspace_id); + const refs = await listWorkspaceRefs(workspace); + return { + content: [textBlock(`Loaded ${refs.refs.length} Git refs.`)], + structuredContent: { + current_ref: refs.currentRef, + default_base_ref: refs.defaultBaseRef, + refs: refs.refs, + }, + }; + }, + ); +} + +function workspaceDiffScopeFromInput(input: { + kind: "review" | "working-tree" | "branch" | "compare"; + review_ref?: string; + base_ref?: string; + from_ref?: string; + to_ref?: string; +}): WorkspaceDiffScope { + switch (input.kind) { + case "review": + if (!input.review_ref) throw new Error("review_ref is required for review diffs."); + return { kind: "review", reviewRef: input.review_ref }; + case "working-tree": + return { kind: "working-tree" }; + case "branch": + return { kind: "branch", ...(input.base_ref ? { baseRef: input.base_ref } : {}) }; + case "compare": + if (!input.from_ref || !input.to_ref) throw new Error("from_ref and to_ref are required."); + return { kind: "compare", fromRef: input.from_ref, toRef: input.to_ref }; + } +} + +function workspaceDiffScopeToOutput(scope: WorkspaceDiffScope): Record { + switch (scope.kind) { + case "review": + return { kind: scope.kind, review_ref: scope.reviewRef }; + case "working-tree": + return { kind: scope.kind }; + case "branch": + return { kind: scope.kind, base_ref: scope.baseRef }; + case "compare": + return { kind: scope.kind, from_ref: scope.fromRef, to_ref: scope.toRef }; + } } function withObservedToolHandlers( @@ -847,6 +1091,7 @@ export function createServer( error: error instanceof Error ? error.message : String(error), }); }); + const workspaceActivityService = new WorkspaceActivityService(config.stateDir); const localAgentProviders = buildLocalAgentProviderStatuses( config.subagents, getLocalAgentProviderAvailabilitySnapshot(process.env, config.subagents), @@ -867,6 +1112,7 @@ export function createServer( incomingArtifactAdapters, toolActivities.track, workspaceActivityJournal, + workspaceActivityService, ); }); const logMcpHandlerError = (error: Error) => logEvent( @@ -1004,6 +1250,7 @@ export function createServer( } await toolActivities.waitForIdle(); workspaceActivityJournal.close(); + workspaceActivityService.close(); processSessions.shutdown(); oauthProvider.close(); workspaceStore.close?.(); diff --git a/src/workspace-activity-service.ts b/src/workspace-activity-service.ts new file mode 100644 index 00000000..6e7b829f --- /dev/null +++ b/src/workspace-activity-service.ts @@ -0,0 +1,50 @@ +import { groupWorkspaceToolCalls, type WorkspaceActivityGroup } from "./workspace-activity.js"; +import { + WorkspaceActivityStore, + type WorkspaceToolCall, + type WorkspaceToolCallSummary, +} from "./workspace-activity-store.js"; + +const DEFAULT_ACTIVITY_CALL_LIMIT = 250; + +export interface WorkspaceActivitySnapshot { + groups: WorkspaceActivityGroup[]; +} + +export class WorkspaceActivityService { + private readonly store: WorkspaceActivityStore; + + constructor(stateDir: string) { + this.store = new WorkspaceActivityStore(stateDir); + } + + listActivity(workspaceId: string, limit = DEFAULT_ACTIVITY_CALL_LIMIT): WorkspaceActivitySnapshot { + const calls = this.store.listCallSummaries({ workspaceId, limit }); + return { groups: groupWorkspaceToolCalls(calls) }; + } + + getToolCall(workspaceId: string, callId: number): WorkspaceToolCall | undefined { + return this.store.getCall(workspaceId, callId); + } + + findReviewGroup(workspaceId: string, reviewRef: string): WorkspaceActivityGroup | undefined { + const boundary = this.store.findCallSummaryByReviewRef(workspaceId, reviewRef); + if (!boundary) return undefined; + + const preceding = this.store.listCallSummaries({ + workspaceId, + beforeId: boundary.id, + limit: DEFAULT_ACTIVITY_CALL_LIMIT, + }); + return groupWorkspaceToolCalls([boundary, ...preceding]) + .find((group) => group.reviewRef === reviewRef); + } + + close(): void { + this.store.close(); + } +} + +export function toolCallState(call: WorkspaceToolCallSummary): "running" | "completed" { + return call.completedAt ? "completed" : "running"; +} diff --git a/src/workspace-activity-store.ts b/src/workspace-activity-store.ts index 799d13af..dcca4e0c 100644 --- a/src/workspace-activity-store.ts +++ b/src/workspace-activity-store.ts @@ -138,6 +138,33 @@ export class WorkspaceActivityStore { return row ? rowToWorkspaceToolCall(row) : undefined; } + findCallSummaryByReviewRef( + workspaceId: string, + reviewRef: string, + ): WorkspaceToolCallSummary | undefined { + const row = this.database.db + .select({ + id: workspaceToolCalls.id, + workspaceSessionId: workspaceToolCalls.workspaceSessionId, + conversationScopeId: workspaceToolCalls.conversationScopeId, + requestId: workspaceToolCalls.requestId, + toolName: workspaceToolCalls.toolName, + startedAt: workspaceToolCalls.startedAt, + completedAt: workspaceToolCalls.completedAt, + durationMs: workspaceToolCalls.durationMs, + reviewRef: workspaceToolCalls.reviewRef, + }) + .from(workspaceToolCalls) + .where( + and( + eq(workspaceToolCalls.workspaceSessionId, workspaceId), + eq(workspaceToolCalls.reviewRef, reviewRef), + ), + ) + .get(); + return row ? rowToWorkspaceToolCallSummary(row) : undefined; + } + close(): void { this.database.close(); } diff --git a/src/workspace-diff.test.ts b/src/workspace-diff.test.ts new file mode 100644 index 00000000..99f5eb67 --- /dev/null +++ b/src/workspace-diff.test.ts @@ -0,0 +1,72 @@ +import assert from "node:assert/strict"; +import { execFile } from "node:child_process"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import test from "node:test"; +import { promisify } from "node:util"; +import { createReviewCheckpointManager } from "./review-checkpoints.js"; +import { listWorkspaceRefs, readWorkspaceDiff } from "./workspace-diff.js"; +import type { Workspace } from "./workspaces.js"; + +const execFileAsync = promisify(execFile); + +test("workspace diff supports review, working tree, branch, and exact ref comparisons", async (t) => { + const root = await mkdtemp(join(tmpdir(), "devspace-workspace-diff-test-")); + t.after(() => rm(root, { recursive: true, force: true })); + await git(root, ["init", "-b", "main"]); + await git(root, ["config", "user.email", "devspace@example.com"]); + await git(root, ["config", "user.name", "DevSpace Test"]); + await writeFile(join(root, "README.md"), "base\n"); + await git(root, ["add", "."]); + await git(root, ["commit", "-m", "base"]); + + const workspace = testWorkspace(root); + const checkpoints = createReviewCheckpointManager(); + await checkpoints.initializeWorkspace({ workspaceId: workspace.id, root }); + + await git(root, ["switch", "-c", "feature"]); + await writeFile(join(root, "README.md"), "feature\n"); + await git(root, ["commit", "-am", "feature"]); + const branch = await readWorkspaceDiff(workspace, checkpoints, { kind: "branch", baseRef: "main" }); + assert.match(branch.patch, /-base\n\+feature/); + + await writeFile(join(root, "new.txt"), "untracked\n"); + const workingTree = await readWorkspaceDiff(workspace, checkpoints, { kind: "working-tree" }); + assert.match(workingTree.patch, /new\.txt/); + + const compare = await readWorkspaceDiff(workspace, checkpoints, { + kind: "compare", + fromRef: "main", + toRef: "feature", + }); + assert.match(compare.patch, /-base\n\+feature/); + + await writeFile(join(root, "README.md"), "reviewed\n"); + const review = await checkpoints.reviewChanges({ workspaceId: workspace.id, root }); + const historical = await readWorkspaceDiff(workspace, checkpoints, { + kind: "review", + reviewRef: review.reviewRef, + }); + assert.equal(historical.patch, review.patch); + + const refs = await listWorkspaceRefs(workspace); + assert.equal(refs.currentRef, "feature"); + assert.ok(refs.refs.includes("main")); + assert.ok(refs.refs.includes("feature")); +}); + +function testWorkspace(root: string): Workspace { + return { + id: "ws_diff", + root, + mode: "checkout", + skills: [], + skillDiagnostics: [], + agentProfiles: [], + }; +} + +async function git(cwd: string, args: string[]): Promise { + await execFileAsync("git", args, { cwd }); +} diff --git a/src/workspace-diff.ts b/src/workspace-diff.ts new file mode 100644 index 00000000..d8d57816 --- /dev/null +++ b/src/workspace-diff.ts @@ -0,0 +1,121 @@ +import { git, getGitEligibility } from "./git.js"; +import { + createWorkingTreeSnapshot, + readReviewBetween, + type ReviewCheckpointManager, + type ReviewFile, + type ReviewSummary, +} from "./review-checkpoints.js"; +import type { Workspace } from "./workspaces.js"; + +export type WorkspaceDiffScope = + | { kind: "review"; reviewRef: string } + | { kind: "working-tree" } + | { kind: "branch"; baseRef?: string } + | { kind: "compare"; fromRef: string; toRef: string }; + +export interface WorkspaceDiffResult { + scope: WorkspaceDiffScope; + summary: ReviewSummary; + files: ReviewFile[]; + patch: string; +} + +export interface WorkspaceRefList { + currentRef?: string; + defaultBaseRef?: string; + refs: string[]; +} + +export async function readWorkspaceDiff( + workspace: Workspace, + reviewCheckpoints: ReviewCheckpointManager, + scope: WorkspaceDiffScope, +): Promise { + if (scope.kind === "review") { + const review = await reviewCheckpoints.reviewByRef({ + workspaceId: workspace.id, + root: workspace.root, + reviewRef: scope.reviewRef, + }); + return { scope, summary: review.summary, files: review.files, patch: review.patch }; + } + + const gitRoot = await requireGitRoot(workspace.root); + if (scope.kind === "working-tree") { + const head = await resolveCommit(gitRoot, "HEAD"); + const snapshot = await createWorkingTreeSnapshot(gitRoot, head); + const diff = await readReviewBetween(gitRoot, head, snapshot); + return { scope, ...diff }; + } + + if (scope.kind === "branch") { + const baseRef = scope.baseRef ?? workspace.worktree?.baseRef ?? await defaultBaseRef(gitRoot); + if (!baseRef) { + throw new Error("No default branch comparison target is available; choose a base ref."); + } + const [base, head] = await Promise.all([ + resolveCommit(gitRoot, baseRef), + resolveCommit(gitRoot, "HEAD"), + ]); + const mergeBase = (await git(gitRoot, ["merge-base", base, head])).stdout.trim(); + const diff = await readReviewBetween(gitRoot, mergeBase, head); + return { scope: { kind: "branch", baseRef }, ...diff }; + } + + const [from, to] = await Promise.all([ + resolveCommit(gitRoot, scope.fromRef), + resolveCommit(gitRoot, scope.toRef), + ]); + const diff = await readReviewBetween(gitRoot, from, to); + return { scope, ...diff }; +} + +export async function listWorkspaceRefs(workspace: Workspace): Promise { + const gitRoot = await requireGitRoot(workspace.root); + const [refsResult, currentRef, inferredDefault] = await Promise.all([ + git(gitRoot, [ + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + "refs/remotes", + ]), + symbolicRef(gitRoot, "HEAD"), + defaultBaseRef(gitRoot), + ]); + const refs = refsResult.stdout + .split("\n") + .map((value) => value.trim()) + .filter((value) => value.length > 0 && !value.endsWith("/HEAD")); + return { + ...(currentRef ? { currentRef } : {}), + ...(workspace.worktree?.baseRef ?? inferredDefault + ? { defaultBaseRef: workspace.worktree?.baseRef ?? inferredDefault } + : {}), + refs: [...new Set(refs)].sort((left, right) => left.localeCompare(right)), + }; +} + +async function requireGitRoot(root: string): Promise { + const eligibility = await getGitEligibility(root); + if (!eligibility.ok || !eligibility.gitRoot) { + throw new Error(eligibility.message ?? "Workspace is not a Git repository."); + } + return eligibility.gitRoot; +} + +async function resolveCommit(gitRoot: string, ref: string): Promise { + return (await git(gitRoot, ["rev-parse", "--verify", `${ref}^{commit}`])).stdout.trim(); +} + +async function symbolicRef(gitRoot: string, ref: string): Promise { + try { + return (await git(gitRoot, ["symbolic-ref", "--quiet", "--short", ref])).stdout.trim() || undefined; + } catch { + return undefined; + } +} + +async function defaultBaseRef(gitRoot: string): Promise { + return symbolicRef(gitRoot, "refs/remotes/origin/HEAD"); +} From 3a00ecfba22b3baddd640757e13cfa000e997ea0 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:35:06 +0530 Subject: [PATCH 3/5] test(inspector): keep app queries out of activity history --- src/server.test.ts | 34 ++++++++++++++++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/src/server.test.ts b/src/server.test.ts index cc1b736a..467b85ad 100644 --- a/src/server.test.ts +++ b/src/server.test.ts @@ -20,6 +20,7 @@ import { WorkspaceRegistry } from "./workspaces.js"; import { writeTestDevspaceConfig } from "./test-support/config.test.js"; import { groupWorkspaceToolCalls } from "./workspace-activity.js"; import { WorkspaceActivityJournal } from "./workspace-activity-journal.js"; +import { WorkspaceActivityService } from "./workspace-activity-service.js"; import { WorkspaceActivityStore } from "./workspace-activity-store.js"; const execFileAsync = promisify(execFile); @@ -291,6 +292,34 @@ test("workspace activity groups a real tool sequence under its review ref", asyn ); }); +test("workspace inspector tools are app-only and do not journal their own reads", async (t) => { + const context = await fixture(t, { git: true, captureActivity: true, uiEnabled: true }); + assert.ok(context.activity); + const workspaceId = structuredContent( + await callOpen(context.client, context.project, "inspector-tools"), + ).workspace_id; + assert.ok(typeof workspaceId === "string"); + + await context.client.callTool({ + name: "read", + arguments: { workspace_id: workspaceId, path: "README.md" }, + }); + const before = context.activity.listCalls({ workspaceId, limit: 20 }).length; + const activity = structuredContent(await context.client.callTool({ + name: "get_workspace_activity", + arguments: { workspace_id: workspaceId }, + })); + assert.ok(Array.isArray(activity.groups)); + assert.equal(context.activity.listCalls({ workspaceId, limit: 20 }).length, before); + + const tools = await context.client.listTools(); + const inspectorTool = tools.tools.find((tool) => tool.name === "get_workspace_activity"); + assert.deepEqual( + (inspectorTool?._meta as { ui?: { visibility?: string[] } } | undefined)?.ui?.visibility, + ["app"], + ); +}); + test("open_workspace keeps lifecycle flags out of model output and preserves complete card metadata", async (t) => { const providerNote = "available"; const context = await fixture(t, { @@ -775,6 +804,9 @@ async function fixture( const activityJournal = options.captureActivity ? new WorkspaceActivityJournal(stateDir) : undefined; + const activityService = options.captureActivity + ? new WorkspaceActivityService(stateDir) + : undefined; const activity = options.captureActivity ? new WorkspaceActivityStore(stateDir) : undefined; @@ -788,6 +820,7 @@ async function fixture( [], undefined, activityJournal, + activityService, ); const [clientTransport, serverTransport] = InMemoryTransport.createLinkedPair(); const client = new Client({ name: "devspace-test-client", version: "1.0.0" }); @@ -803,6 +836,7 @@ async function fixture( await client.close(); await server.close(); activity?.close(); + activityService?.close(); activityJournal?.close(); store.close(); }; From d6855f39661cb765c3746f6c384bc7f8e51dcc9a Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:35:06 +0530 Subject: [PATCH 4/5] feat(ui): add workspace inspector views --- src/ui/inspector/queries.ts | 49 +++ src/ui/inspector/transport.ts | 130 +++++++ src/ui/inspector/workspace-inspector.tsx | 444 +++++++++++++++++++++++ src/ui/review-payload.tsx | 2 +- 4 files changed, 624 insertions(+), 1 deletion(-) create mode 100644 src/ui/inspector/queries.ts create mode 100644 src/ui/inspector/transport.ts create mode 100644 src/ui/inspector/workspace-inspector.tsx diff --git a/src/ui/inspector/queries.ts b/src/ui/inspector/queries.ts new file mode 100644 index 00000000..c36efab9 --- /dev/null +++ b/src/ui/inspector/queries.ts @@ -0,0 +1,49 @@ +import { queryOptions } from "@tanstack/react-query"; +import type { WorkspaceDiffScopeInput, WorkspaceInspectorTransport } from "./transport.js"; + +export function workspaceActivityQuery( + transport: WorkspaceInspectorTransport, + workspaceId: string, + reviewRef?: string, +) { + return queryOptions({ + queryKey: ["workspace", workspaceId, "activity", reviewRef ?? "all"] as const, + queryFn: () => transport.getActivity(workspaceId, reviewRef), + staleTime: 5_000, + }); +} + +export function workspaceToolCallQuery( + transport: WorkspaceInspectorTransport, + workspaceId: string, + callId: number, +) { + return queryOptions({ + queryKey: ["workspace", workspaceId, "tool-call", callId] as const, + queryFn: () => transport.getToolCall(workspaceId, callId), + staleTime: Infinity, + }); +} + +export function workspaceDiffQuery( + transport: WorkspaceInspectorTransport, + workspaceId: string, + scope: WorkspaceDiffScopeInput, +) { + return queryOptions({ + queryKey: ["workspace", workspaceId, "diff", scope] as const, + queryFn: () => transport.getDiff(workspaceId, scope), + staleTime: scope.kind === "review" || scope.kind === "compare" ? Infinity : 0, + }); +} + +export function workspaceRefsQuery( + transport: WorkspaceInspectorTransport, + workspaceId: string, +) { + return queryOptions({ + queryKey: ["workspace", workspaceId, "refs"] as const, + queryFn: () => transport.getRefs(workspaceId), + staleTime: 5_000, + }); +} diff --git a/src/ui/inspector/transport.ts b/src/ui/inspector/transport.ts new file mode 100644 index 00000000..cd2a9296 --- /dev/null +++ b/src/ui/inspector/transport.ts @@ -0,0 +1,130 @@ +import type { App } from "@modelcontextprotocol/ext-apps"; +import * as z from "zod/v4"; + +const activityCallSchema = z.object({ + id: z.number().int(), + tool_name: z.string(), + started_at: z.string(), + completed_at: z.string().optional(), + duration_ms: z.number().int().optional(), +}); + +const activityGroupSchema = z.object({ + id: z.string(), + kind: z.enum(["review", "inferred"]), + started_at: z.string(), + completed_at: z.string().optional(), + review_ref: z.string().optional(), + calls: z.array(activityCallSchema), +}); + +const activitySchema = z.object({ + groups: z.array(activityGroupSchema), +}); + +const toolCallSchema = z.object({ + call: z.object({ + id: z.number().int(), + tool_name: z.string(), + arguments: z.unknown(), + result: z.unknown().optional(), + error: z.unknown().optional(), + started_at: z.string(), + completed_at: z.string().optional(), + duration_ms: z.number().int().optional(), + review_ref: z.string().optional(), + }), +}); + +const diffSchema = z.object({ + scope: z.unknown(), + summary: z.object({ + files: z.number().int(), + additions: z.number().int(), + removals: z.number().int(), + }), + files: z.array(z.object({ + path: z.string(), + previousPath: z.string().optional(), + type: z.enum(["change", "rename-pure", "rename-changed", "new", "deleted"]), + additions: z.number().int(), + removals: z.number().int(), + })), + patch: z.string(), +}); + +const refsSchema = z.object({ + current_ref: z.string().optional(), + default_base_ref: z.string().optional(), + refs: z.array(z.string()), +}); + +export type WorkspaceActivityData = z.infer; +export type WorkspaceToolCallData = z.infer["call"]; +export type WorkspaceDiffData = z.infer; +export type WorkspaceRefsData = z.infer; + +export type WorkspaceDiffScopeInput = + | { kind: "review"; review_ref: string } + | { kind: "working-tree" } + | { kind: "branch"; base_ref?: string } + | { kind: "compare"; from_ref: string; to_ref: string }; + +export interface WorkspaceInspectorTransport { + getActivity(workspaceId: string, reviewRef?: string): Promise; + getToolCall(workspaceId: string, callId: number): Promise; + getDiff(workspaceId: string, scope: WorkspaceDiffScopeInput): Promise; + getRefs(workspaceId: string): Promise; +} + +export function createMcpInspectorTransport(app: App): WorkspaceInspectorTransport { + return { + async getActivity(workspaceId, reviewRef) { + return parseStructured( + await app.callServerTool({ + name: "get_workspace_activity", + arguments: { + workspace_id: workspaceId, + ...(reviewRef ? { review_ref: reviewRef } : {}), + }, + }), + activitySchema, + ); + }, + async getToolCall(workspaceId, callId) { + return parseStructured( + await app.callServerTool({ + name: "get_workspace_tool_call", + arguments: { workspace_id: workspaceId, call_id: callId }, + }), + toolCallSchema, + ).call; + }, + async getDiff(workspaceId, scope) { + return parseStructured( + await app.callServerTool({ + name: "get_workspace_diff", + arguments: { workspace_id: workspaceId, scope }, + }), + diffSchema, + ); + }, + async getRefs(workspaceId) { + return parseStructured( + await app.callServerTool({ + name: "get_workspace_refs", + arguments: { workspace_id: workspaceId }, + }), + refsSchema, + ); + }, + }; +} + +function parseStructured( + result: { structuredContent?: unknown; isError?: boolean }, + schema: z.ZodType, +): T { + if (result.isError) throw new Error("Workspace inspector request failed."); + return schema.parse(result.structuredContent); +} diff --git a/src/ui/inspector/workspace-inspector.tsx b/src/ui/inspector/workspace-inspector.tsx new file mode 100644 index 00000000..669fb146 --- /dev/null +++ b/src/ui/inspector/workspace-inspector.tsx @@ -0,0 +1,444 @@ +import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query"; +import { + Link, + Outlet, + RouterProvider, + createMemoryHistory, + createRootRoute, + createRoute, + createRouter, + useNavigate, +} from "@tanstack/react-router"; +import { createContext, useContext, useMemo, useState } from "react"; +import { createRoot } from "react-dom/client"; +import type { HostContext, ToolResultCard } from "../card-types.js"; +import { ReviewPayload } from "../review-payload.js"; +import { + workspaceActivityQuery, + workspaceDiffQuery, + workspaceRefsQuery, + workspaceToolCallQuery, +} from "./queries.js"; +import type { + WorkspaceDiffScopeInput, + WorkspaceInspectorTransport, +} from "./transport.js"; +import { createMcpInspectorTransport } from "./transport.js"; +import type { App } from "@modelcontextprotocol/ext-apps"; + +interface InspectorOptions { + workspaceId: string; + root: string; + mode?: "checkout" | "worktree"; + hostContext?: HostContext; + transport: WorkspaceInspectorTransport; + initialReviewRef?: string; + onExitFullscreen?: () => void; +} + +interface MountedInspector { + unmount(): void; +} + +const InspectorContext = createContext(null); + +const rootRoute = createRootRoute({ component: InspectorLayout }); +const activityRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/activity", + validateSearch: (search: Record) => ({ + ...(typeof search.review === "string" ? { review: search.review } : {}), + ...(typeof search.group === "string" ? { group: search.group } : {}), + }), + component: ActivityView, +}); +const changesRoute = createRoute({ + getParentRoute: () => rootRoute, + path: "/changes", + validateSearch: (search: Record) => ({ + ...(search.scope === "review" + || search.scope === "working-tree" + || search.scope === "branch" + || search.scope === "compare" + ? { scope: search.scope } + : {}), + ...(typeof search.review === "string" ? { review: search.review } : {}), + ...(typeof search.base === "string" ? { base: search.base } : {}), + ...(typeof search.from === "string" ? { from: search.from } : {}), + ...(typeof search.to === "string" ? { to: search.to } : {}), + }), + component: ChangesView, +}); +const routeTree = rootRoute.addChildren([activityRoute, changesRoute]); + +export function mountWorkspaceInspector( + container: HTMLElement, + options: InspectorOptions, +): MountedInspector { + const root = createRoot(container); + const queryClient = new QueryClient({ + defaultOptions: { + queries: { retry: 1, refetchOnWindowFocus: false }, + }, + }); + const initialPath = options.initialReviewRef + ? `/activity?review=${encodeURIComponent(options.initialReviewRef)}` + : "/activity"; + const router = createRouter({ + routeTree, + history: createMemoryHistory({ initialEntries: [initialPath] }), + }); + + root.render( + + + + + , + ); + + return { unmount: () => root.unmount() }; +} + +export function mountMcpWorkspaceInspector( + container: HTMLElement, + options: Omit & { app: App }, +): MountedInspector { + const { app, ...inspectorOptions } = options; + return mountWorkspaceInspector(container, { + ...inspectorOptions, + transport: createMcpInspectorTransport(app), + }); +} + +function InspectorLayout() { + const inspector = useInspector(); + const title = workspaceTitle(inspector.root); + return ( +
+
+
+
+ {title} + {inspector.mode ? {inspector.mode} : null} +
+ {inspector.root} +
+ {inspector.onExitFullscreen ? ( + + ) : null} +
+ +
+ +
+
+ ); +} + +function ActivityView() { + const inspector = useInspector(); + const search = activityRoute.useSearch(); + const navigate = useNavigate({ from: "/activity" }); + const activity = useQuery(workspaceActivityQuery(inspector.transport, inspector.workspaceId)); + const groups = activity.data?.groups ?? []; + const selected = groups.find((group) => + search.review ? group.review_ref === search.review : search.group ? group.id === search.group : false, + ) ?? groups[0]; + + if (activity.isPending) return Loading workspace activity…; + if (activity.error) return {activity.error.message}; + if (!selected) return No persisted tool activity yet.; + + return ( +
+
+ + {selected.calls.length} calls + {selected.review_ref ? ( + + ) : null} +
+
+ {selected.calls.map((call) => ( + + ))} +
+
+ ); + + function navigateToReviewChanges(reviewRef: string) { + return navigate({ + to: "/changes", + search: { scope: "review", review: reviewRef }, + }); + } +} + +function ToolCallRow({ + call, +}: { + call: { + id: number; + tool_name: string; + started_at: string; + completed_at?: string; + duration_ms?: number; + }; +}) { + const inspector = useInspector(); + const [expanded, setExpanded] = useState(false); + const detail = useQuery({ + ...workspaceToolCallQuery(inspector.transport, inspector.workspaceId, call.id), + enabled: expanded, + }); + return ( +
+ + {expanded ? ( +
+ {detail.isPending ? Loading raw call… : null} + {detail.error ? {detail.error.message} : null} + {detail.data ? ( + <> + + {detail.data.error !== undefined + ? + : } + + ) : null} +
+ ) : null} +
+ ); +} + +function ChangesView() { + const inspector = useInspector(); + const search = changesRoute.useSearch(); + const navigate = useNavigate({ from: "/changes" }); + const activity = useQuery(workspaceActivityQuery(inspector.transport, inspector.workspaceId)); + const refs = useQuery(workspaceRefsQuery(inspector.transport, inspector.workspaceId)); + const reviewedGroups = (activity.data?.groups ?? []).filter((group) => group.review_ref); + const scope = useMemo(() => { + if (search.scope === "review" && search.review) { + return { kind: "review", review_ref: search.review }; + } + if (search.scope === "branch") { + return { kind: "branch", ...(search.base ? { base_ref: search.base } : {}) }; + } + if (search.scope === "compare" && search.from && search.to) { + return { kind: "compare", from_ref: search.from, to_ref: search.to }; + } + if (search.scope === "working-tree") return { kind: "working-tree" }; + const latestReview = reviewedGroups[0]?.review_ref; + return latestReview ? { kind: "review", review_ref: latestReview } : { kind: "working-tree" }; + }, [reviewedGroups, search.base, search.from, search.review, search.scope, search.to]); + const diff = useQuery(workspaceDiffQuery(inspector.transport, inspector.workspaceId, scope)); + const scopeValue = scope.kind === "review" + ? `review:${scope.review_ref}` + : scope.kind; + + return ( +
+
+ + {scope.kind === "branch" ? ( + void navigate({ to: "/changes", search: { scope: "branch", base } })} + /> + ) : null} + {scope.kind === "compare" ? ( +
+ void navigate({ to: "/changes", search: { scope: "compare", from, to: scope.to_ref } })} + /> + + void navigate({ to: "/changes", search: { scope: "compare", from: scope.from_ref, to } })} + /> +
+ ) : null} + {diff.data ? ( + + +{diff.data.summary.additions}{" "} + -{diff.data.summary.removals} + + ) : null} + {scope.kind === "review" ? ( + + ) : null} +
+ {diff.isPending ? Loading diff… : null} + {diff.error ? {diff.error.message} : null} + {diff.data ? ( +
+ +
+ ) : null} +
+ ); +} + +function RefSelect(props: { + label: string; + refs: string[]; + value: string; + onChange(value: string): void; +}) { + return ( + + ); +} + +function RawJsonBlock({ title, value }: { title: string; value: unknown }) { + return ( +
+
{title}
+
{value === undefined ? "—" : JSON.stringify(value, null, 2)}
+
+ ); +} + +function InspectorStatus({ + children, + tone = "muted", +}: { + children: React.ReactNode; + tone?: "muted" | "error"; +}) { + return
{children}
; +} + +function useInspector(): InspectorOptions { + const value = useContext(InspectorContext); + if (!value) throw new Error("Workspace inspector context is missing."); + return value; +} + +function activityGroupLabel(group: { kind: "review" | "inferred"; started_at: string }): string { + const date = new Date(group.started_at); + const time = Number.isNaN(date.getTime()) + ? group.started_at + : date.toLocaleTimeString([], { hour: "numeric", minute: "2-digit" }); + return group.kind === "review" ? `Reviewed activity · ${time}` : `Activity · ${time}`; +} + +function toolLabel(toolName: string): string { + switch (toolName) { + case "read": return "Read file"; + case "exec_command": + case "bash": return "Run command"; + case "apply_patch": + case "edit": + case "write": return "Modify files"; + case "show_changes": return "Capture review"; + case "open_workspace": return "Open workspace"; + default: return "Tool call"; + } +} + +function formatDuration(durationMs: number | undefined): string { + if (durationMs === undefined) return "running"; + if (durationMs < 1_000) return `${durationMs} ms`; + return `${(durationMs / 1_000).toFixed(durationMs < 10_000 ? 1 : 0)} s`; +} + +function workspaceTitle(root: string): string { + const parts = root.replace(/[\\/]+$/, "").split(/[\\/]/); + return parts.at(-1) || root; +} diff --git a/src/ui/review-payload.tsx b/src/ui/review-payload.tsx index 455e5472..e738fd4f 100644 --- a/src/ui/review-payload.tsx +++ b/src/ui/review-payload.tsx @@ -42,7 +42,7 @@ export function mountReviewPayload( }; } -function ReviewPayload({ +export function ReviewPayload({ card, hostContext, errorMessage = null, From 2ae6d91f842bfcbe6fd2285c61958a1d39420790 Mon Sep 17 00:00:00 2001 From: Waishnav <86405648+Waishnav@users.noreply.github.com> Date: Fri, 11 Sep 2026 02:35:07 +0530 Subject: [PATCH 5/5] feat(ui): open workspace inspector in fullscreen --- src/ui/icons.ts | 2 + src/ui/workspace-app.css | 376 +++++++++++++++++++++++++++++++++++++++ src/ui/workspace-app.tsx | 103 ++++++++++- 3 files changed, 478 insertions(+), 3 deletions(-) diff --git a/src/ui/icons.ts b/src/ui/icons.ts index 5d489e35..64aa4952 100644 --- a/src/ui/icons.ts +++ b/src/ui/icons.ts @@ -11,6 +11,7 @@ import { FolderOpen, GitBranch, GitCommitHorizontal, + Maximize2, createElement, type IconNode, } from "lucide"; @@ -28,6 +29,7 @@ export const toolIcons = { providers: Cpu, skills: Blocks, sourceCheckout: FolderGit2, + fullscreen: Maximize2, warning: CircleAlert, } as const satisfies Record; diff --git a/src/ui/workspace-app.css b/src/ui/workspace-app.css index 64c46930..285b3470 100644 --- a/src/ui/workspace-app.css +++ b/src/ui/workspace-app.css @@ -105,6 +105,53 @@ body { text-align: left; } +.tool-header-shell { + display: flex; + align-items: center; + min-width: 0; +} + +.tool-header-primary { + min-width: 0; + flex: 1; +} + +.workspace-fullscreen-action { + display: grid; + width: 30px; + height: 30px; + margin-right: 8px; + flex: 0 0 auto; + place-items: center; + border: 0; + border-radius: 8px; + background: transparent; + color: var(--color-text-tertiary, #a3a3aa); + cursor: pointer; + opacity: 0; + transition: background 140ms ease, color 140ms ease, opacity 140ms ease; +} + +.tool-header-shell:hover .workspace-fullscreen-action, +.workspace-fullscreen-action:focus-visible { + opacity: 1; +} + +.workspace-fullscreen-action:hover { + background: var(--tool-card-hover-bg); + color: var(--color-text-primary, #f5f5f6); +} + +.workspace-fullscreen-action:focus-visible { + outline: 2px solid color-mix(in srgb, var(--tool-accent) 72%, transparent); + outline-offset: -2px; +} + +.workspace-fullscreen-action .icon-svg { + width: 16px; + height: 16px; +} + .tool-header:focus-visible, .review-diff-file-header:focus-visible, .review-more:focus-visible { @@ -228,6 +275,335 @@ body { overflow: auto; } +.workspace-inspector-host { + width: 100%; + height: 100vh; + min-height: 0; + background: var(--color-background-primary, #181818); +} + +.workspace-inspector { + display: grid; + grid-template-rows: auto auto minmax(0, 1fr); + width: 100%; + height: 100%; + min-height: 0; + color: var(--color-text-primary, #f5f5f6); + background: var(--color-background-primary, #181818); +} + +.inspector-header { + display: flex; + min-width: 0; + align-items: center; + justify-content: space-between; + gap: 20px; + padding: 16px 20px 12px; +} + +.inspector-heading { + display: grid; + min-width: 0; + gap: 3px; +} + +.inspector-title-row { + display: flex; + min-width: 0; + align-items: center; + gap: 8px; + font-size: var(--font-text-lg-size, 16px); +} + +.inspector-badge { + padding: 2px 6px; + border: 1px solid var(--tool-card-border); + border-radius: 6px; + color: var(--color-text-secondary, #b6b6bd); + font-size: var(--font-text-xs-size, 11px); + font-weight: 500; +} + +.inspector-root { + overflow: hidden; + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-xs-size, 12px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.inspector-exit, +.inspector-link-button { + border: 0; + border-radius: 7px; + background: transparent; + color: var(--color-text-secondary, #b6b6bd); + cursor: pointer; + font: inherit; +} + +.inspector-exit { + padding: 6px 8px; + font-size: var(--font-text-xs-size, 12px); +} + +.inspector-exit:hover, +.inspector-link-button:hover { + background: var(--tool-card-hover-bg); + color: var(--color-text-primary, #f5f5f6); +} + +.inspector-tabs { + display: flex; + gap: 18px; + padding: 0 20px; + border-bottom: 1px solid var(--tool-card-divider); +} + +.inspector-tabs a { + position: relative; + padding: 8px 1px 10px; + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 13px); + font-weight: 520; + text-decoration: none; +} + +.inspector-tabs a.active { + color: var(--color-text-primary, #f5f5f6); +} + +.inspector-tabs a.active::after { + position: absolute; + right: 0; + bottom: -1px; + left: 0; + height: 2px; + border-radius: 2px 2px 0 0; + background: var(--tool-accent); + content: ""; +} + +.inspector-content, +.inspector-view { + min-width: 0; + min-height: 0; +} + +.inspector-content { + overflow: hidden; +} + +.inspector-view { + display: grid; + grid-template-rows: auto minmax(0, 1fr); + height: 100%; +} + +.inspector-toolbar { + display: flex; + min-width: 0; + align-items: center; + gap: 9px; + padding: 10px 20px; + border-bottom: 1px solid var(--tool-card-divider); +} + +.inspector-select, +.inspector-ref-select { + min-width: 0; + height: 30px; + border: 1px solid var(--tool-card-border); + border-radius: 7px; + outline: 0; + background: var(--color-background-secondary, #272727); + color: var(--color-text-primary, #f5f5f6); + font: inherit; + font-size: var(--font-text-xs-size, 12px); +} + +.inspector-select { + max-width: 260px; + padding: 0 28px 0 9px; +} + +.inspector-ref-select { + max-width: 220px; + padding: 0 24px 0 8px; + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); +} + +.inspector-select:focus-visible, +.inspector-ref-select:focus-visible { + border-color: color-mix(in srgb, var(--tool-accent) 65%, var(--tool-card-border)); +} + +.inspector-toolbar-meta, +.inspector-diff-stat { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-xs-size, 12px); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.inspector-link-button { + margin-left: auto; + padding: 5px 7px; + font-size: var(--font-text-xs-size, 12px); +} + +.compare-refs { + display: flex; + min-width: 0; + align-items: center; + gap: 7px; + color: var(--color-text-tertiary, #a3a3aa); +} + +.activity-list, +.inspector-diff { + min-height: 0; + overflow: auto; +} + +.activity-list { + padding: 8px 14px 24px; +} + +.activity-row { + border-bottom: 1px solid color-mix(in srgb, var(--tool-card-divider) 60%, transparent); +} + +.activity-row-header { + display: grid; + grid-template-columns: minmax(110px, 0.18fr) minmax(0, 1fr) auto 18px; + width: 100%; + align-items: center; + gap: 12px; + padding: 10px 8px; + border: 0; + border-radius: 7px; + background: transparent; + color: inherit; + cursor: pointer; + text-align: left; +} + +.activity-row-header:hover { + background: var(--tool-card-hover-bg); +} + +.activity-tool { + color: var(--color-text-secondary, #b6b6bd); + font-size: var(--font-text-sm-size, 13px); + font-weight: 520; +} + +.activity-tool-name { + overflow: hidden; + color: var(--color-text-tertiary, #a3a3aa); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-xs-size, 12px); + text-overflow: ellipsis; + white-space: nowrap; +} + +.activity-duration, +.activity-disclosure { + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-xs-size, 12px); + font-variant-numeric: tabular-nums; + white-space: nowrap; +} + +.activity-disclosure { + text-align: center; +} + +.activity-detail { + display: grid; + gap: 12px; + padding: 2px 8px 14px calc(18% + 20px); +} + +.raw-json-block { + min-width: 0; +} + +.raw-json-title { + margin-bottom: 5px; + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-xs-size, 11px); + font-weight: 600; + letter-spacing: 0.04em; + text-transform: uppercase; +} + +.raw-json-block pre { + max-height: 320px; + margin: 0; + overflow: auto; + padding: 10px 11px; + border: 1px solid var(--tool-card-divider); + border-radius: 8px; + background: var(--tool-card-body-bg); + color: var(--color-text-secondary, #b6b6bd); + font-family: var(--font-mono, ui-monospace, SFMono-Regular, monospace); + font-size: var(--font-text-xs-size, 11px); + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.inspector-status { + padding: 18px 20px; + color: var(--color-text-tertiary, #a3a3aa); + font-size: var(--font-text-sm-size, 13px); +} + +.inspector-status.error { + color: var(--color-danger-text, #ee7676); +} + +.inspector-diff { + padding: 8px 12px 28px; +} + +.inspector-diff .review-diff, +.inspector-diff .review-single-file { + max-height: none; +} + +@media (max-width: 700px) { + .inspector-header, + .inspector-toolbar, + .inspector-tabs { + padding-right: 12px; + padding-left: 12px; + } + + .inspector-toolbar { + flex-wrap: wrap; + } + + .inspector-link-button { + margin-left: 0; + } + + .activity-row-header { + grid-template-columns: minmax(0, 1fr) auto 18px; + } + + .activity-tool-name { + display: none; + } + + .activity-detail { + padding-left: 8px; + } +} + .workspace-rows { display: grid; padding: 4px 0; diff --git a/src/ui/workspace-app.tsx b/src/ui/workspace-app.tsx index a04f48be..ced74377 100644 --- a/src/ui/workspace-app.tsx +++ b/src/ui/workspace-app.tsx @@ -47,6 +47,10 @@ interface MountedPayload { unmount(): void; } +interface MountedInspector { + unmount(): void; +} + let app: App | null = null; let connected = false; let connectionError: string | null = null; @@ -61,6 +65,8 @@ let openWorkspaceInstructionKey: string | null = null; let showAvailableWorkspaceInstructions = false; let pendingToolResult: CallToolResult | null = null; let pendingReviewKey: string | null = null; +let currentInspector: MountedInspector | null = null; +let currentInspectorContainer: HTMLElement | null = null; const maybeAppRoot = document.querySelector("#app"); @@ -77,7 +83,7 @@ async function boot(): Promise { app = new App( { name: "devspace-tool-cards", version: "0.4.0" }, - {}, + { availableDisplayModes: ["inline", "fullscreen"] }, ); app.ontoolresult = (result) => { @@ -90,11 +96,20 @@ async function boot(): Promise { app.onhostcontextchanged = (ctx) => { const previousTheme = hostContext?.theme; + const previousDisplayMode = hostContext?.displayMode; hostContext = { ...hostContext, ...ctx, }; applyHostContext(); + if (ctx.displayMode && ctx.displayMode !== previousDisplayMode) { + render(); + return; + } + if (hostContext?.displayMode === "fullscreen" && card?.tool === "open_workspace") { + render(); + return; + } // Workspace details inherit host variables directly. Rebuilding their DOM on // iframe resize would reset an in-progress instruction preview interaction. if (card?.tool === "open_workspace") { @@ -108,6 +123,7 @@ async function boot(): Promise { app.onteardown = async () => { window.removeEventListener("openai:set_globals", handleChatGptGlobalsChanged); + unmountInspector(); unmountPayload(); return {}; }; @@ -242,6 +258,7 @@ function applyHostContext(): void { } function render(): void { + unmountInspector(); unmountPayload(); if (connectionError) { @@ -259,6 +276,15 @@ function render(): void { return; } + if ( + card.tool === "open_workspace" + && card.workspaceId + && hostContext?.displayMode === "fullscreen" + ) { + renderWorkspaceInspector(card); + return; + } + const display = cardDisplay(card); if (card.tool === "show_changes") { renderReviewCard(card, display); @@ -270,8 +296,9 @@ function render(): void { const section = element("section", { className: toolCardClassName(display), }); + const header = element("div", { className: "tool-header-shell" }); const button = element("button", { - className: "tool-header", + className: "tool-header tool-header-primary", type: "button", ariaExpanded: String(expanded), disabled: !expandable, @@ -304,7 +331,23 @@ function render(): void { renderHeaderSummary(card), renderChevron(expanded, expandable), ); - section.append(button); + header.append(button); + + if (canOpenWorkspaceInspector(card)) { + const fullscreen = element("button", { + className: "workspace-fullscreen-action", + type: "button", + title: "Open workspace inspector", + ariaLabel: "Open workspace inspector", + }); + fullscreen.append(renderIcon(toolIcons.fullscreen)); + fullscreen.addEventListener("click", () => { + void requestWorkspaceInspector(); + }); + header.append(fullscreen); + } + + section.append(header); if (expanded) { const body = element("div", { className: "tool-body" }); @@ -317,6 +360,54 @@ function render(): void { renderPayloadIfNeeded(); } +function canOpenWorkspaceInspector(value: ToolResultCard): boolean { + return value.tool === "open_workspace" + && Boolean(value.workspaceId) + && Boolean(app?.getHostCapabilities()?.serverTools) + && Boolean(hostContext?.availableDisplayModes?.includes("fullscreen")); +} + +async function requestWorkspaceInspector(): Promise { + if (!app) return; + const result = await app.requestDisplayMode({ mode: "fullscreen" }); + hostContext = { ...hostContext, displayMode: result.mode }; + render(); +} + +async function exitWorkspaceInspector(): Promise { + if (!app) return; + const result = await app.requestDisplayMode({ mode: "inline" }); + hostContext = { ...hostContext, displayMode: result.mode }; + render(); +} + +function renderWorkspaceInspector(workspaceCard: ToolResultCard): void { + if (!app || !workspaceCard.workspaceId) return; + const container = element("div", { className: "workspace-inspector-host" }); + currentInspectorContainer = container; + appRoot.replaceChildren(container); + const target = container; + const currentApp = app; + + void import("./inspector/workspace-inspector.js").then(({ mountMcpWorkspaceInspector }) => { + if (currentInspectorContainer !== target || hostContext?.displayMode !== "fullscreen") return; + currentInspector = mountMcpWorkspaceInspector(target, { + app: currentApp, + workspaceId: workspaceCard.workspaceId!, + root: workspaceCard.root ?? workspaceCard.path ?? workspaceCard.workspaceId!, + mode: workspaceCard.mode, + hostContext, + onExitFullscreen: () => { void exitWorkspaceInspector(); }, + }); + }).catch((error) => { + if (currentInspectorContainer !== target) return; + target.replaceChildren(element("div", { + className: "inspector-status error", + text: error instanceof Error ? error.message : String(error), + })); + }); +} + function renderEmpty(message: string, tone: "muted" | "error" = "muted"): void { const main = element("main", { className: "shell" }); main.append(element("section", { className: `empty ${tone}`, text: message })); @@ -365,6 +456,12 @@ function unmountPayload(): void { currentPayloadContainer = null; } +function unmountInspector(): void { + currentInspector?.unmount(); + currentInspector = null; + currentInspectorContainer = null; +} + function unmountCurrentPayload(): void { currentPayload?.unmount(); currentPayload = null;