From 82e59b0d926b91786ac80d28aef1ace40d978c71 Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 26 Jun 2026 01:07:46 -1000 Subject: [PATCH 1/4] feat: add workspace changes views --- Cargo.lock | 1 + .../devo-ai-sdk/src/v2/client.test.ts | 149 +++++++ .../packages/devo-ai-sdk/src/v2/client.ts | 172 ++++++++ .../src/v2/protocol-validation.test.ts | 79 ++++ .../renderer/atoms/actions/event-processor.ts | 5 + .../src/renderer/atoms/workspace-changes.ts | 145 +++++++ .../src/renderer/components/agent-detail.tsx | 34 +- .../components/review/review-panel.tsx | 218 +++++++--- .../renderer/hooks/use-workspace-changes.ts | 95 ++++ apps/desktop/src/renderer/lib/types.ts | 12 + .../src/renderer/lib/workspace-diff.test.ts | 95 ++++ .../src/renderer/lib/workspace-diff.ts | 119 +++++ apps/desktop/src/renderer/services/devo.ts | 50 ++- crates/core/src/conversation/mod.rs | 3 +- crates/core/src/conversation/records.rs | 23 + crates/core/src/durable_record.rs | 24 ++ crates/protocol/README.md | 10 + crates/protocol/src/acp_ts.rs | 37 ++ crates/protocol/src/event.rs | 32 ++ crates/protocol/src/lib.rs | 2 + crates/protocol/src/protocol.rs | 15 + crates/protocol/src/workspace_changes.rs | 171 ++++++++ crates/server/Cargo.toml | 1 + crates/server/src/lib.rs | 1 + crates/server/src/persistence.rs | 60 +++ crates/server/src/runtime.rs | 10 + crates/server/src/runtime/connection.rs | 3 + crates/server/src/runtime/handlers.rs | 1 + crates/server/src/runtime/handlers/turn.rs | 2 + .../src/runtime/handlers/workspace_changes.rs | 152 +++++++ crates/server/src/runtime/research.rs | 4 + crates/server/src/runtime/turn_exec.rs | 17 + .../server/src/runtime/workspace_baseline.rs | 118 +++++ crates/server/src/workspace_changes/diff.rs | 224 ++++++++++ .../src/workspace_changes/fs_snapshot.rs | 395 +++++++++++++++++ crates/server/src/workspace_changes/git.rs | 358 +++++++++++++++ crates/server/src/workspace_changes/mod.rs | 408 ++++++++++++++++++ crates/utils/git/src/git_op/ghost_commits.rs | 23 + crates/utils/git/src/git_op/mod.rs | 1 + 39 files changed, 3195 insertions(+), 74 deletions(-) create mode 100644 apps/desktop/src/renderer/atoms/workspace-changes.ts create mode 100644 apps/desktop/src/renderer/hooks/use-workspace-changes.ts create mode 100644 apps/desktop/src/renderer/lib/workspace-diff.test.ts create mode 100644 apps/desktop/src/renderer/lib/workspace-diff.ts create mode 100644 crates/protocol/src/workspace_changes.rs create mode 100644 crates/server/src/runtime/handlers/workspace_changes.rs create mode 100644 crates/server/src/runtime/workspace_baseline.rs create mode 100644 crates/server/src/workspace_changes/diff.rs create mode 100644 crates/server/src/workspace_changes/fs_snapshot.rs create mode 100644 crates/server/src/workspace_changes/git.rs create mode 100644 crates/server/src/workspace_changes/mod.rs diff --git a/Cargo.lock b/Cargo.lock index f7c59f71..5f72057e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1808,6 +1808,7 @@ dependencies = [ "rusqlite", "serde", "serde_json", + "sha2 0.10.9", "smol_str 0.3.2", "tempfile", "thiserror 2.0.18", diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts index d6181ca4..9517cc7f 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.test.ts @@ -48,6 +48,12 @@ class FakeTransport implements DevoAcpTransport { } } + emitNotification(method: string, params: unknown): void { + for (const listener of this.listeners) { + listener({ type: "notification", method, params }) + } + } + emitRequest(id: string | number, method: string, params: unknown): void { for (const listener of this.listeners) { listener({ type: "request", id, method, params }) @@ -82,6 +88,36 @@ const initializeResult = { authMethods: [], } +const workspaceChangeView = { + scope: "turn", + status: "ready", + workspace_root: "/repo", + base: { + kind: "turn_checkpoint", + turn_id: "t1", + checkpoint_id: "checkpoint-1", + backend: "git_ghost_commit", + }, + coverage: "git_visible", + attribution: "workspace_net", + change_set_status: "finalized", + files: [ + { + path: "src/main.rs", + status: "modified", + additions: 2, + deletions: 1, + binary: false, + diff_truncated: false, + }, + ], + stats: { files_changed: 1, additions: 2, deletions: 1 }, + unified_diff: + "diff --git a/src/main.rs b/src/main.rs\n--- a/src/main.rs\n+++ b/src/main.rs\n@@ -1 +1 @@\n-old\n+new\n", + warnings: [], + generated_at: "2026-06-26T00:00:00Z", +} + const originalNow = Date.now afterEach(() => { @@ -1029,6 +1065,119 @@ describe("ACP desktop SDK session mapping", () => { ]) }) + test("reads workspace changes through the runtime workspace API", async () => { + const transport = new FakeTransport((method, params, directory) => { + if (method === "_devo/workspace/changes/read") { + expect(directory).toBe("/repo") + expect(params).toEqual({ + session_id: "s1", + scopes: ["turn"], + diff_detail: "full", + turn_id: "t1", + max_diff_bytes: 2_000_000, + }) + return { views: [workspaceChangeView] } + } + throw new Error(`unexpected request ${method}`) + }) + const client = createDevoClient({ directory: "/repo", transport }) + + const result = await client.workspace.changes.read({ + sessionID: "s1", + scopes: ["turn"], + turnID: "t1", + diffDetail: "full", + maxDiffBytes: 2_000_000, + }) + + expect(result.data).toEqual({ views: [workspaceChangeView] }) + }) + + test("emits workspace change events from direct workspace notifications", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") return initializeResult + throw new Error(`unexpected request ${method}`) + }) + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + transport.emitNotification("workspace/changes/updated", { + session_id: "s1", + turn_id: "t1", + scope: "turn", + status: "ready", + coverage: "git_visible", + change_set_status: "finalized", + stats: { files_changed: 1, additions: 2, deletions: 1 }, + version: 42, + generated_at: "2026-06-26T00:00:00Z", + }) + + expect(await nextPayload(stream, "workspace-direct")).toEqual({ + type: "workspace.changes.updated", + properties: { + sessionID: "s1", + turnID: "t1", + scope: "turn", + status: "ready", + coverage: "git_visible", + changeSetStatus: "finalized", + stats: { filesChanged: 1, additions: 2, deletions: 1 }, + version: 42, + generatedAt: "2026-06-26T00:00:00Z", + }, + }) + }) + + test("emits workspace change events from wrapped original server events", async () => { + const transport = new FakeTransport((method) => { + if (method === "initialize") return initializeResult + if (method === "session/list") return { sessions: [sessionInfo] } + throw new Error(`unexpected request ${method}`) + }) + const client = createDevoClient({ directory: "/repo", transport }) + const stream = (await client.global.event()).stream[Symbol.asyncIterator]() + + await client.session.list() + transport.emitSessionUpdate({ + sessionId: "s1", + update: { sessionUpdate: "session_info_update" }, + _meta: { + "devo/originalEvent": { + kind: "workspace_changes_updated", + session_id: "s1", + turn_id: "t1", + scope: "turn", + status: "ready", + coverage: "git_visible", + change_set_status: "finalized", + stats: { files_changed: 1, additions: 2, deletions: 1 }, + version: 43, + generated_at: "2026-06-26T00:00:01Z", + }, + }, + } satisfies AcpSessionNotification) + + expect(await nextPayload(stream, "workspace-session-update")).toEqual({ + type: "session.updated", + properties: { info: expect.any(Object), session: expect.any(Object) }, + }) + expect(await nextPayload(stream, "workspace-wrapped")).toEqual({ + type: "workspace.changes.updated", + properties: { + sessionID: "s1", + turnID: "t1", + scope: "turn", + status: "ready", + coverage: "git_visible", + changeSetStatus: "finalized", + stats: { filesChanged: 1, additions: 2, deletions: 1 }, + version: 43, + generatedAt: "2026-06-26T00:00:01Z", + }, + }) + }) + test("maps original request_user_input events to questions and replies through runtime API", async () => { const transport = new FakeTransport((method) => { if (method === "initialize") return initializeResult diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts index b8e1edb3..0d3b41a1 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/client.ts @@ -34,6 +34,15 @@ import type { ModelConfigParams, ModelConfigResult, RequestUserInputRespondParams, + WorkspaceChangeCoverage, + WorkspaceChangeScope, + WorkspaceChangeSetStatus, + WorkspaceChangeStats, + WorkspaceChangeViewStatus, + WorkspaceChangesReadParams, + WorkspaceChangesReadResult, + WorkspaceChangesUpdatedPayload, + WorkspaceDiffDetail, } from "./generated/protocol" import { ProtocolValidationError, @@ -122,6 +131,48 @@ export type ToolState = any export type ToolStateCompleted = any export type UserMessage = any export type Worktree = any +export type { + WorkspaceChangeAttribution, + WorkspaceChangeBase, + WorkspaceChangeCoverage, + WorkspaceChangeScope, + WorkspaceChangeSetStatus, + WorkspaceChangeStats, + WorkspaceChangeView, + WorkspaceChangeViewStatus, + WorkspaceChangedFile, + WorkspaceChangedFileStatus, + WorkspaceChangesReadParams, + WorkspaceChangesReadResult, + WorkspaceChangesUpdatedPayload, + WorkspaceDiffDetail, +} from "./generated/protocol" + +export type WorkspaceChangesReadOptions = { + sessionID: string + cwd?: string + scopes: WorkspaceChangeScope[] + baseBranch?: string + turnID?: string + diffDetail?: WorkspaceDiffDetail + maxDiffBytes?: number | bigint +} + +export type WorkspaceChangesUpdatedEventProperties = { + sessionID: string + turnID: string + scope: WorkspaceChangeScope + status: WorkspaceChangeViewStatus + coverage: WorkspaceChangeCoverage + changeSetStatus: WorkspaceChangeSetStatus + stats: { + filesChanged: number + additions: number + deletions: number + } + version: number + generatedAt: string +} interface GlobalEvent { directory: string @@ -153,6 +204,71 @@ function sessionMeta(value: unknown): Record | undefined { return objectRecord(meta?.["devo/session"]) } +function numberFromProtocol(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "bigint") return Number(value) + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return 0 +} + +function workspaceChangeStats(value: unknown): WorkspaceChangeStats { + const stats = objectRecord(value) + return { + files_changed: numberFromProtocol(stats?.files_changed ?? stats?.filesChanged), + additions: numberFromProtocol(stats?.additions), + deletions: numberFromProtocol(stats?.deletions), + } +} + +function workspaceChangesUpdatedFromOriginalEvent( + original: unknown, +): WorkspaceChangesUpdatedPayload | null { + const event = objectRecord(original) + if (!event) return null + const payload = + event.kind === "workspace_changes_updated" + ? event + : objectRecord(event.WorkspaceChangesUpdated) ?? + objectRecord(event.workspace_changes_updated) + if (!payload) return null + return { + session_id: String(payload.session_id ?? payload.sessionId ?? ""), + turn_id: String(payload.turn_id ?? payload.turnId ?? ""), + scope: String(payload.scope ?? "turn") as WorkspaceChangeScope, + status: String(payload.status ?? "ready") as WorkspaceChangeViewStatus, + coverage: String(payload.coverage ?? "none") as WorkspaceChangeCoverage, + change_set_status: String( + payload.change_set_status ?? payload.changeSetStatus ?? "finalized", + ) as WorkspaceChangeSetStatus, + stats: workspaceChangeStats(payload.stats), + version: numberFromProtocol(payload.version), + generated_at: String(payload.generated_at ?? payload.generatedAt ?? ""), + } +} + +function workspaceChangesUpdatedEventProperties( + payload: WorkspaceChangesUpdatedPayload, +): WorkspaceChangesUpdatedEventProperties { + return { + sessionID: payload.session_id, + turnID: payload.turn_id, + scope: payload.scope, + status: payload.status, + coverage: payload.coverage, + changeSetStatus: payload.change_set_status, + stats: { + filesChanged: numberFromProtocol(payload.stats.files_changed), + additions: numberFromProtocol(payload.stats.additions), + deletions: numberFromProtocol(payload.stats.deletions), + }, + version: numberFromProtocol(payload.version), + generatedAt: payload.generated_at, + } +} + function parseTimestampMs(value: unknown): number | undefined { if (typeof value !== "string") return undefined const parsed = Date.parse(value) @@ -441,6 +557,29 @@ class AcpClient { }, } + workspace = { + changes: { + read: async (params: WorkspaceChangesReadOptions) => { + const wireParams: WorkspaceChangesReadParams = { + session_id: params.sessionID, + scopes: params.scopes, + diff_detail: params.diffDetail ?? "summary", + } + if (params.cwd !== undefined) wireParams.cwd = params.cwd + if (params.baseBranch !== undefined) wireParams.base_branch = params.baseBranch + if (params.turnID !== undefined) wireParams.turn_id = params.turnID + if (params.maxDiffBytes !== undefined) { + wireParams.max_diff_bytes = Number(params.maxDiffBytes) + } + const data = (await this.request( + "_devo/workspace/changes/read", + wireParams, + )) as WorkspaceChangesReadResult + return { data } + }, + }, + } + command = { list: async () => ({ data: [{ name: "compact", description: "Compact the session" }] }), } @@ -718,6 +857,21 @@ class AcpClient { this.handleSessionUpdate(notification) return } + if ( + event.type === "notification" && + (event.method === "workspace/changes/updated" || + event.method === "_devo/workspace/changes/updated") && + event.params + ) { + const payload = this.validateTransportPayload( + event.method, + "incomingNotification", + event.params, + ) + if (!payload) return + this.handleWorkspaceChangesUpdated(payload) + return + } if (event.type === "request" && event.id !== undefined && event.method) { const params = this.validateTransportPayload(event.method, "incomingRequest", event.params) if (!params) return @@ -973,6 +1127,10 @@ class AcpClient { const payload = (original as { RequestUserInput: Record }).RequestUserInput this.handleRequestUserInput(sessionId, directory, payload) } + const workspaceChanges = workspaceChangesUpdatedFromOriginalEvent(original) + if (workspaceChanges) { + this.handleWorkspaceChangesUpdated(workspaceChanges, directory) + } if ("ServerRequestResolved" in original) { const payload = (original as { ServerRequestResolved: Record }) .ServerRequestResolved @@ -987,6 +1145,20 @@ class AcpClient { } } + private handleWorkspaceChangesUpdated( + payload: WorkspaceChangesUpdatedPayload, + directory?: string, + ): void { + const event = workspaceChangesUpdatedEventProperties(payload) + if (!event.sessionID) return + const emitDirectory = + directory ?? this.sessionDirectories.get(event.sessionID) ?? this.options.directory ?? defaultCwd() + this.emit(emitDirectory, { + type: "workspace.changes.updated", + properties: event, + }) + } + private handleRequestUserInput( sessionId: string, directory: string, diff --git a/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts b/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts index 175733f2..8727a186 100644 --- a/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts +++ b/apps/desktop/packages/devo-ai-sdk/src/v2/protocol-validation.test.ts @@ -123,6 +123,85 @@ describe("desktop protocol runtime validation", () => { ).toThrow(ProtocolValidationError) }) + test("validates workspace changes read requests and results", () => { + const requestPayload = { + session_id: "s1", + scopes: ["turn"], + turn_id: "t1", + diff_detail: "full", + max_diff_bytes: 2_000_000, + } + const resultPayload = { + views: [ + { + scope: "turn", + status: "ready", + workspace_root: "/repo", + base: { + kind: "turn_checkpoint", + turn_id: "t1", + checkpoint_id: "checkpoint-1", + backend: "git_ghost_commit", + }, + coverage: "git_visible", + attribution: "workspace_net", + change_set_status: "finalized", + files: [ + { + path: "src/main.rs", + status: "modified", + additions: 2, + deletions: 1, + binary: false, + diff_truncated: false, + }, + ], + stats: { files_changed: 1, additions: 2, deletions: 1 }, + unified_diff: "diff --git a/src/main.rs b/src/main.rs\n", + warnings: [], + generated_at: "2026-06-26T00:00:00Z", + }, + ], + } + + expect( + assertValidProtocolPayload({ + direction: "outgoingRequest", + method: "_devo/workspace/changes/read", + payload: requestPayload, + }), + ).toBe(requestPayload) + expect( + assertValidProtocolPayload({ + direction: "incomingResult", + method: "_devo/workspace/changes/read", + payload: resultPayload, + }), + ).toBe(resultPayload) + }) + + test("validates workspace changes updated notifications", () => { + const payload = { + session_id: "s1", + turn_id: "t1", + scope: "turn", + status: "ready", + coverage: "git_visible", + change_set_status: "finalized", + stats: { files_changed: 1, additions: 2, deletions: 1 }, + version: 1, + generated_at: "2026-06-26T00:00:00Z", + } + + expect( + assertValidProtocolPayload({ + direction: "incomingNotification", + method: "workspace/changes/updated", + payload, + }), + ).toBe(payload) + }) + test("rejects unknown protocol methods", () => { expect(() => assertValidProtocolPayload({ diff --git a/apps/desktop/src/renderer/atoms/actions/event-processor.ts b/apps/desktop/src/renderer/atoms/actions/event-processor.ts index f68d3096..fa60261e 100644 --- a/apps/desktop/src/renderer/atoms/actions/event-processor.ts +++ b/apps/desktop/src/renderer/atoms/actions/event-processor.ts @@ -20,6 +20,7 @@ import { appStore } from "../store" import { isStreamingField, streamingVersionFamily } from "../streaming" import { todosFamily } from "../todos" import { setSessionDiffAtom } from "../ui" +import { applyWorkspaceChangesUpdatedAtom } from "../workspace-changes" const log = createLogger("event-processor") @@ -266,6 +267,10 @@ export function processEvent(event: Event): void { break } + case "workspace.changes.updated": + set(applyWorkspaceChangesUpdatedAtom, event.properties) + break + // --- Worktree lifecycle events (from Devo experimental API) --- case "worktree.ready": diff --git a/apps/desktop/src/renderer/atoms/workspace-changes.ts b/apps/desktop/src/renderer/atoms/workspace-changes.ts new file mode 100644 index 00000000..15c805a8 --- /dev/null +++ b/apps/desktop/src/renderer/atoms/workspace-changes.ts @@ -0,0 +1,145 @@ +import type { + WorkspaceChangeCoverage, + WorkspaceChangeScope, + WorkspaceChangeSetStatus, + WorkspaceChangeView, + WorkspaceChangeViewStatus, + WorkspaceChangesUpdatedEventProperties, +} from "@devo-ai/sdk/v2/client" +import { atom } from "jotai" +import { atomFamily } from "jotai/utils" + +export type WorkspaceChangesCacheKeyInput = { + sessionId: string + scope: WorkspaceChangeScope + turnId?: string | null + baseBranch?: string | null +} + +export type WorkspaceChangesSummary = { + sessionId: string + turnId: string + scope: WorkspaceChangeScope + status: WorkspaceChangeViewStatus + coverage: WorkspaceChangeCoverage + changeSetStatus: WorkspaceChangeSetStatus + stats: { + files_changed: number + additions: number + deletions: number + } + version: number + generatedAt: string +} + +export type WorkspaceChangesState = { + summary: WorkspaceChangesSummary | null + view: WorkspaceChangeView | null + loading: boolean + stale: boolean + error: string | null +} + +export function workspaceChangesKey(input: WorkspaceChangesCacheKeyInput): string { + return [ + input.sessionId, + input.scope, + input.turnId ?? "", + input.baseBranch ?? "", + ].join("\u001f") +} + +function emptyWorkspaceChangesState(): WorkspaceChangesState { + return { + summary: null, + view: null, + loading: false, + stale: true, + error: null, + } +} + +function eventSummary(event: WorkspaceChangesUpdatedEventProperties): WorkspaceChangesSummary { + return { + sessionId: event.sessionID, + turnId: event.turnID, + scope: event.scope, + status: event.status, + coverage: event.coverage, + changeSetStatus: event.changeSetStatus, + stats: { + files_changed: event.stats.filesChanged, + additions: event.stats.additions, + deletions: event.stats.deletions, + }, + version: event.version, + generatedAt: event.generatedAt, + } +} + +export const latestWorkspaceTurnIdFamily = atomFamily((_sessionId: string) => + atom(null), +) + +export const workspaceChangesStateFamily = atomFamily((_key: string) => + atom(emptyWorkspaceChangesState()), +) + +export const markWorkspaceChangesLoadingAtom = atom( + null, + (get, set, args: { key: string; loading: boolean; error?: string | null }) => { + const current = get(workspaceChangesStateFamily(args.key)) + set(workspaceChangesStateFamily(args.key), { + ...current, + loading: args.loading, + error: args.error === undefined ? current.error : args.error, + }) + }, +) + +export const setWorkspaceChangesViewAtom = atom( + null, + (_get, set, args: { key: string; view: WorkspaceChangeView }) => { + set(workspaceChangesStateFamily(args.key), { + summary: null, + view: args.view, + loading: false, + stale: false, + error: null, + }) + }, +) + +export const setWorkspaceChangesErrorAtom = atom( + null, + (get, set, args: { key: string; error: string }) => { + const current = get(workspaceChangesStateFamily(args.key)) + set(workspaceChangesStateFamily(args.key), { + ...current, + loading: false, + error: args.error, + }) + }, +) + +export const applyWorkspaceChangesUpdatedAtom = atom( + null, + (get, set, event: WorkspaceChangesUpdatedEventProperties) => { + const summary = eventSummary(event) + if (event.scope === "turn") { + set(latestWorkspaceTurnIdFamily(event.sessionID), event.turnID) + } + const key = workspaceChangesKey({ + sessionId: event.sessionID, + scope: event.scope, + turnId: event.scope === "turn" ? event.turnID : undefined, + }) + const current = get(workspaceChangesStateFamily(key)) + set(workspaceChangesStateFamily(key), { + ...current, + summary, + stale: true, + error: null, + }) + }, +) diff --git a/apps/desktop/src/renderer/components/agent-detail.tsx b/apps/desktop/src/renderer/components/agent-detail.tsx index 6e3882ec..c0bbc856 100644 --- a/apps/desktop/src/renderer/components/agent-detail.tsx +++ b/apps/desktop/src/renderer/components/agent-detail.tsx @@ -23,7 +23,12 @@ import { import { useCallback, useEffect, useRef, useState } from "react" import type { OpenInTarget } from "../../preload/api" import { terminalPanelOpenAtom } from "../atoms/terminal" -import { reviewPanelOpenAtom, reviewPanelSettingsAtom, sessionDiffStatsFamily } from "../atoms/ui" +import { reviewPanelOpenAtom, reviewPanelSettingsAtom } from "../atoms/ui" +import { + latestWorkspaceTurnIdFamily, + workspaceChangesKey, + workspaceChangesStateFamily, +} from "../atoms/workspace-changes" import type { ConfigData, ModelRef, @@ -34,6 +39,7 @@ import type { import type { ChatTurn } from "../hooks/use-session-chat" import { formatShortcut } from "../lib/shortcut-display" import type { Agent, FileAttachment, QuestionAnswer } from "../lib/types" +import { workspaceChangeStats } from "../lib/workspace-diff" import { fetchOpenInTargets, isElectron, @@ -45,6 +51,28 @@ import { ReviewPanel } from "./review/review-panel" import { SessionMetricsBar } from "./session-metrics-bar" import { WorktreeActions } from "./worktree-actions" +function useTurnWorkspaceChangeStats(sessionId: string): { + fileCount: number + additions: number + deletions: number +} { + const latestTurnId = useAtomValue(latestWorkspaceTurnIdFamily(sessionId)) + const key = workspaceChangesKey({ + sessionId, + scope: "turn", + turnId: latestTurnId, + }) + const state = useAtomValue(workspaceChangesStateFamily(key)) + if (state.view) return workspaceChangeStats(state.view) + if (state.summary) { + return { + fileCount: state.summary.stats.files_changed, + additions: state.summary.stats.additions, + deletions: state.summary.stats.deletions, + } + } + return { fileCount: 0, additions: 0, deletions: 0 } +} interface AgentDetailProps { agent: Agent @@ -162,7 +190,7 @@ export function AgentDetail({ // Close review panel when navigating to a session with no diffs const prevSessionIdRef = useRef(agent.sessionId) - const diffStats = useAtomValue(sessionDiffStatsFamily(agent.sessionId)) + const diffStats = useTurnWorkspaceChangeStats(agent.sessionId) useEffect(() => { if (prevSessionIdRef.current !== agent.sessionId) { prevSessionIdRef.current = agent.sessionId @@ -327,7 +355,7 @@ function SessionPanelHeader({ onToggleReviewPanel: () => void }) { const navigate = useNavigate() - const diffStats = useAtomValue(sessionDiffStatsFamily(agent.sessionId)) + const diffStats = useTurnWorkspaceChangeStats(agent.sessionId) const toggleReviewPanelShortcut = formatShortcut(["shift", "mod", "D"]) return ( diff --git a/apps/desktop/src/renderer/components/review/review-panel.tsx b/apps/desktop/src/renderer/components/review/review-panel.tsx index f1e9975f..ecb2948c 100644 --- a/apps/desktop/src/renderer/components/review/review-panel.tsx +++ b/apps/desktop/src/renderer/components/review/review-panel.tsx @@ -11,7 +11,7 @@ * 7. Only the active theme (light/dark) is rendered, not both */ import { cn } from "@devo/ui/lib/utils" -import { MultiFileDiff, useWorkerPool, WorkerPoolContextProvider } from "@pierre/diffs/react" +import { PatchDiff, useWorkerPool, WorkerPoolContextProvider } from "@pierre/diffs/react" import { useVirtualizer } from "@tanstack/react-virtual" import { useAtom, useAtomValue, useSetAtom } from "jotai" import { @@ -46,9 +46,13 @@ import { reviewPanelSelectedFileAtom, reviewPanelSettingsAtom, } from "../../atoms/ui" -import { useSessionDiff } from "../../hooks/use-session-diff" -import type { FileDiff } from "../../lib/types" -import { DiffCommentButton, ReviewPanelComments, useDiffComments } from "./review-comments" +import { useWorkspaceChanges } from "../../hooks/use-workspace-changes" +import type { WorkspaceChangeScope, WorkspaceChangeView } from "../../lib/types" +import { + type WorkspacePatchFile, + workspaceChangeStats, + workspacePatchFilesFromView, +} from "../../lib/workspace-diff" // ============================================================ // Constants @@ -67,6 +71,20 @@ const LARGE_DIFF_LINE_THRESHOLD = 1500 /** Estimated height (px) of a collapsed diff section (header only). */ const COLLAPSED_ROW_HEIGHT = 32 +const WORKSPACE_CHANGE_SCOPES: Array<{ scope: WorkspaceChangeScope; label: string }> = [ + { scope: "turn", label: "Turn" }, + { scope: "branch", label: "Branch" }, + { scope: "uncommitted", label: "Uncommitted" }, +] + +function statsFromView(view: WorkspaceChangeView | null | undefined): { + fileCount: number + additions: number + deletions: number +} { + return workspaceChangeStats(view) +} + // ============================================================ // Generated / vendor file detection // ============================================================ @@ -106,7 +124,7 @@ function isGeneratedFile(filePath: string): boolean { return GENERATED_FILE_PATTERNS.some((p) => p.test(filePath)) } -function isLargeDiff(diff: FileDiff): boolean { +function isLargeDiff(diff: WorkspacePatchFile): boolean { return diff.additions + diff.deletions > LARGE_DIFF_LINE_THRESHOLD } @@ -171,11 +189,15 @@ export const ReviewPanel = memo(function ReviewPanel({ directory, className, }: ReviewPanelProps) { - const { diffs, stats, loading } = useSessionDiff(sessionId, directory) + const [scope, setScope] = useState("turn") const [settings, setSettings] = useAtom(reviewPanelSettingsAtom) - const setOpen = useAtom(reviewPanelOpenAtom)[1] + const [panelOpen, setOpen] = useAtom(reviewPanelOpenAtom) const [selectedFile, setSelectedFile] = useState(null) - const { comments, addComment, removeComment, clearComments } = useDiffComments(sessionId) + const { view, loading, error } = useWorkspaceChanges(sessionId, directory, scope, { + enabled: panelOpen, + }) + const diffs = useMemo(() => workspacePatchFilesFromView(view), [view]) + const stats = useMemo(() => statsFromView(view), [view]) // --- External file selection (e.g. "View diff" button in tool cards) --- const externalFile = useAtomValue(reviewPanelSelectedFileAtom) @@ -206,7 +228,7 @@ export const ReviewPanel = memo(function ReviewPanel({ const manyFiles = diffs.length > AUTO_COLLAPSE_THRESHOLD const getIsCollapsed = useCallback( - (diff: FileDiff): boolean => { + (diff: WorkspacePatchFile): boolean => { // User override takes priority if (diff.file in userToggles) return !userToggles[diff.file] // Auto-collapse rules @@ -253,6 +275,11 @@ export const ReviewPanel = memo(function ReviewPanel({ } }, [sessionId]) + useEffect(() => { + setSelectedFile(null) + setUserToggles({}) + }, [scope]) + // --- Handlers --- const handleClose = useCallback(() => setOpen(false), [setOpen]) const handleToggleExpanded = useCallback( @@ -281,6 +308,7 @@ export const ReviewPanel = memo(function ReviewPanel({

Changes

+ {stats.fileCount > 0 && ( @@ -358,10 +386,7 @@ export const ReviewPanel = memo(function ReviewPanel({
)} - {/* Comment pills */} - {comments.length > 0 && ( - - )} + {/* Diff content -- virtualized */}
@@ -371,14 +396,13 @@ export const ReviewPanel = memo(function ReviewPanel({ Loading changes...
) : diffs.length === 0 ? ( - + ) : ( )}
@@ -386,21 +410,74 @@ export const ReviewPanel = memo(function ReviewPanel({ ) }) +function ScopeSegmentedControl({ + scope, + onScopeChange, +}: { + scope: WorkspaceChangeScope + onScopeChange: (scope: WorkspaceChangeScope) => void +}) { + return ( +
+ {WORKSPACE_CHANGE_SCOPES.map((item) => ( + + ))} +
+ ) +} + +function WorkspaceChangeNotice({ + view, + error, +}: { + view: WorkspaceChangeView | null + error: string | null +}) { + const warnings = view?.warnings ?? [] + if (!error && warnings.length === 0 && view?.status !== "partial") return null + return ( +
+ {error ? ( +
+ + {error} +
+ ) : ( +
+ + Partial change view + {warnings.map((warning) => ( + + {warning} + + ))} +
+ )} +
+ ) +} + // ============================================================ // Virtualized diff list using TanStack Virtual // ============================================================ interface VirtualizedDiffListProps { - diffs: FileDiff[] + diffs: WorkspacePatchFile[] diffStyle: DiffStyle - getIsCollapsed: (diff: FileDiff) => boolean + getIsCollapsed: (diff: WorkspacePatchFile) => boolean onToggle: (file: string) => void - onAddComment: (comment: { - filePath: string - lineNumber: number - side: "additions" | "deletions" - content: string - }) => void } const VirtualizedDiffList = memo(function VirtualizedDiffList({ @@ -408,11 +485,10 @@ const VirtualizedDiffList = memo(function VirtualizedDiffList({ diffStyle, getIsCollapsed, onToggle, - onAddComment, }: VirtualizedDiffListProps) { const scrollRef = useRef(null) const isDark = useIsDarkMode() - const [pinnedDiff, setPinnedDiff] = useState(null) + const [pinnedDiff, setPinnedDiff] = useState(null) const pinnedFileRef = useRef(null) const theme = isDark ? ("one-dark-pro" as const) : ("one-light" as const) @@ -462,7 +538,7 @@ const VirtualizedDiffList = memo(function VirtualizedDiffList({ } // Find the expanded file whose header has fully scrolled out of view // but whose body still extends below the viewport top - let found: FileDiff | null = null + let found: WorkspacePatchFile | null = null for (const item of virtualizer.getVirtualItems()) { const diff = diffs[item.index] if (getIsCollapsed(diff)) continue @@ -547,7 +623,6 @@ const VirtualizedDiffList = memo(function VirtualizedDiffList({ diffStyle={diffStyle} collapsed={collapsed} onToggle={onToggle} - onAddComment={onAddComment} /> ) @@ -592,7 +667,7 @@ const FileList = memo(function FileList({ selectedFile, onSelectFile, }: { - diffs: FileDiff[] + diffs: WorkspacePatchFile[] selectedFile: string | null onSelectFile: (file: string | null) => void }) { @@ -688,16 +763,10 @@ const FileListItem = memo(function FileListItem({ // ============================================================ interface FileDiffSectionProps { - diff: FileDiff + diff: WorkspacePatchFile diffStyle: DiffStyle collapsed: boolean onToggle: (file: string) => void - onAddComment: (comment: { - filePath: string - lineNumber: number - side: "additions" | "deletions" - content: string - }) => void } const FileDiffSection = memo(function FileDiffSection({ @@ -705,7 +774,6 @@ const FileDiffSection = memo(function FileDiffSection({ diffStyle, collapsed, onToggle, - onAddComment, }: FileDiffSectionProps) { const generated = isGeneratedFile(diff.file) const large = isLargeDiff(diff) @@ -722,26 +790,6 @@ const FileDiffSection = memo(function FileDiffSection({ [diffStyle], ) - const oldFile = useMemo( - () => ({ name: diff.file, contents: diff.before }), - [diff.file, diff.before], - ) - const newFile = useMemo( - () => ({ name: diff.file, contents: diff.after }), - [diff.file, diff.after], - ) - - const renderHoverUtility = useCallback( - (getHoveredLine: () => { lineNumber: number; side: "additions" | "deletions" } | undefined) => ( - - ), - [diff.file, onAddComment], - ) - const handleToggle = useCallback(() => onToggle(diff.file), [diff.file, onToggle]) const handleLoadLarge = useCallback(() => { startTransition(() => setLoadLargeDiff(true)) @@ -763,12 +811,11 @@ const FileDiffSection = memo(function FileDiffSection({ // syntax highlighting from the background -- no manual queue needed. body = (
- + {diff.patch ? ( + + ) : ( + + )}
) } @@ -822,6 +869,26 @@ function LargeDiffPlaceholder({ ) } +function MetadataOnlyPlaceholder({ warnings }: { warnings: string[] }) { + return ( +
+
+ + Text diff is not available for this file +
+ {warnings.length > 0 && ( +
+ {warnings.map((warning) => ( + + {warning} + + ))} +
+ )} +
+ ) +} + // ============================================================ // File diff header // ============================================================ @@ -921,17 +988,34 @@ function StatusBadge({ status }: { status: "added" | "deleted" | "modified" }) { // Empty state // ============================================================ -function EmptyState() { +function EmptyState({ + scope, + view, + error, +}: { + scope: WorkspaceChangeScope + view: WorkspaceChangeView | null + error: string | null +}) { + const scopeLabel = WORKSPACE_CHANGE_SCOPES.find((item) => item.scope === scope)?.label ?? "Changes" + const title = error + ? "Unable to load changes" + : view?.status === "unsupported" + ? `${scopeLabel} changes unavailable` + : "No changes yet" + const detail = error + ? error + : view?.status === "unsupported" + ? (view.warnings[0] ?? "This workspace does not support that change scope") + : "File changes will appear here as the agent works" return (
-

No changes yet

-

- File changes will appear here as the agent works -

+

{title}

+

{detail}

) diff --git a/apps/desktop/src/renderer/hooks/use-workspace-changes.ts b/apps/desktop/src/renderer/hooks/use-workspace-changes.ts new file mode 100644 index 00000000..e9cd36e5 --- /dev/null +++ b/apps/desktop/src/renderer/hooks/use-workspace-changes.ts @@ -0,0 +1,95 @@ +import type { WorkspaceChangeScope, WorkspaceChangeView } from "@devo-ai/sdk/v2/client" +import { useAtomValue, useSetAtom } from "jotai" +import { useCallback, useEffect, useMemo } from "react" +import { isMockModeAtom } from "../atoms/mock-mode" +import { + latestWorkspaceTurnIdFamily, + markWorkspaceChangesLoadingAtom, + setWorkspaceChangesErrorAtom, + setWorkspaceChangesViewAtom, + workspaceChangesKey, + workspaceChangesStateFamily, +} from "../atoms/workspace-changes" +import { getProjectClient } from "../services/connection-manager" +import { getWorkspaceChanges } from "../services/devo" + +const FULL_DIFF_LIMIT_BYTES = 2_000_000 + +export function useWorkspaceChanges( + sessionId: string, + directory: string, + scope: WorkspaceChangeScope, + options: { enabled?: boolean; baseBranch?: string | null } = {}, +) { + const latestTurnId = useAtomValue(latestWorkspaceTurnIdFamily(sessionId)) + const turnId = scope === "turn" ? latestTurnId : undefined + const key = useMemo( + () => + workspaceChangesKey({ + sessionId, + scope, + turnId: turnId ?? undefined, + baseBranch: options.baseBranch, + }), + [sessionId, scope, turnId, options.baseBranch], + ) + const state = useAtomValue(workspaceChangesStateFamily(key)) + const markLoading = useSetAtom(markWorkspaceChangesLoadingAtom) + const setView = useSetAtom(setWorkspaceChangesViewAtom) + const setError = useSetAtom(setWorkspaceChangesErrorAtom) + const isMockMode = useAtomValue(isMockModeAtom) + const enabled = options.enabled ?? true + + const fetchChanges = useCallback(async () => { + if (isMockMode) return + const client = getProjectClient(directory) + if (!client) return + markLoading({ key, loading: true, error: null }) + try { + const result = await getWorkspaceChanges(client, { + sessionId, + scopes: [scope], + baseBranch: options.baseBranch ?? undefined, + turnId: turnId ?? undefined, + diffDetail: "full", + maxDiffBytes: FULL_DIFF_LIMIT_BYTES, + }) + const view = result.views.find((item) => item.scope === scope) as + | WorkspaceChangeView + | undefined + if (view) { + setView({ key, view }) + } else { + setError({ key, error: "Workspace change view missing from response" }) + } + } catch (error) { + setError({ + key, + error: error instanceof Error ? error.message : "Failed to load workspace changes", + }) + } + }, [ + directory, + isMockMode, + key, + markLoading, + options.baseBranch, + scope, + sessionId, + setError, + setView, + turnId, + ]) + + useEffect(() => { + if (!enabled) return + if (!state.view || state.stale) void fetchChanges() + }, [enabled, fetchChanges, state.stale, state.view]) + + return { + ...state, + key, + latestTurnId, + refetch: fetchChanges, + } +} diff --git a/apps/desktop/src/renderer/lib/types.ts b/apps/desktop/src/renderer/lib/types.ts index c6edd83a..1b8e7321 100644 --- a/apps/desktop/src/renderer/lib/types.ts +++ b/apps/desktop/src/renderer/lib/types.ts @@ -36,6 +36,18 @@ export type { ToolState, ToolStateCompleted, UserMessage, + WorkspaceChangeAttribution, + WorkspaceChangeBase, + WorkspaceChangeCoverage, + WorkspaceChangeScope, + WorkspaceChangeSetStatus, + WorkspaceChangeView, + WorkspaceChangeViewStatus, + WorkspaceChangedFile, + WorkspaceChangedFileStatus, + WorkspaceChangesReadResult, + WorkspaceChangesUpdatedEventProperties, + WorkspaceDiffDetail, } from "@devo-ai/sdk/v2/client" // ============================================================ diff --git a/apps/desktop/src/renderer/lib/workspace-diff.test.ts b/apps/desktop/src/renderer/lib/workspace-diff.test.ts new file mode 100644 index 00000000..d3637c69 --- /dev/null +++ b/apps/desktop/src/renderer/lib/workspace-diff.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import type { WorkspaceChangeView } from "@devo-ai/sdk/v2/client" +import { workspacePatchFilesFromView } from "./workspace-diff" + +describe("workspacePatchFilesFromView", () => { + test("splits unified git diff into per-file patch entries", () => { + const view = { + scope: "turn", + status: "ready", + workspace_root: "/repo", + coverage: "git_visible", + attribution: "workspace_net", + change_set_status: "finalized", + files: [ + file("src/a.ts", "modified", 1, 1), + file("src/b.ts", "added", 1, 0), + file("src/c.ts", "deleted", 0, 1), + ], + stats: { files_changed: 3, additions: 2, deletions: 2 }, + unified_diff: [ + "diff --git a/src/a.ts b/src/a.ts", + "--- a/src/a.ts", + "+++ b/src/a.ts", + "@@ -1 +1 @@", + "-old", + "+new", + "diff --git a/src/b.ts b/src/b.ts", + "--- /dev/null", + "+++ b/src/b.ts", + "@@ -0,0 +1 @@", + "+new", + "diff --git a/src/c.ts b/src/c.ts", + "--- a/src/c.ts", + "+++ /dev/null", + "@@ -1 +0,0 @@", + "-old", + ].join("\n"), + warnings: [], + generated_at: "2026-06-26T00:00:00Z", + } as unknown as WorkspaceChangeView + + expect(workspacePatchFilesFromView(view)).toEqual([ + expect.objectContaining({ file: "src/a.ts", patch: expect.stringContaining("-old") }), + expect.objectContaining({ file: "src/b.ts", patch: expect.stringContaining("+new") }), + expect.objectContaining({ file: "src/c.ts", patch: expect.stringContaining("-old") }), + ]) + }) + + test("keeps metadata-only files visible", () => { + const view = { + scope: "turn", + status: "partial", + workspace_root: "/repo", + coverage: "partial", + attribution: "workspace_net", + change_set_status: "finalized", + files: [file("asset.bin", "modified", 0, 0, true, true)], + stats: { files_changed: 1, additions: 0, deletions: 0 }, + warnings: ["large_file_without_text_diff"], + generated_at: "2026-06-26T00:00:00Z", + } as unknown as WorkspaceChangeView + + expect(workspacePatchFilesFromView(view)).toEqual([ + { + file: "asset.bin", + status: "modified", + rawStatus: "modified", + additions: 0, + deletions: 0, + binary: true, + diffTruncated: true, + patch: null, + warnings: ["Binary file", "Diff truncated", "No text diff available"], + }, + ]) + }) +}) + +function file( + path: string, + status: "added" | "modified" | "deleted", + additions: number, + deletions: number, + binary = false, + diffTruncated = false, +) { + return { + path, + status, + additions, + deletions, + binary, + diff_truncated: diffTruncated, + } +} diff --git a/apps/desktop/src/renderer/lib/workspace-diff.ts b/apps/desktop/src/renderer/lib/workspace-diff.ts new file mode 100644 index 00000000..751c136a --- /dev/null +++ b/apps/desktop/src/renderer/lib/workspace-diff.ts @@ -0,0 +1,119 @@ +import type { + WorkspaceChangedFile, + WorkspaceChangedFileStatus, + WorkspaceChangeView, +} from "@devo-ai/sdk/v2/client" + +export type ReviewFileStatus = "added" | "deleted" | "modified" + +export type WorkspacePatchFile = { + file: string + status: ReviewFileStatus + rawStatus: WorkspaceChangedFileStatus + additions: number + deletions: number + binary: boolean + diffTruncated: boolean + patch: string | null + warnings: string[] +} + +export function numberFromProtocol(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) return value + if (typeof value === "bigint") return Number(value) + if (typeof value === "string") { + const parsed = Number(value) + if (Number.isFinite(parsed)) return parsed + } + return 0 +} + +export function workspaceChangeStats(view: WorkspaceChangeView | null | undefined): { + fileCount: number + additions: number + deletions: number +} { + if (!view) return { fileCount: 0, additions: 0, deletions: 0 } + return { + fileCount: numberFromProtocol(view.stats.files_changed), + additions: numberFromProtocol(view.stats.additions), + deletions: numberFromProtocol(view.stats.deletions), + } +} + +export function workspacePatchFilesFromView( + view: WorkspaceChangeView | null | undefined, +): WorkspacePatchFile[] { + if (!view) return [] + const patches = patchesByPath(view.unified_diff ?? "") + return view.files.map((file) => { + const path = String(file.path) + return { + file: path, + status: reviewStatus(file.status), + rawStatus: file.status, + additions: numberFromProtocol(file.additions), + deletions: numberFromProtocol(file.deletions), + binary: Boolean(file.binary), + diffTruncated: Boolean(file.diff_truncated), + patch: patches.get(path) ?? null, + warnings: warningsForFile(view, file), + } + }) +} + +function warningsForFile( + view: WorkspaceChangeView, + file: WorkspaceChangedFile, +): string[] { + const warnings: string[] = [] + if (file.binary) warnings.push("Binary file") + if (file.diff_truncated) warnings.push("Diff truncated") + if (!view.unified_diff) warnings.push("No text diff available") + return warnings +} + +function reviewStatus(status: WorkspaceChangedFileStatus): ReviewFileStatus { + switch (status) { + case "added": + case "untracked": + return "added" + case "deleted": + return "deleted" + case "modified": + case "renamed": + case "type_changed": + case "unknown": + return "modified" + } +} + +function patchesByPath(diff: string): Map { + const map = new Map() + for (const chunk of splitGitDiff(diff)) { + const path = pathFromPatch(chunk) + if (!path) continue + map.set(path, chunk.endsWith("\n") ? chunk : `${chunk}\n`) + } + return map +} + +function splitGitDiff(diff: string): string[] { + if (!diff.trim()) return [] + const lines = diff.split(/(?=^diff --git )/m) + return lines.map((line) => line.trimStart()).filter(Boolean) +} + +function pathFromPatch(patch: string): string | null { + const header = patch.match(/^diff --git a\/(.+?) b\/(.+)$/m) + if (header) return cleanPath(header[2]) + const renamed = patch.match(/^\+\+\+ b\/(.+)$/m) + if (renamed) return cleanPath(renamed[1]) + const deleted = patch.match(/^--- a\/(.+)$/m) + if (deleted) return cleanPath(deleted[1]) + return null +} + +function cleanPath(path: string): string { + return path.replace(/^"|"$/g, "") +} diff --git a/apps/desktop/src/renderer/services/devo.ts b/apps/desktop/src/renderer/services/devo.ts index df82b52f..ca76c433 100644 --- a/apps/desktop/src/renderer/services/devo.ts +++ b/apps/desktop/src/renderer/services/devo.ts @@ -1,7 +1,13 @@ -import type { DevoClient } from "@devo-ai/sdk/v2/client" +import type { + DevoClient, + WorkspaceChangeScope, + WorkspaceChangesReadResult, + WorkspaceDiffDetail, +} from "@devo-ai/sdk/v2/client" import { createDevoClient } from "@devo-ai/sdk/v2/client" import type { Event, DevoProject, QuestionAnswer, Session, SessionStatus } from "../lib/types" import { createLogger } from "../lib/logger" +import { workspacePatchFilesFromView } from "../lib/workspace-diff" export type { DevoClient } @@ -151,8 +157,46 @@ export async function getSession(client: DevoClient, sessionId: string): Promise * Get file diffs for a session. */ export async function getSessionDiff(client: DevoClient, sessionId: string) { - const result = await client.session.diff({ sessionID: sessionId }) - return result.data ?? [] + const result = await getWorkspaceChanges(client, { + sessionId, + scopes: ["turn"], + diffDetail: "full", + maxDiffBytes: 2_000_000, + }) + const view = result.views.find((item) => item.scope === "turn") + return workspacePatchFilesFromView(view).map((file) => ({ + file: file.file, + status: file.status, + additions: file.additions, + deletions: file.deletions, + before: "", + after: "", + diff: file.patch ?? "", + })) +} + +export async function getWorkspaceChanges( + client: DevoClient, + params: { + sessionId: string + scopes: WorkspaceChangeScope[] + cwd?: string + baseBranch?: string + turnId?: string + diffDetail?: WorkspaceDiffDetail + maxDiffBytes?: number + }, +): Promise { + const result = await client.workspace.changes.read({ + sessionID: params.sessionId, + scopes: params.scopes, + cwd: params.cwd, + baseBranch: params.baseBranch, + turnID: params.turnId, + diffDetail: params.diffDetail, + maxDiffBytes: params.maxDiffBytes, + }) + return result.data as WorkspaceChangesReadResult } /** diff --git a/crates/core/src/conversation/mod.rs b/crates/core/src/conversation/mod.rs index b5f75013..1846b194 100644 --- a/crates/core/src/conversation/mod.rs +++ b/crates/core/src/conversation/mod.rs @@ -6,6 +6,7 @@ pub use records::{ ItemLine, ItemRecord, MessageEditRecordedLine, ResearchArtifactItem, ResearchArtifactType, RolloutLine, SessionMetaLine, SessionRecord, SessionRollbackLine, SessionTitleUpdatedLine, TextItem, ToolCallItem, ToolProgressItem, ToolResultItem, TurnError, TurnItem, TurnLine, - TurnRecord, TurnSupersededLine, TurnWorkspaceRestoreCompletedLine, + TurnRecord, TurnSupersededLine, TurnWorkspaceChangeRecordedLine, + TurnWorkspaceCheckpointRecordedLine, TurnWorkspaceRestoreCompletedLine, TurnWorkspaceRestoreStartedLine, Worklog, }; diff --git a/crates/core/src/conversation/records.rs b/crates/core/src/conversation/records.rs index ae592acd..e6e10d16 100644 --- a/crates/core/src/conversation/records.rs +++ b/crates/core/src/conversation/records.rs @@ -6,6 +6,7 @@ use serde::{Deserialize, Serialize}; use crate::conversation::{ItemId, SessionId, SessionTitleState, TurnId, TurnStatus, TurnUsage}; use crate::{ MessageEditRecordedRecord, SessionContext, TurnContext, TurnKind, TurnSupersededRecord, + TurnWorkspaceChangeRecordedRecord, TurnWorkspaceCheckpointRecordedRecord, TurnWorkspaceRestoreCompletedRecord, TurnWorkspaceRestoreStartedRecord, }; use devo_protocol::{StopReason, TurnFailureReason}; @@ -456,6 +457,24 @@ pub struct TurnSupersededLine { pub record: TurnSupersededRecord, } +/// Stores one workspace-checkpoint record in the rollout file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnWorkspaceCheckpointRecordedLine { + /// The time when this rollout line was persisted. + pub timestamp: DateTime, + /// The workspace-checkpoint payload carried by the line. + pub record: TurnWorkspaceCheckpointRecordedRecord, +} + +/// Stores one workspace-change record in the rollout file. +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct TurnWorkspaceChangeRecordedLine { + /// The time when this rollout line was persisted. + pub timestamp: DateTime, + /// The workspace-change payload carried by the line. + pub record: TurnWorkspaceChangeRecordedRecord, +} + /// Stores one workspace-restore-start record in the rollout file. #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] pub struct TurnWorkspaceRestoreStartedLine { @@ -491,6 +510,10 @@ pub enum RolloutLine { MessageEditRecorded(Box), /// Turn-superseded marker line. TurnSuperseded(Box), + /// Workspace-checkpoint record line. + TurnWorkspaceCheckpointRecorded(Box), + /// Workspace-change record line. + TurnWorkspaceChangeRecorded(Box), /// Workspace-restore-start record line. TurnWorkspaceRestoreStarted(Box), /// Workspace-restore-completed record line. diff --git a/crates/core/src/durable_record.rs b/crates/core/src/durable_record.rs index e8e3f218..ad7f94a9 100644 --- a/crates/core/src/durable_record.rs +++ b/crates/core/src/durable_record.rs @@ -780,6 +780,16 @@ pub struct TurnWorkspaceCheckpointRecordedRecord { pub checkpoint_id: String, pub pre_turn_hash: String, pub files: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage: Option, + #[serde(default)] + pub warnings: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_ref: Option, pub created_at: DateTime, } @@ -794,6 +804,20 @@ pub struct TurnWorkspaceChangeRecordedRecord { pub post_hash: String, pub inverse_ref: Option, pub display_diff_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub workspace_root: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub backend: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub coverage: Option, + #[serde(default)] + pub warnings: Vec, + #[serde(default)] + pub changed_files: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub artifact_ref: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub change_set_status: Option, pub recorded_at: DateTime, } diff --git a/crates/protocol/README.md b/crates/protocol/README.md index d29cf944..3ac9fc38 100644 --- a/crates/protocol/README.md +++ b/crates/protocol/README.md @@ -78,6 +78,16 @@ behavior that is not represented by the portable ACP method set. - `_devo/turn/interrupt`: interrupt the active Devo turn. - `_devo/turn/steer`: send steering input into a running turn. +### Workspace extensions + +- `_devo/workspace/changes/read`: read branch, uncommitted, or turn-scoped + workspace change views. Git workspaces support branch and uncommitted scopes; + non-Git workspaces report those scopes as unsupported and only expose + turn-scoped bounded filesystem snapshots. +- `workspace/changes/updated`: notify subscribed clients that the turn-scoped + workspace change summary was finalized or updated. The notification carries a + summary only; clients call `_devo/workspace/changes/read` for full diffs. + ### Provider and model extensions - `_devo/provider/list`: list configured provider vendors. diff --git a/crates/protocol/src/acp_ts.rs b/crates/protocol/src/acp_ts.rs index db02ac32..dacde52d 100644 --- a/crates/protocol/src/acp_ts.rs +++ b/crates/protocol/src/acp_ts.rs @@ -220,6 +220,21 @@ pub fn generate_protocol_typescript() -> String { push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); + push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); push_decl::(&cfg, &mut output); @@ -675,6 +690,9 @@ fn register_devo_protocol_schemas( schema::(schemas); schema::(schemas); schema::(schemas); + schema::(schemas); + schema::(schemas); + schema::(schemas); schema::(schemas); schema::(schemas); schema::(schemas); @@ -775,6 +793,11 @@ fn register_devo_protocol_schemas( devo_method::(methods, ClientMethod::TurnShellCommand); devo_method::(methods, ClientMethod::TurnInterrupt); devo_method::(methods, ClientMethod::TurnSteer); + devo_method::( + methods, + ClientMethod::WorkspaceChangesRead, + ); + devo_notification::(methods, "workspace/changes/updated"); devo_request_only::( methods, ClientMethod::RequestUserInputRespond, @@ -869,6 +892,20 @@ fn devo_request_only( ); } +fn devo_notification( + methods: &mut BTreeMap, + method_name: &'static str, +) { + let binding = MethodSchemaBinding { + incoming_notification: Some(Box::leak(P::schema_name().into_boxed_str())), + ..MethodSchemaBinding::default() + }; + methods + .entry(method_name.to_string()) + .or_insert_with(|| binding.clone()); + methods.insert(devo_extension_method(method_name), binding); +} + fn devo_method_named( methods: &mut BTreeMap, client_method: ClientMethod, diff --git a/crates/protocol/src/event.rs b/crates/protocol/src/event.rs index 08173d22..5acff537 100644 --- a/crates/protocol/src/event.rs +++ b/crates/protocol/src/event.rs @@ -9,6 +9,7 @@ use crate::reference_search::{ReferenceSearchFailedPayload, ReferenceSearchSnaps use crate::request_user_input::RequestUserInputQuestion; use crate::session::{SessionMetadata, SessionRuntimeStatus}; use crate::turn::TurnMetadata; +use crate::workspace_changes::WorkspaceChangesUpdatedPayload; use crate::{ItemId, SessionId, TurnId, TurnUsage}; #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -332,6 +333,7 @@ pub enum ServerEvent { TurnPlanUpdated(TurnPlanUpdatedPayload), TurnDiffUpdated(TurnEventPayload), TurnUsageUpdated(TurnUsageUpdatedPayload), + WorkspaceChangesUpdated(WorkspaceChangesUpdatedPayload), ToolCallStatusUpdated(ToolCallStatusUpdatedPayload), RequestUserInput(RequestUserInputPayload), InputQueueUpdated(InputQueueUpdatedPayload), @@ -373,6 +375,7 @@ impl ServerEvent { | Self::TurnDiffUpdated(payload) => Some(payload.session_id), Self::TurnPlanUpdated(payload) => Some(payload.session_id), Self::TurnUsageUpdated(payload) => Some(payload.session_id), + Self::WorkspaceChangesUpdated(payload) => Some(payload.session_id), Self::ToolCallStatusUpdated(payload) => Some(payload.session_id), Self::RequestUserInput(payload) => Some(payload.request.session_id), Self::InputQueueUpdated(payload) => Some(payload.session_id), @@ -412,6 +415,7 @@ impl ServerEvent { Self::TurnPlanUpdated(_) => "turn/plan/updated", Self::TurnDiffUpdated(_) => "turn/diff/updated", Self::TurnUsageUpdated(_) => "turn/usage/updated", + Self::WorkspaceChangesUpdated(_) => "workspace/changes/updated", Self::ToolCallStatusUpdated(_) => "tool_call/status_updated", Self::RequestUserInput(_) => "item/tool/requestUserInput", Self::InputQueueUpdated(_) => "inputQueue/updated", @@ -447,6 +451,7 @@ impl ServerEvent { } Self::ItemDelta { payload, .. } => payload.context.seq = seq, Self::TurnUsageUpdated(_) + | Self::WorkspaceChangesUpdated(_) | Self::ToolCallStatusUpdated(_) | Self::RequestUserInput(_) | Self::InputQueueUpdated(_) @@ -471,6 +476,10 @@ mod tests { use pretty_assertions::assert_eq; use super::*; + use crate::workspace_changes::{ + WorkspaceChangeCoverage, WorkspaceChangeScope, WorkspaceChangeSetStatus, + WorkspaceChangeStats, WorkspaceChangeViewStatus, + }; #[test] fn input_queue_updated_event_roundtrips() { @@ -634,6 +643,29 @@ mod tests { assert!(event.session_id().is_some()); } + #[test] + fn workspace_changes_updated_method_name() { + let session_id = SessionId::new(); + let event = ServerEvent::WorkspaceChangesUpdated(WorkspaceChangesUpdatedPayload { + session_id, + turn_id: TurnId::new(), + scope: WorkspaceChangeScope::Turn, + status: WorkspaceChangeViewStatus::Ready, + coverage: WorkspaceChangeCoverage::GitVisible, + change_set_status: WorkspaceChangeSetStatus::Finalized, + stats: WorkspaceChangeStats { + files_changed: 1, + additions: 2, + deletions: 0, + }, + version: 1, + generated_at: Utc::now(), + }); + + assert_eq!(event.method_name(), "workspace/changes/updated"); + assert_eq!(event.session_id(), Some(session_id)); + } + #[test] fn research_artifact_delta_method_name() { let session_id = SessionId::new(); diff --git a/crates/protocol/src/lib.rs b/crates/protocol/src/lib.rs index e36a1678..faefa693 100644 --- a/crates/protocol/src/lib.rs +++ b/crates/protocol/src/lib.rs @@ -42,6 +42,7 @@ mod slash_command; mod truncation; mod turn; pub mod user_input; +mod workspace_changes; pub use acp::*; pub use acp_auth::*; @@ -78,3 +79,4 @@ pub use slash_command::*; pub use truncation::*; pub use turn::*; pub use user_input::*; +pub use workspace_changes::*; diff --git a/crates/protocol/src/protocol.rs b/crates/protocol/src/protocol.rs index 16ddf8a6..3349ce72 100644 --- a/crates/protocol/src/protocol.rs +++ b/crates/protocol/src/protocol.rs @@ -69,6 +69,7 @@ pub enum ClientMethod { TurnShellCommand, TurnInterrupt, TurnSteer, + WorkspaceChangesRead, RequestUserInputRespond, SearchStart, SearchUpdate, @@ -118,6 +119,7 @@ impl ClientMethod { Self::TurnShellCommand => "turn/shell_command", Self::TurnInterrupt => "turn/interrupt", Self::TurnSteer => "turn/steer", + Self::WorkspaceChangesRead => "workspace/changes/read", Self::RequestUserInputRespond => "request_user_input/respond", Self::SearchStart => "search/start", Self::SearchUpdate => "search/update", @@ -167,6 +169,7 @@ impl ClientMethod { "turn/shell_command" => Self::TurnShellCommand, "turn/interrupt" => Self::TurnInterrupt, "turn/steer" => Self::TurnSteer, + "workspace/changes/read" => Self::WorkspaceChangesRead, "request_user_input/respond" => Self::RequestUserInputRespond, "search/start" => Self::SearchStart, "search/update" => Self::SearchUpdate, @@ -588,6 +591,18 @@ mod tests { ); } + #[test] + fn client_method_recognizes_workspace_changes_read() { + assert_eq!( + ClientMethod::parse("workspace/changes/read"), + Some(ClientMethod::WorkspaceChangesRead) + ); + assert_eq!( + ClientMethod::WorkspaceChangesRead.as_str(), + "workspace/changes/read" + ); + } + #[test] fn client_method_does_not_recognize_legacy_approval_respond() { assert_eq!(ClientMethod::parse("approval/respond"), None); diff --git a/crates/protocol/src/workspace_changes.rs b/crates/protocol/src/workspace_changes.rs new file mode 100644 index 00000000..5a159b6c --- /dev/null +++ b/crates/protocol/src/workspace_changes.rs @@ -0,0 +1,171 @@ +use std::path::PathBuf; + +use chrono::{DateTime, Utc}; +use schemars::JsonSchema; +use serde::{Deserialize, Serialize}; +use ts_rs::TS; + +use crate::{SessionId, TurnId}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceChangeScope { + Branch, + Uncommitted, + Turn, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS, Default)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceDiffDetail { + None, + #[default] + Summary, + Full, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceChangeViewStatus { + Ready, + Empty, + Unsupported, + Partial, + Error, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceChangeCoverage { + Full, + GitVisible, + BoundedFilesystem, + Partial, + None, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceChangeSetStatus { + Accumulating, + Finalized, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceChangedFileStatus { + Added, + Modified, + Deleted, + Renamed, + TypeChanged, + Untracked, + Unknown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct WorkspaceChangesReadParams { + pub session_id: SessionId, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub cwd: Option, + pub scopes: Vec, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base_branch: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub turn_id: Option, + #[serde(default)] + pub diff_detail: WorkspaceDiffDetail, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub max_diff_bytes: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct WorkspaceChangesReadResult { + pub views: Vec, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct WorkspaceChangeView { + pub scope: WorkspaceChangeScope, + pub status: WorkspaceChangeViewStatus, + pub workspace_root: PathBuf, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub base: Option, + pub coverage: WorkspaceChangeCoverage, + pub attribution: WorkspaceChangeAttribution, + pub change_set_status: WorkspaceChangeSetStatus, + pub files: Vec, + pub stats: WorkspaceChangeStats, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub unified_diff: Option, + #[serde(default)] + pub warnings: Vec, + pub generated_at: DateTime, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(tag = "kind", rename_all = "snake_case")] +pub enum WorkspaceChangeBase { + Branch { + base_branch: String, + merge_base: String, + head: String, + }, + Head { + head: Option, + }, + TurnCheckpoint { + turn_id: TurnId, + checkpoint_id: String, + backend: WorkspaceCheckpointBackend, + }, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceCheckpointBackend { + GitGhostCommit, + FileManifest, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +#[serde(rename_all = "snake_case")] +pub enum WorkspaceChangeAttribution { + GitBranch, + GitWorkingTree, + WorkspaceNet, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct WorkspaceChangedFile { + pub path: PathBuf, + pub status: WorkspaceChangedFileStatus, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub additions: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub deletions: Option, + #[serde(default)] + pub binary: bool, + #[serde(default)] + pub diff_truncated: bool, +} + +#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct WorkspaceChangeStats { + pub files_changed: u64, + pub additions: u64, + pub deletions: u64, +} + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, JsonSchema, TS)] +pub struct WorkspaceChangesUpdatedPayload { + pub session_id: SessionId, + pub turn_id: TurnId, + pub scope: WorkspaceChangeScope, + pub status: WorkspaceChangeViewStatus, + pub coverage: WorkspaceChangeCoverage, + pub change_set_status: WorkspaceChangeSetStatus, + pub stats: WorkspaceChangeStats, + pub version: u64, + pub generated_at: DateTime, +} diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index af152106..10231ef5 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -31,6 +31,7 @@ fs2 = { workspace = true } rusqlite = { workspace = true } serde = { workspace = true } serde_json = { workspace = true } +sha2 = { workspace = true } smol_str = { workspace = true } thiserror = { workspace = true } tokio = { workspace = true } diff --git a/crates/server/src/lib.rs b/crates/server/src/lib.rs index 4dfef092..a1434123 100644 --- a/crates/server/src/lib.rs +++ b/crates/server/src/lib.rs @@ -23,6 +23,7 @@ mod titles; mod tool_actions; mod transport; mod turn; +mod workspace_changes; pub use approval::*; pub use bootstrap::*; diff --git a/crates/server/src/persistence.rs b/crates/server/src/persistence.rs index 5ccfb67d..b05c308a 100644 --- a/crates/server/src/persistence.rs +++ b/crates/server/src/persistence.rs @@ -46,6 +46,10 @@ use devo_core::TurnRecord; use devo_core::TurnStatus; use devo_core::TurnSupersededLine; use devo_core::TurnSupersededRecord; +use devo_core::TurnWorkspaceChangeRecordedLine; +use devo_core::TurnWorkspaceChangeRecordedRecord; +use devo_core::TurnWorkspaceCheckpointRecordedLine; +use devo_core::TurnWorkspaceCheckpointRecordedRecord; use devo_core::TurnWorkspaceRestoreCompletedLine; use devo_core::TurnWorkspaceRestoreCompletedRecord; use devo_core::TurnWorkspaceRestoreStartedLine; @@ -262,6 +266,38 @@ impl RolloutStore { ) } + /// Appends one workspace-checkpoint record to the durable rollout journal. + pub(crate) fn append_workspace_checkpoint_recorded( + &self, + record: &SessionRecord, + checkpoint: TurnWorkspaceCheckpointRecordedRecord, + ) -> Result<()> { + self.append_line( + &record.rollout_path, + &RolloutLine::TurnWorkspaceCheckpointRecorded(Box::new( + TurnWorkspaceCheckpointRecordedLine { + timestamp: Utc::now(), + record: checkpoint, + }, + )), + ) + } + + /// Appends one workspace-change record to the durable rollout journal. + pub(crate) fn append_workspace_change_recorded( + &self, + record: &SessionRecord, + change: TurnWorkspaceChangeRecordedRecord, + ) -> Result<()> { + self.append_line( + &record.rollout_path, + &RolloutLine::TurnWorkspaceChangeRecorded(Box::new(TurnWorkspaceChangeRecordedLine { + timestamp: Utc::now(), + record: change, + })), + ) + } + /// Appends one workspace-restore-completed record to the durable rollout journal. #[allow(dead_code)] pub(crate) fn append_workspace_restore_completed( @@ -612,6 +648,30 @@ impl ReplayState { )?; self.apply_turn_superseded(line.record); } + RolloutLine::TurnWorkspaceCheckpointRecorded(line) => { + self.apply_record_timestamp( + line.record.session_id, + line.timestamp, + "workspace checkpoint line", + )?; + self.apply_activity_timestamp( + line.record.session_id, + line.timestamp, + "workspace checkpoint line", + )?; + } + RolloutLine::TurnWorkspaceChangeRecorded(line) => { + self.apply_record_timestamp( + line.record.session_id, + line.timestamp, + "workspace change line", + )?; + self.apply_activity_timestamp( + line.record.session_id, + line.timestamp, + "workspace change line", + )?; + } RolloutLine::TurnWorkspaceRestoreStarted(line) => { self.apply_record_timestamp( line.record.session_id, diff --git a/crates/server/src/runtime.rs b/crates/server/src/runtime.rs index 4f33626f..cc287e3e 100644 --- a/crates/server/src/runtime.rs +++ b/crates/server/src/runtime.rs @@ -56,6 +56,11 @@ use devo_core::tools::ToolPermissionRequest; use devo_core::tools::ToolRegistry; use devo_core::tools::ToolRuntime; use devo_core::tools::ToolRuntimeContext; +use devo_protocol::{ + WorkspaceChangeAttribution, WorkspaceChangeScope, WorkspaceChangeView, + WorkspaceChangesReadParams, WorkspaceChangesReadResult, WorkspaceChangesUpdatedPayload, + WorkspaceDiffDetail, +}; use devo_safety::PermissionMode; use devo_util_shell_command::parse_command::parse_command; @@ -144,6 +149,7 @@ use crate::subagent::SubagentMailbox; use crate::subagent::SubagentMetadata; use crate::subagent::SubagentOutputBuffer; use crate::subagent::SubagentStatus; +use crate::workspace_changes::ActiveWorkspaceBaseline; mod acp_fs; mod acp_terminal; @@ -168,6 +174,7 @@ mod skills; mod turn_exec; mod turn_reservation; mod user_input; +mod workspace_baseline; pub(crate) use connection::CONNECTION_NOTIFICATION_CHANNEL_CAPACITY; pub(crate) use connection::ConnectionRuntime; @@ -210,6 +217,8 @@ pub struct ServerRuntime { Mutex>, /// Live client-owned shell/process sessions. command_exec_manager: command_exec::CommandExecManager, + /// Turn-scoped workspace baselines captured at actual execution start. + active_workspace_baselines: Mutex>, } #[derive(Debug, Clone, PartialEq, Eq)] @@ -321,6 +330,7 @@ impl ServerRuntime { research_child_agents: Mutex::new(HashMap::new()), reference_searches: Mutex::new(HashMap::new()), command_exec_manager: command_exec::CommandExecManager::new(), + active_workspace_baselines: Mutex::new(HashMap::new()), }) } } diff --git a/crates/server/src/runtime/connection.rs b/crates/server/src/runtime/connection.rs index e98f9767..4eaa4248 100644 --- a/crates/server/src/runtime/connection.rs +++ b/crates/server/src/runtime/connection.rs @@ -400,6 +400,9 @@ impl ServerRuntime { Some(ClientMethod::TurnSteer) => { Some(self.handle_turn_steer(connection_id, id?, params).await) } + Some(ClientMethod::WorkspaceChangesRead) => { + Some(self.handle_workspace_changes_read(id?, params).await) + } Some(ClientMethod::RequestUserInputRespond) => { Some(self.handle_request_user_input_respond(id?, params).await) } diff --git a/crates/server/src/runtime/handlers.rs b/crates/server/src/runtime/handlers.rs index 607db7f5..afb4fd08 100644 --- a/crates/server/src/runtime/handlers.rs +++ b/crates/server/src/runtime/handlers.rs @@ -8,3 +8,4 @@ mod message_edit; mod message_edit_restore; mod session; mod turn; +mod workspace_changes; diff --git a/crates/server/src/runtime/handlers/turn.rs b/crates/server/src/runtime/handlers/turn.rs index 49184d73..04900b7b 100644 --- a/crates/server/src/runtime/handlers/turn.rs +++ b/crates/server/src/runtime/handlers/turn.rs @@ -790,6 +790,8 @@ impl ServerRuntime { status = ?interrupted_turn.status, "interrupted turn" ); + self.finalize_turn_workspace_changes(params.session_id, &interrupted_turn) + .await; self.broadcast_event(ServerEvent::TurnInterrupted(TurnEventPayload { session_id: params.session_id, turn: interrupted_turn.clone(), diff --git a/crates/server/src/runtime/handlers/workspace_changes.rs b/crates/server/src/runtime/handlers/workspace_changes.rs new file mode 100644 index 00000000..1b4fb199 --- /dev/null +++ b/crates/server/src/runtime/handlers/workspace_changes.rs @@ -0,0 +1,152 @@ +use super::super::*; + +impl ServerRuntime { + pub(crate) async fn handle_workspace_changes_read( + self: &Arc, + request_id: serde_json::Value, + params: serde_json::Value, + ) -> serde_json::Value { + let params: WorkspaceChangesReadParams = match serde_json::from_value(params) { + Ok(params) => params, + Err(error) => { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + format!("invalid workspace/changes/read params: {error}"), + ); + } + }; + if params.scopes.is_empty() { + return self.error_response( + request_id, + ProtocolErrorCode::InvalidParams, + "workspace/changes/read requires at least one scope", + ); + } + + let Some(session_arc) = self.sessions.lock().await.get(¶ms.session_id).cloned() else { + return self.error_response( + request_id, + ProtocolErrorCode::SessionNotFound, + "session does not exist", + ); + }; + let (cwd, active_turn_id, latest_turn_id) = { + let session = session_arc.lock().await; + ( + params + .cwd + .clone() + .unwrap_or_else(|| session.summary.cwd.clone()), + session.active_turn.as_ref().map(|turn| turn.turn_id), + session.latest_turn.as_ref().map(|turn| turn.turn_id), + ) + }; + + let mut views = Vec::with_capacity(params.scopes.len()); + for scope in params.scopes { + let view = match scope { + WorkspaceChangeScope::Branch => { + crate::workspace_changes::branch_view( + cwd.clone(), + params.base_branch.clone(), + params.diff_detail, + params.max_diff_bytes, + ) + .await + } + WorkspaceChangeScope::Uncommitted => { + crate::workspace_changes::uncommitted_view( + cwd.clone(), + params.diff_detail, + params.max_diff_bytes, + ) + .await + } + WorkspaceChangeScope::Turn => { + let turn_id = params.turn_id.or(active_turn_id).or(latest_turn_id); + match turn_id { + Some(turn_id) => { + self.read_turn_workspace_changes( + params.session_id, + turn_id, + cwd.clone(), + params.diff_detail, + params.max_diff_bytes, + ) + .await + } + None => crate::workspace_changes::unsupported_view( + WorkspaceChangeScope::Turn, + cwd.clone(), + WorkspaceChangeAttribution::WorkspaceNet, + "turn_id_not_available", + ), + } + } + }; + views.push(view); + } + + serde_json::to_value(SuccessResponse { + id: request_id, + result: WorkspaceChangesReadResult { views }, + }) + .expect("serialize workspace/changes/read response") + } + + async fn read_turn_workspace_changes( + &self, + session_id: SessionId, + turn_id: TurnId, + cwd: PathBuf, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, + ) -> WorkspaceChangeView { + if let Some(baseline) = self + .active_workspace_baselines + .lock() + .await + .get(&turn_id) + .cloned() + { + return match crate::workspace_changes::read_active_turn_view( + baseline, + diff_detail, + max_diff_bytes, + ) + .await + { + Ok(view) => view, + Err(error) => crate::workspace_changes::error_view( + WorkspaceChangeScope::Turn, + cwd, + WorkspaceChangeAttribution::WorkspaceNet, + error.to_string(), + ), + }; + } + + match crate::workspace_changes::read_finalized_turn_view( + self.metadata.server_home.as_path(), + session_id, + turn_id, + diff_detail, + max_diff_bytes, + ) { + Ok(Some(view)) => view, + Ok(None) => crate::workspace_changes::unsupported_view( + WorkspaceChangeScope::Turn, + cwd, + WorkspaceChangeAttribution::WorkspaceNet, + "turn_baseline_not_available", + ), + Err(error) => crate::workspace_changes::error_view( + WorkspaceChangeScope::Turn, + cwd, + WorkspaceChangeAttribution::WorkspaceNet, + error.to_string(), + ), + } + } +} diff --git a/crates/server/src/runtime/research.rs b/crates/server/src/runtime/research.rs index a21e19b0..ed417ab9 100644 --- a/crates/server/src/runtime/research.rs +++ b/crates/server/src/runtime/research.rs @@ -631,6 +631,8 @@ impl ServerRuntime { question, cwd, } = input; + self.capture_turn_workspace_baseline(session_id, turn.turn_id, PathBuf::from(cwd.clone())) + .await; let usage_ledger = self.research_usage_ledger(session_id).await; let result = self .run_research_pipeline( @@ -2334,6 +2336,8 @@ impl ServerRuntime { { tracing::warn!(session_id = %session_id, error = %error, "failed to persist research turn finish"); } + self.finalize_turn_workspace_changes(session_id, &turn) + .await; match status { TurnStatus::Completed => { self.broadcast_event(ServerEvent::TurnCompleted(TurnEventPayload { diff --git a/crates/server/src/runtime/turn_exec.rs b/crates/server/src/runtime/turn_exec.rs index 215eb2d8..9a3a2473 100644 --- a/crates/server/src/runtime/turn_exec.rs +++ b/crates/server/src/runtime/turn_exec.rs @@ -925,6 +925,8 @@ impl ServerRuntime { command: String, cwd: std::path::PathBuf, ) { + self.capture_turn_workspace_baseline(session_id, turn.turn_id, cwd.clone()) + .await; if let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() { session_arc.lock().await.turn_approval_cache = crate::execution::ApprovalGrantCache::default(); @@ -1106,6 +1108,8 @@ impl ServerRuntime { { tracing::warn!(session_id = %session_id, error = %error, "failed to persist shell command turn line"); } + self.finalize_turn_workspace_changes(session_id, &final_turn) + .await; if is_error { self.broadcast_event(ServerEvent::TurnFailed(TurnEventPayload { session_id, @@ -1140,6 +1144,17 @@ impl ServerRuntime { collaboration_mode, input_mode, } = request; + let baseline_cwd = { + let session_arc = self.sessions.lock().await.get(&session_id).cloned(); + match session_arc { + Some(session_arc) => Some(session_arc.lock().await.summary.cwd.clone()), + None => None, + } + }; + if let Some(cwd) = baseline_cwd { + self.capture_turn_workspace_baseline(session_id, turn.turn_id, cwd) + .await; + } if let Some(session_arc) = self.sessions.lock().await.get(&session_id).cloned() { session_arc.lock().await.turn_approval_cache = crate::execution::ApprovalGrantCache::default(); @@ -2268,6 +2283,8 @@ impl ServerRuntime { { tracing::warn!(session_id = %session_id, error = %error, "failed to persist terminal turn line"); } + self.finalize_turn_workspace_changes(session_id, &final_turn) + .await; // Emit the terminal result before we look at queued follow-up input. if let Err(error) = result { tracing::warn!( diff --git a/crates/server/src/runtime/workspace_baseline.rs b/crates/server/src/runtime/workspace_baseline.rs new file mode 100644 index 00000000..fccd07a7 --- /dev/null +++ b/crates/server/src/runtime/workspace_baseline.rs @@ -0,0 +1,118 @@ +use super::*; + +impl ServerRuntime { + pub(super) async fn capture_turn_workspace_baseline( + self: &Arc, + session_id: SessionId, + turn_id: TurnId, + cwd: PathBuf, + ) { + match crate::workspace_changes::capture_baseline( + self.metadata.server_home.clone(), + session_id, + turn_id, + cwd, + ) + .await + { + Ok(captured) => { + self.active_workspace_baselines + .lock() + .await + .insert(turn_id, captured.baseline); + let record = { + let session_arc = self.sessions.lock().await.get(&session_id).cloned(); + match session_arc { + Some(session_arc) => session_arc.lock().await.record.clone(), + None => None, + } + }; + if let Some(record) = record + && let Err(error) = self + .rollout_store + .append_workspace_checkpoint_recorded(&record, captured.record) + { + tracing::warn!( + session_id = %session_id, + turn_id = %turn_id, + error = %error, + "failed to persist workspace checkpoint record" + ); + } + } + Err(error) => { + tracing::warn!( + session_id = %session_id, + turn_id = %turn_id, + error = %error, + "failed to capture workspace baseline" + ); + } + } + } + + pub(super) async fn finalize_turn_workspace_changes( + self: &Arc, + session_id: SessionId, + turn: &TurnMetadata, + ) { + let Some(baseline) = self + .active_workspace_baselines + .lock() + .await + .remove(&turn.turn_id) + else { + return; + }; + match crate::workspace_changes::finalize_baseline( + self.metadata.server_home.clone(), + baseline, + ) + .await + { + Ok(finalized) => { + let record = { + let session_arc = self.sessions.lock().await.get(&session_id).cloned(); + match session_arc { + Some(session_arc) => session_arc.lock().await.record.clone(), + None => None, + } + }; + if let Some(record) = record + && let Err(error) = self + .rollout_store + .append_workspace_change_recorded(&record, finalized.record) + { + tracing::warn!( + session_id = %session_id, + turn_id = %turn.turn_id, + error = %error, + "failed to persist workspace change record" + ); + } + self.broadcast_event(ServerEvent::WorkspaceChangesUpdated( + WorkspaceChangesUpdatedPayload { + session_id, + turn_id: turn.turn_id, + scope: WorkspaceChangeScope::Turn, + status: finalized.view.status, + coverage: finalized.view.coverage, + change_set_status: finalized.view.change_set_status, + stats: finalized.view.stats, + version: Utc::now().timestamp_millis().max(0) as u64, + generated_at: Utc::now(), + }, + )) + .await; + } + Err(error) => { + tracing::warn!( + session_id = %session_id, + turn_id = %turn.turn_id, + error = %error, + "failed to finalize workspace changes" + ); + } + } + } +} diff --git a/crates/server/src/workspace_changes/diff.rs b/crates/server/src/workspace_changes/diff.rs new file mode 100644 index 00000000..cbeee9b7 --- /dev/null +++ b/crates/server/src/workspace_changes/diff.rs @@ -0,0 +1,224 @@ +use std::path::PathBuf; + +use devo_core::ChangeSetCoverage; +use devo_protocol::{ + WorkspaceChangeAttribution, WorkspaceChangeCoverage, WorkspaceChangeScope, + WorkspaceChangeSetStatus, WorkspaceChangeStats, WorkspaceChangeView, WorkspaceChangeViewStatus, + WorkspaceChangedFile, WorkspaceChangedFileStatus, WorkspaceDiffDetail, +}; + +pub(super) struct DiffViewInput { + pub scope: WorkspaceChangeScope, + pub workspace_root: PathBuf, + pub base: Option, + pub attribution: WorkspaceChangeAttribution, + pub coverage: WorkspaceChangeCoverage, + pub change_set_status: WorkspaceChangeSetStatus, + pub diff: String, + pub warnings: Vec, + pub diff_detail: WorkspaceDiffDetail, + pub max_diff_bytes: Option, +} + +pub(super) fn view_from_diff(input: DiffViewInput) -> WorkspaceChangeView { + let (files, stats) = files_from_diff(&input.diff); + let status = if files.is_empty() { + WorkspaceChangeViewStatus::Empty + } else if matches!(input.coverage, WorkspaceChangeCoverage::Partial) + || !input.warnings.is_empty() + { + WorkspaceChangeViewStatus::Partial + } else { + WorkspaceChangeViewStatus::Ready + }; + let mut view = WorkspaceChangeView { + scope: input.scope, + status, + workspace_root: input.workspace_root, + base: input.base, + coverage: input.coverage, + attribution: input.attribution, + change_set_status: input.change_set_status, + files, + stats, + unified_diff: Some(input.diff), + warnings: input.warnings, + generated_at: chrono::Utc::now(), + }; + apply_diff_detail(&mut view, input.diff_detail, input.max_diff_bytes); + view +} + +pub(super) fn apply_diff_detail( + view: &mut WorkspaceChangeView, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, +) { + if !matches!(diff_detail, WorkspaceDiffDetail::Full) { + view.unified_diff = None; + return; + } + let Some(diff) = view.unified_diff.as_mut() else { + return; + }; + let max = max_diff_bytes.unwrap_or(2 * 1024 * 1024) as usize; + if diff.len() > max { + diff.truncate(max); + view.warnings.push("diff_truncated".to_string()); + for file in &mut view.files { + file.diff_truncated = true; + } + if view.status == WorkspaceChangeViewStatus::Ready { + view.status = WorkspaceChangeViewStatus::Partial; + } + } +} + +pub(crate) fn unsupported_view( + scope: WorkspaceChangeScope, + workspace_root: PathBuf, + attribution: WorkspaceChangeAttribution, + reason: &str, +) -> WorkspaceChangeView { + WorkspaceChangeView { + scope, + status: WorkspaceChangeViewStatus::Unsupported, + workspace_root, + base: None, + coverage: WorkspaceChangeCoverage::None, + attribution, + change_set_status: WorkspaceChangeSetStatus::Finalized, + files: Vec::new(), + stats: WorkspaceChangeStats::default(), + unified_diff: None, + warnings: vec![reason.to_string()], + generated_at: chrono::Utc::now(), + } +} + +pub(crate) fn error_view( + scope: WorkspaceChangeScope, + workspace_root: PathBuf, + attribution: WorkspaceChangeAttribution, + error: String, +) -> WorkspaceChangeView { + WorkspaceChangeView { + scope, + status: WorkspaceChangeViewStatus::Error, + workspace_root, + base: None, + coverage: WorkspaceChangeCoverage::None, + attribution, + change_set_status: WorkspaceChangeSetStatus::Finalized, + files: Vec::new(), + stats: WorkspaceChangeStats::default(), + unified_diff: None, + warnings: vec![error], + generated_at: chrono::Utc::now(), + } +} + +pub(super) fn coverage_to_change_set(value: WorkspaceChangeCoverage) -> ChangeSetCoverage { + match value { + WorkspaceChangeCoverage::Full + | WorkspaceChangeCoverage::GitVisible + | WorkspaceChangeCoverage::BoundedFilesystem => ChangeSetCoverage::Full, + WorkspaceChangeCoverage::Partial => ChangeSetCoverage::Partial, + WorkspaceChangeCoverage::None => ChangeSetCoverage::None, + } +} + +fn files_from_diff(diff: &str) -> (Vec, WorkspaceChangeStats) { + let mut files = Vec::new(); + let mut current: Option = None; + let mut stats = WorkspaceChangeStats::default(); + for line in diff.lines() { + if let Some(path) = line + .strip_prefix("diff --git ") + .and_then(parse_diff_git_path) + { + if let Some(file) = current.take() { + files.push(file); + } + current = Some(WorkspaceChangedFile { + path, + status: WorkspaceChangedFileStatus::Modified, + additions: Some(0), + deletions: Some(0), + binary: false, + diff_truncated: false, + }); + continue; + } + let Some(file) = current.as_mut() else { + continue; + }; + if line.starts_with("new file mode") { + file.status = WorkspaceChangedFileStatus::Added; + } else if line.starts_with("deleted file mode") { + file.status = WorkspaceChangedFileStatus::Deleted; + } else if line.starts_with("rename from ") || line.starts_with("rename to ") { + file.status = WorkspaceChangedFileStatus::Renamed; + } else if line.starts_with("Binary files ") { + file.binary = true; + } else if line.starts_with('+') && !line.starts_with("+++") { + let additions = file.additions.get_or_insert(0); + *additions += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + let deletions = file.deletions.get_or_insert(0); + *deletions += 1; + } + } + if let Some(file) = current { + files.push(file); + } + for file in &files { + stats.files_changed += 1; + stats.additions += file.additions.unwrap_or_default(); + stats.deletions += file.deletions.unwrap_or_default(); + } + (files, stats) +} + +fn parse_diff_git_path(rest: &str) -> Option { + let (_, b_path) = rest.rsplit_once(" b/")?; + Some(PathBuf::from(unquote_git_path(b_path))) +} + +fn unquote_git_path(path: &str) -> String { + path.trim_matches('"').replace("\\\"", "\"") +} + +pub(super) fn text_file_diff(path: &str, before: Option<&str>, after: Option<&str>) -> String { + let before = before.unwrap_or_default(); + let after = after.unwrap_or_default(); + if before == after { + return String::new(); + } + let patch = diffy::create_patch(before, after); + let patch_text = diffy::PatchFormatter::new().fmt_patch(&patch).to_string(); + let old_path = if before.is_empty() { + "/dev/null".to_string() + } else { + format!("a/{path}") + }; + let new_path = if after.is_empty() { + "/dev/null".to_string() + } else { + format!("b/{path}") + }; + format!("diff --git a/{path} b/{path}\n--- {old_path}\n+++ {new_path}\n{patch_text}") +} + +pub(super) fn count_diff_lines(diff: &str) -> (u64, u64) { + let mut additions = 0; + let mut deletions = 0; + for line in diff.lines() { + if line.starts_with('+') && !line.starts_with("+++") { + additions += 1; + } else if line.starts_with('-') && !line.starts_with("---") { + deletions += 1; + } + } + (additions, deletions) +} diff --git a/crates/server/src/workspace_changes/fs_snapshot.rs b/crates/server/src/workspace_changes/fs_snapshot.rs new file mode 100644 index 00000000..197fd373 --- /dev/null +++ b/crates/server/src/workspace_changes/fs_snapshot.rs @@ -0,0 +1,395 @@ +use std::collections::{BTreeMap, BTreeSet}; +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::Result; +use devo_core::ChangeSetCoverage; +use devo_protocol::{ + SessionId, TurnId, WorkspaceChangeAttribution, WorkspaceChangeBase, WorkspaceChangeCoverage, + WorkspaceChangeScope, WorkspaceChangeSetStatus, WorkspaceChangeStats, WorkspaceChangeView, + WorkspaceChangeViewStatus, WorkspaceChangedFile, WorkspaceChangedFileStatus, + WorkspaceCheckpointBackend, WorkspaceDiffDetail, +}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +use super::{ActiveWorkspaceBaseline, CapturedWorkspaceBaseline}; +use super::{CheckpointRecordInput, artifact_ref, checkpoint_record, write_json}; +use crate::workspace_changes::diff::{apply_diff_detail, count_diff_lines, text_file_diff}; + +const FS_MAX_FILES: usize = 10_000; +const FS_MAX_SNAPSHOT_BYTES: u64 = 64 * 1024 * 1024; +const FS_MAX_TEXT_FILE_BYTES: u64 = 2 * 1024 * 1024; +const FS_MAX_HASH_FILE_BYTES: u64 = 10 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub(crate) struct FileWorkspaceBaseline { + pub session_id: SessionId, + pub turn_id: TurnId, + pub workspace_root: PathBuf, + pub checkpoint_id: String, + manifest: FileManifest, + pub warnings: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FileManifest { + root: PathBuf, + entries: BTreeMap, + warnings: Vec, + scanned_files: usize, + captured_bytes: u64, +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +struct FileManifestEntry { + kind: FileEntryKind, + size: u64, + modified_ms: Option, + hash: Option, + text_content: Option, + link_target: Option, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +enum FileEntryKind { + File, + Directory, + Symlink, + Other, + Unreadable, +} + +pub(crate) fn capture_file_baseline( + artifact_dir: &Path, + session_id: SessionId, + turn_id: TurnId, + cwd: &Path, +) -> Result { + let workspace_root = cwd.to_path_buf(); + let manifest = scan_file_manifest(cwd); + let checkpoint_id = format!("fs-{}", Uuid::new_v4()); + let artifact_ref = artifact_ref(session_id, turn_id, "baseline.json"); + write_json(&artifact_dir.join("baseline.json"), &manifest)?; + let mut warnings = manifest.warnings.clone(); + warnings.sort(); + warnings.dedup(); + let coverage = if warnings.is_empty() { + ChangeSetCoverage::Full + } else { + ChangeSetCoverage::Partial + }; + let baseline = FileWorkspaceBaseline { + session_id, + turn_id, + workspace_root, + checkpoint_id, + manifest, + warnings, + }; + Ok(CapturedWorkspaceBaseline { + record: checkpoint_record(CheckpointRecordInput { + session_id, + turn_id, + checkpoint_id: &baseline.checkpoint_id, + workspace_root: &baseline.workspace_root, + backend: "file_manifest", + coverage, + warnings: baseline.warnings.clone(), + artifact_ref: Some(artifact_ref), + }), + baseline: ActiveWorkspaceBaseline::File(baseline), + }) +} + +pub(crate) fn diff_file_baseline( + baseline: &FileWorkspaceBaseline, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, + change_set_status: WorkspaceChangeSetStatus, +) -> WorkspaceChangeView { + let current = scan_file_manifest(&baseline.workspace_root); + let mut diff = String::new(); + let mut files = Vec::new(); + let mut stats = WorkspaceChangeStats::default(); + let mut paths = BTreeSet::new(); + paths.extend(baseline.manifest.entries.keys().cloned()); + paths.extend(current.entries.keys().cloned()); + + for path in paths { + let before = baseline.manifest.entries.get(&path); + let after = current.entries.get(&path); + let status = match (before, after) { + (None, Some(_)) => WorkspaceChangedFileStatus::Added, + (Some(_), None) => WorkspaceChangedFileStatus::Deleted, + (Some(before), Some(after)) if before.kind != after.kind => { + WorkspaceChangedFileStatus::TypeChanged + } + (Some(before), Some(after)) + if before.hash != after.hash || before.size != after.size => + { + WorkspaceChangedFileStatus::Modified + } + _ => continue, + }; + let before_text = before.and_then(|entry| entry.text_content.as_deref()); + let after_text = after.and_then(|entry| entry.text_content.as_deref()); + let file_diff = match (before_text, after_text) { + (Some(before), Some(after)) => text_file_diff(&path, Some(before), Some(after)), + (None, Some(after)) if before.is_none() => text_file_diff(&path, None, Some(after)), + (Some(before), None) if after.is_none() => text_file_diff(&path, Some(before), None), + _ => String::new(), + }; + let (additions, deletions) = count_diff_lines(&file_diff); + stats.files_changed += 1; + stats.additions += additions; + stats.deletions += deletions; + let binary = file_diff.is_empty() + && before + .or(after) + .is_some_and(|entry| entry.kind == FileEntryKind::File); + files.push(WorkspaceChangedFile { + path: PathBuf::from(&path), + status, + additions: Some(additions), + deletions: Some(deletions), + binary, + diff_truncated: false, + }); + diff.push_str(&file_diff); + } + + let mut warnings = baseline.warnings.clone(); + warnings.extend(current.warnings.clone()); + warnings.sort(); + warnings.dedup(); + let coverage = if warnings.is_empty() { + WorkspaceChangeCoverage::BoundedFilesystem + } else { + WorkspaceChangeCoverage::Partial + }; + let mut view = WorkspaceChangeView { + scope: WorkspaceChangeScope::Turn, + status: if files.is_empty() { + WorkspaceChangeViewStatus::Empty + } else if warnings.is_empty() { + WorkspaceChangeViewStatus::Ready + } else { + WorkspaceChangeViewStatus::Partial + }, + workspace_root: baseline.workspace_root.clone(), + base: Some(WorkspaceChangeBase::TurnCheckpoint { + turn_id: baseline.turn_id, + checkpoint_id: baseline.checkpoint_id.clone(), + backend: WorkspaceCheckpointBackend::FileManifest, + }), + coverage, + attribution: WorkspaceChangeAttribution::WorkspaceNet, + change_set_status, + files, + stats, + unified_diff: Some(diff), + warnings, + generated_at: chrono::Utc::now(), + }; + apply_diff_detail(&mut view, diff_detail, max_diff_bytes); + view +} + +fn scan_file_manifest(root: &Path) -> FileManifest { + let mut manifest = FileManifest { + root: root.to_path_buf(), + entries: BTreeMap::new(), + warnings: Vec::new(), + scanned_files: 0, + captured_bytes: 0, + }; + scan_dir(root, root, &mut manifest); + manifest.warnings.sort(); + manifest.warnings.dedup(); + manifest +} + +fn scan_dir(root: &Path, dir: &Path, manifest: &mut FileManifest) { + let read_dir = match fs::read_dir(dir) { + Ok(read_dir) => read_dir, + Err(error) => { + manifest.warnings.push(format!( + "read_dir_failed: {}: {error}", + relative_path(root, dir) + )); + return; + } + }; + for entry in read_dir { + if manifest.scanned_files >= FS_MAX_FILES { + manifest.warnings.push("max_files_exceeded".to_string()); + return; + } + let Ok(entry) = entry else { + manifest.warnings.push("read_dir_entry_failed".to_string()); + continue; + }; + scan_path(root, entry.path(), manifest); + } +} + +fn scan_path(root: &Path, path: PathBuf, manifest: &mut FileManifest) { + let rel = relative_path(root, &path); + let metadata = match fs::symlink_metadata(&path) { + Ok(metadata) => metadata, + Err(error) => { + manifest.entries.insert( + rel.clone(), + FileManifestEntry { + kind: FileEntryKind::Unreadable, + size: 0, + modified_ms: None, + hash: None, + text_content: None, + link_target: None, + }, + ); + manifest + .warnings + .push(format!("metadata_failed: {rel}: {error}")); + return; + } + }; + let modified_ms = metadata.modified().ok().and_then(|modified| { + modified + .duration_since(std::time::UNIX_EPOCH) + .ok() + .map(|duration| duration.as_millis() as i64) + }); + if metadata.is_dir() { + manifest.entries.insert( + rel, + FileManifestEntry { + kind: FileEntryKind::Directory, + size: 0, + modified_ms, + hash: None, + text_content: None, + link_target: None, + }, + ); + scan_dir(root, &path, manifest); + } else if metadata.file_type().is_symlink() { + manifest.entries.insert( + rel, + FileManifestEntry { + kind: FileEntryKind::Symlink, + size: 0, + modified_ms, + hash: None, + text_content: None, + link_target: fs::read_link(&path) + .ok() + .map(|target| target.display().to_string()), + }, + ); + } else if metadata.is_file() { + manifest.scanned_files += 1; + let entry = file_manifest_entry(&path, &rel, metadata.len(), modified_ms, manifest); + manifest.entries.insert(rel, entry); + } else { + manifest.entries.insert( + rel, + FileManifestEntry { + kind: FileEntryKind::Other, + size: metadata.len(), + modified_ms, + hash: None, + text_content: None, + link_target: None, + }, + ); + } +} + +fn file_manifest_entry( + path: &Path, + rel: &str, + size: u64, + modified_ms: Option, + manifest: &mut FileManifest, +) -> FileManifestEntry { + if size > FS_MAX_HASH_FILE_BYTES { + manifest + .warnings + .push(format!("large_file_without_hash: {rel}")); + return metadata_only_file(size, modified_ms); + } + let bytes = match fs::read(path) { + Ok(bytes) => bytes, + Err(error) => { + manifest + .warnings + .push(format!("read_file_failed: {rel}: {error}")); + return FileManifestEntry { + kind: FileEntryKind::Unreadable, + size, + modified_ms, + hash: None, + text_content: None, + link_target: None, + }; + } + }; + manifest.captured_bytes += bytes.len() as u64; + if manifest.captured_bytes > FS_MAX_SNAPSHOT_BYTES { + manifest + .warnings + .push("snapshot_bytes_exceeded".to_string()); + } + let text_content = if size <= FS_MAX_TEXT_FILE_BYTES + && !bytes.contains(&0) + && manifest.captured_bytes <= FS_MAX_SNAPSHOT_BYTES + { + String::from_utf8(bytes.clone()).ok() + } else { + if size > FS_MAX_TEXT_FILE_BYTES { + manifest + .warnings + .push(format!("large_file_without_text_diff: {rel}")); + } + None + }; + FileManifestEntry { + kind: FileEntryKind::File, + size, + modified_ms, + hash: Some(hash_bytes(&bytes)), + text_content, + link_target: None, + } +} + +fn metadata_only_file(size: u64, modified_ms: Option) -> FileManifestEntry { + FileManifestEntry { + kind: FileEntryKind::File, + size, + modified_ms, + hash: None, + text_content: None, + link_target: None, + } +} + +fn relative_path(root: &Path, path: &Path) -> String { + path.strip_prefix(root) + .unwrap_or(path) + .components() + .map(|component| component.as_os_str().to_string_lossy()) + .collect::>() + .join("/") +} + +fn hash_bytes(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + format!("sha256:{:x}", hasher.finalize()) +} diff --git a/crates/server/src/workspace_changes/git.rs b/crates/server/src/workspace_changes/git.rs new file mode 100644 index 00000000..9b28caf8 --- /dev/null +++ b/crates/server/src/workspace_changes/git.rs @@ -0,0 +1,358 @@ +use std::path::{Path, PathBuf}; +use std::process::Output; + +use anyhow::{Context, Result}; +use devo_core::ChangeSetCoverage; +use devo_protocol::{ + SessionId, TurnId, WorkspaceChangeAttribution, WorkspaceChangeBase, WorkspaceChangeCoverage, + WorkspaceChangeScope, WorkspaceChangeSetStatus, WorkspaceChangeView, + WorkspaceCheckpointBackend, WorkspaceDiffDetail, +}; +use devo_util_git::{ + CreateGhostCommitOptions, GhostCommit, GhostSnapshotReport, create_ghost_commit_with_report, + default_branch_name, diff_ghost_commits, get_git_repo_root, merge_base_with_head, +}; +use tokio::process::Command; + +use super::{ActiveWorkspaceBaseline, CapturedWorkspaceBaseline}; +use super::{CheckpointRecordInput, artifact_ref, checkpoint_record, write_json}; +use crate::workspace_changes::diff::{DiffViewInput, error_view, unsupported_view, view_from_diff}; + +#[derive(Debug, Clone)] +pub(crate) struct GitWorkspaceBaseline { + pub session_id: SessionId, + pub turn_id: TurnId, + pub workspace_root: PathBuf, + pub checkpoint_id: String, + ghost: GhostCommit, + warnings: Vec, +} + +pub(crate) fn capture_git_baseline( + artifact_dir: &Path, + session_id: SessionId, + turn_id: TurnId, + repo_root: &Path, +) -> Result { + let (ghost, report) = create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(repo_root) + .message("devo turn workspace baseline") + .ignore_large_untracked_files(10 * 1024 * 1024), + ) + .with_context(|| format!("create git ghost baseline at {}", repo_root.display()))?; + let warnings = ghost_report_warnings(&report); + let checkpoint_id = ghost.id().to_string(); + let artifact_ref = artifact_ref(session_id, turn_id, "checkpoint.json"); + let baseline = GitWorkspaceBaseline { + session_id, + turn_id, + workspace_root: repo_root.to_path_buf(), + checkpoint_id: checkpoint_id.clone(), + ghost, + warnings: warnings.clone(), + }; + write_json( + &artifact_dir.join("checkpoint.json"), + &serde_json::json!({ + "schema_version": 1, + "backend": "git_ghost_commit", + "checkpoint_id": checkpoint_id, + "workspace_root": repo_root, + "warnings": warnings, + }), + )?; + Ok(CapturedWorkspaceBaseline { + record: checkpoint_record(CheckpointRecordInput { + session_id, + turn_id, + checkpoint_id: &baseline.checkpoint_id, + workspace_root: &baseline.workspace_root, + backend: "git_ghost_commit", + coverage: ChangeSetCoverage::Full, + warnings: baseline.warnings.clone(), + artifact_ref: Some(artifact_ref), + }), + baseline: ActiveWorkspaceBaseline::Git(baseline), + }) +} + +pub(crate) fn diff_git_baseline( + baseline: &GitWorkspaceBaseline, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, + change_set_status: WorkspaceChangeSetStatus, +) -> Result { + let (current, report) = create_ghost_commit_with_report( + &CreateGhostCommitOptions::new(baseline.workspace_root.as_path()) + .message("devo turn workspace current") + .ignore_large_untracked_files(10 * 1024 * 1024), + )?; + let diff = diff_ghost_commits(baseline.workspace_root.as_path(), &baseline.ghost, ¤t)?; + let mut warnings = baseline.warnings.clone(); + warnings.extend(ghost_report_warnings(&report)); + warnings.sort(); + warnings.dedup(); + Ok(view_from_diff(DiffViewInput { + scope: WorkspaceChangeScope::Turn, + workspace_root: baseline.workspace_root.clone(), + base: Some(WorkspaceChangeBase::TurnCheckpoint { + turn_id: baseline.turn_id, + checkpoint_id: baseline.checkpoint_id.clone(), + backend: WorkspaceCheckpointBackend::GitGhostCommit, + }), + attribution: WorkspaceChangeAttribution::WorkspaceNet, + coverage: if warnings.is_empty() { + WorkspaceChangeCoverage::GitVisible + } else { + WorkspaceChangeCoverage::Partial + }, + change_set_status, + diff, + warnings, + diff_detail, + max_diff_bytes, + })) +} + +pub(crate) async fn branch_view( + cwd: PathBuf, + base_branch: Option, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, +) -> WorkspaceChangeView { + let Some(repo_root) = get_git_repo_root(&cwd) else { + return unsupported_view( + WorkspaceChangeScope::Branch, + cwd, + WorkspaceChangeAttribution::GitBranch, + "not_git_repository", + ); + }; + let base_branch = match base_branch { + Some(branch) => branch, + None => default_branch_name(repo_root.as_path()) + .await + .unwrap_or_else(|| "main".to_string()), + }; + let merge_base = match merge_base_with_head(repo_root.as_path(), &base_branch) { + Ok(Some(merge_base)) => merge_base, + Ok(None) => { + return unsupported_view( + WorkspaceChangeScope::Branch, + repo_root, + WorkspaceChangeAttribution::GitBranch, + "base_branch_not_found_or_no_head", + ); + } + Err(error) => { + return error_view( + WorkspaceChangeScope::Branch, + repo_root, + WorkspaceChangeAttribution::GitBranch, + error.to_string(), + ); + } + }; + let head = match git_stdout(&repo_root, &["rev-parse", "HEAD"]).await { + Ok(head) => head, + Err(error) => { + return error_view( + WorkspaceChangeScope::Branch, + repo_root, + WorkspaceChangeAttribution::GitBranch, + error, + ); + } + }; + let diff = match git_stdout( + &repo_root, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--binary", + &merge_base, + "HEAD", + "--", + ], + ) + .await + { + Ok(diff) => diff, + Err(error) => { + return error_view( + WorkspaceChangeScope::Branch, + repo_root, + WorkspaceChangeAttribution::GitBranch, + error, + ); + } + }; + view_from_diff(DiffViewInput { + scope: WorkspaceChangeScope::Branch, + workspace_root: repo_root, + base: Some(WorkspaceChangeBase::Branch { + base_branch, + merge_base, + head, + }), + attribution: WorkspaceChangeAttribution::GitBranch, + coverage: WorkspaceChangeCoverage::GitVisible, + change_set_status: WorkspaceChangeSetStatus::Finalized, + diff, + warnings: Vec::new(), + diff_detail, + max_diff_bytes, + }) +} + +pub(crate) async fn uncommitted_view( + cwd: PathBuf, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, +) -> WorkspaceChangeView { + let Some(repo_root) = get_git_repo_root(&cwd) else { + return unsupported_view( + WorkspaceChangeScope::Uncommitted, + cwd, + WorkspaceChangeAttribution::GitWorkingTree, + "not_git_repository", + ); + }; + let head = git_stdout(&repo_root, &["rev-parse", "--verify", "HEAD"]) + .await + .ok(); + let Some(head_ref) = head.clone() else { + return unsupported_view( + WorkspaceChangeScope::Uncommitted, + repo_root, + WorkspaceChangeAttribution::GitWorkingTree, + "no_head", + ); + }; + let mut diff = match git_stdout( + &repo_root, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--binary", + "HEAD", + "--", + ], + ) + .await + { + Ok(diff) => diff, + Err(error) => { + return error_view( + WorkspaceChangeScope::Uncommitted, + repo_root, + WorkspaceChangeAttribution::GitWorkingTree, + error, + ); + } + }; + match git_stdout(&repo_root, &["ls-files", "--others", "--exclude-standard"]).await { + Ok(paths) => { + for path in paths.lines().filter(|line| !line.trim().is_empty()) { + if let Ok(extra) = git_stdout_allow_diff_exit( + &repo_root, + &[ + "diff", + "--no-textconv", + "--no-ext-diff", + "--binary", + "--no-index", + "--", + null_device(), + path, + ], + ) + .await + { + diff.push_str(&extra); + } + } + } + Err(error) => { + return error_view( + WorkspaceChangeScope::Uncommitted, + repo_root, + WorkspaceChangeAttribution::GitWorkingTree, + error, + ); + } + } + view_from_diff(DiffViewInput { + scope: WorkspaceChangeScope::Uncommitted, + workspace_root: repo_root, + base: Some(WorkspaceChangeBase::Head { + head: Some(head_ref), + }), + attribution: WorkspaceChangeAttribution::GitWorkingTree, + coverage: WorkspaceChangeCoverage::GitVisible, + change_set_status: WorkspaceChangeSetStatus::Accumulating, + diff, + warnings: Vec::new(), + diff_detail, + max_diff_bytes, + }) +} + +async fn git_stdout(cwd: &Path, args: &[&str]) -> std::result::Result { + let output = git_output(cwd, args).await?; + if output.status.success() { + String::from_utf8(output.stdout) + .map(|value| value.trim().to_string()) + .map_err(|error| error.to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +async fn git_stdout_allow_diff_exit( + cwd: &Path, + args: &[&str], +) -> std::result::Result { + let output = git_output(cwd, args).await?; + if output.status.success() || output.status.code() == Some(1) { + String::from_utf8(output.stdout).map_err(|error| error.to_string()) + } else { + Err(String::from_utf8_lossy(&output.stderr).trim().to_string()) + } +} + +async fn git_output(cwd: &Path, args: &[&str]) -> std::result::Result { + Command::new("git") + .env("GIT_OPTIONAL_LOCKS", "0") + .args(args) + .current_dir(cwd) + .kill_on_drop(true) + .output() + .await + .map_err(|error| error.to_string()) +} + +fn ghost_report_warnings(report: &GhostSnapshotReport) -> Vec { + let mut warnings = Vec::new(); + for file in &report.ignored_untracked_files { + warnings.push(format!( + "large_untracked_file_excluded: {} ({} bytes)", + file.path.display(), + file.byte_size + )); + } + for dir in &report.large_untracked_dirs { + warnings.push(format!( + "large_untracked_dir_excluded: {} ({} files)", + dir.path.display(), + dir.file_count + )); + } + warnings +} + +fn null_device() -> &'static str { + if cfg!(windows) { "NUL" } else { "/dev/null" } +} diff --git a/crates/server/src/workspace_changes/mod.rs b/crates/server/src/workspace_changes/mod.rs new file mode 100644 index 00000000..db982e69 --- /dev/null +++ b/crates/server/src/workspace_changes/mod.rs @@ -0,0 +1,408 @@ +use std::fs; +use std::path::{Path, PathBuf}; + +use anyhow::{Context, Result}; +use chrono::Utc; +use devo_core::{ + ChangeSetCoverage, ChangeSetStatus, TurnWorkspaceChangeRecordedRecord, + TurnWorkspaceCheckpointRecordedRecord, +}; +use devo_protocol::{ + SessionId, TurnId, WorkspaceChangeSetStatus, WorkspaceChangeView, WorkspaceDiffDetail, +}; +use devo_util_git::get_git_repo_root; +use serde::{Deserialize, Serialize}; +use sha2::{Digest, Sha256}; +use uuid::Uuid; + +mod diff; +mod fs_snapshot; +mod git; + +pub(crate) use diff::{error_view, unsupported_view}; +pub(crate) use git::{branch_view, uncommitted_view}; + +const DEFAULT_MAX_DIFF_BYTES: u64 = 2 * 1024 * 1024; + +#[derive(Debug, Clone)] +pub(crate) enum ActiveWorkspaceBaseline { + Git(git::GitWorkspaceBaseline), + File(fs_snapshot::FileWorkspaceBaseline), +} + +#[derive(Debug, Clone)] +pub(crate) struct CapturedWorkspaceBaseline { + pub baseline: ActiveWorkspaceBaseline, + pub record: TurnWorkspaceCheckpointRecordedRecord, +} + +#[derive(Debug, Clone)] +pub(crate) struct FinalizedWorkspaceChanges { + pub view: WorkspaceChangeView, + pub record: TurnWorkspaceChangeRecordedRecord, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FinalizedWorkspaceChangeArtifact { + schema_version: u32, + view: WorkspaceChangeView, +} + +pub(crate) async fn capture_baseline( + data_root: PathBuf, + session_id: SessionId, + turn_id: TurnId, + cwd: PathBuf, +) -> Result { + tokio::task::spawn_blocking(move || { + capture_baseline_blocking(data_root.as_path(), session_id, turn_id, cwd.as_path()) + }) + .await + .context("capture workspace baseline task failed")? +} + +fn capture_baseline_blocking( + data_root: &Path, + session_id: SessionId, + turn_id: TurnId, + cwd: &Path, +) -> Result { + let artifact_dir = artifact_dir(data_root, session_id, turn_id); + fs::create_dir_all(&artifact_dir) + .with_context(|| format!("create workspace snapshot dir {}", artifact_dir.display()))?; + + if let Some(repo_root) = get_git_repo_root(cwd) { + match git::capture_git_baseline(&artifact_dir, session_id, turn_id, repo_root.as_path()) { + Ok(captured) => return Ok(captured), + Err(error) => { + let mut captured = + fs_snapshot::capture_file_baseline(&artifact_dir, session_id, turn_id, cwd)?; + if let ActiveWorkspaceBaseline::File(baseline) = &mut captured.baseline { + baseline + .warnings + .push(format!("git_snapshot_unavailable: {error}")); + captured.record.warnings = baseline.warnings.clone(); + } + return Ok(captured); + } + } + } + + fs_snapshot::capture_file_baseline(&artifact_dir, session_id, turn_id, cwd) +} + +pub(crate) async fn finalize_baseline( + data_root: PathBuf, + baseline: ActiveWorkspaceBaseline, +) -> Result { + tokio::task::spawn_blocking(move || { + let session_id = baseline.session_id(); + let turn_id = baseline.turn_id(); + let artifact_dir = artifact_dir(data_root.as_path(), session_id, turn_id); + fs::create_dir_all(&artifact_dir)?; + let view = diff_baseline_blocking( + &baseline, + WorkspaceDiffDetail::Full, + Some(DEFAULT_MAX_DIFF_BYTES), + WorkspaceChangeSetStatus::Finalized, + )?; + let final_ref = artifact_ref(session_id, turn_id, "final.json"); + write_json( + &artifact_dir.join("final.json"), + &FinalizedWorkspaceChangeArtifact { + schema_version: 1, + view: view.clone(), + }, + )?; + let record = TurnWorkspaceChangeRecordedRecord { + schema_version: 1, + session_id, + turn_id, + change_id: Uuid::new_v4().to_string(), + file_path: ".".to_string(), + pre_hash: baseline.checkpoint_id().to_string(), + post_hash: hash_text(view.unified_diff.as_deref().unwrap_or_default()), + inverse_ref: None, + display_diff_ref: Some(final_ref.clone()), + workspace_root: Some(view.workspace_root.display().to_string()), + backend: Some(baseline.backend_name().to_string()), + coverage: Some(diff::coverage_to_change_set(view.coverage)), + warnings: view.warnings.clone(), + changed_files: view + .files + .iter() + .map(|file| file.path.display().to_string()) + .collect(), + artifact_ref: Some(final_ref), + change_set_status: Some(ChangeSetStatus::Finalized), + recorded_at: Utc::now(), + }; + Ok(FinalizedWorkspaceChanges { view, record }) + }) + .await + .context("finalize workspace baseline task failed")? +} + +pub(crate) async fn read_active_turn_view( + baseline: ActiveWorkspaceBaseline, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, +) -> Result { + tokio::task::spawn_blocking(move || { + diff_baseline_blocking( + &baseline, + diff_detail, + max_diff_bytes, + WorkspaceChangeSetStatus::Accumulating, + ) + }) + .await + .context("read active workspace changes task failed")? +} + +pub(crate) fn read_finalized_turn_view( + data_root: &Path, + session_id: SessionId, + turn_id: TurnId, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, +) -> Result> { + let path = artifact_dir(data_root, session_id, turn_id).join("final.json"); + if !path.exists() { + return Ok(None); + } + let text = fs::read_to_string(&path) + .with_context(|| format!("read workspace changes artifact {}", path.display()))?; + let artifact: FinalizedWorkspaceChangeArtifact = serde_json::from_str(&text) + .with_context(|| format!("parse workspace changes artifact {}", path.display()))?; + let mut view = artifact.view; + diff::apply_diff_detail(&mut view, diff_detail, max_diff_bytes); + Ok(Some(view)) +} + +fn diff_baseline_blocking( + baseline: &ActiveWorkspaceBaseline, + diff_detail: WorkspaceDiffDetail, + max_diff_bytes: Option, + change_set_status: WorkspaceChangeSetStatus, +) -> Result { + match baseline { + ActiveWorkspaceBaseline::Git(baseline) => { + git::diff_git_baseline(baseline, diff_detail, max_diff_bytes, change_set_status) + } + ActiveWorkspaceBaseline::File(baseline) => Ok(fs_snapshot::diff_file_baseline( + baseline, + diff_detail, + max_diff_bytes, + change_set_status, + )), + } +} + +pub(super) struct CheckpointRecordInput<'a> { + pub session_id: SessionId, + pub turn_id: TurnId, + pub checkpoint_id: &'a str, + pub workspace_root: &'a Path, + pub backend: &'a str, + pub coverage: ChangeSetCoverage, + pub warnings: Vec, + pub artifact_ref: Option, +} + +fn checkpoint_record(input: CheckpointRecordInput<'_>) -> TurnWorkspaceCheckpointRecordedRecord { + TurnWorkspaceCheckpointRecordedRecord { + schema_version: 1, + session_id: input.session_id, + turn_id: input.turn_id, + checkpoint_id: input.checkpoint_id.to_string(), + pre_turn_hash: input.checkpoint_id.to_string(), + files: Vec::new(), + workspace_root: Some(input.workspace_root.display().to_string()), + backend: Some(input.backend.to_string()), + coverage: Some(input.coverage), + warnings: input.warnings, + artifact_ref: input.artifact_ref, + created_at: Utc::now(), + } +} + +fn artifact_dir(data_root: &Path, session_id: SessionId, turn_id: TurnId) -> PathBuf { + data_root + .join("workspace-snapshots") + .join(session_id.to_string()) + .join(turn_id.to_string()) +} + +fn artifact_ref(session_id: SessionId, turn_id: TurnId, file_name: &str) -> String { + format!("workspace-snapshots/{session_id}/{turn_id}/{file_name}") +} + +fn write_json(path: &Path, value: &T) -> Result<()> { + let text = serde_json::to_string_pretty(value)?; + fs::write(path, text).with_context(|| format!("write {}", path.display())) +} + +fn hash_text(text: &str) -> String { + let mut hasher = Sha256::new(); + hasher.update(text.as_bytes()); + format!("sha256:{:x}", hasher.finalize()) +} + +impl ActiveWorkspaceBaseline { + fn session_id(&self) -> SessionId { + match self { + Self::Git(baseline) => baseline.session_id, + Self::File(baseline) => baseline.session_id, + } + } + + fn turn_id(&self) -> TurnId { + match self { + Self::Git(baseline) => baseline.turn_id, + Self::File(baseline) => baseline.turn_id, + } + } + + fn checkpoint_id(&self) -> &str { + match self { + Self::Git(baseline) => &baseline.checkpoint_id, + Self::File(baseline) => &baseline.checkpoint_id, + } + } + + fn backend_name(&self) -> &'static str { + match self { + Self::Git(_) => "git_ghost_commit", + Self::File(_) => "file_manifest", + } + } +} + +#[cfg(test)] +mod tests { + use std::collections::BTreeMap; + use std::process::Command; + + use devo_protocol::{ + WorkspaceChangeCoverage, WorkspaceChangeSetStatus, WorkspaceChangeViewStatus, + WorkspaceChangedFileStatus, WorkspaceDiffDetail, + }; + use pretty_assertions::assert_eq; + use tempfile::tempdir; + + use super::*; + + #[tokio::test] + async fn non_git_finalized_turn_view_is_stable_after_later_changes() -> Result<()> { + let data_root = tempdir()?; + let workspace = tempdir()?; + fs::write(workspace.path().join("a.txt"), "old\n")?; + let session_id = SessionId::new(); + let turn_id = TurnId::new(); + let captured = capture_baseline( + data_root.path().to_path_buf(), + session_id, + turn_id, + workspace.path().to_path_buf(), + ) + .await?; + + fs::write(workspace.path().join("a.txt"), "new\n")?; + fs::write(workspace.path().join("b.txt"), "added\n")?; + let finalized = + finalize_baseline(data_root.path().to_path_buf(), captured.baseline).await?; + assert_eq!(finalized.view.status, WorkspaceChangeViewStatus::Ready); + assert_eq!( + finalized.view.change_set_status, + WorkspaceChangeSetStatus::Finalized + ); + let statuses = file_statuses(&finalized.view); + assert_eq!( + statuses, + BTreeMap::from([ + ("a.txt".to_string(), WorkspaceChangedFileStatus::Modified), + ("b.txt".to_string(), WorkspaceChangedFileStatus::Added), + ]) + ); + + fs::write(workspace.path().join("a.txt"), "later\n")?; + let reread = read_finalized_turn_view( + data_root.path(), + session_id, + turn_id, + WorkspaceDiffDetail::Full, + None, + )? + .expect("finalized view"); + let diff = reread.unified_diff.expect("full diff"); + assert!(diff.contains("+new")); + assert!(!diff.contains("+later")); + Ok(()) + } + + #[tokio::test] + async fn git_turn_baseline_reports_tracked_and_untracked_net_changes() -> Result<()> { + let data_root = tempdir()?; + let repo = tempdir()?; + run_git(repo.path(), &["init"]); + run_git( + repo.path(), + &["config", "user.email", "snapshot@example.com"], + ); + run_git(repo.path(), &["config", "user.name", "Snapshot Test"]); + fs::write(repo.path().join("tracked.txt"), "before\n")?; + run_git(repo.path(), &["add", "tracked.txt"]); + run_git(repo.path(), &["commit", "-m", "initial"]); + fs::write(repo.path().join("note.txt"), "preexisting\n")?; + + let captured = capture_baseline( + data_root.path().to_path_buf(), + SessionId::new(), + TurnId::new(), + repo.path().to_path_buf(), + ) + .await?; + + fs::write(repo.path().join("tracked.txt"), "after\n")?; + fs::remove_file(repo.path().join("note.txt"))?; + fs::write(repo.path().join("later.txt"), "later\n")?; + let view = + read_active_turn_view(captured.baseline, WorkspaceDiffDetail::Full, None).await?; + + assert_eq!(view.coverage, WorkspaceChangeCoverage::GitVisible); + let statuses = file_statuses(&view); + assert_eq!( + statuses, + BTreeMap::from([ + ("later.txt".to_string(), WorkspaceChangedFileStatus::Added), + ("note.txt".to_string(), WorkspaceChangedFileStatus::Deleted), + ( + "tracked.txt".to_string(), + WorkspaceChangedFileStatus::Modified, + ), + ]) + ); + let diff = view.unified_diff.expect("full diff"); + assert!(diff.contains("tracked.txt")); + assert!(diff.contains("note.txt")); + assert!(diff.contains("later.txt")); + Ok(()) + } + + fn file_statuses(view: &WorkspaceChangeView) -> BTreeMap { + view.files + .iter() + .map(|file| (file.path.display().to_string(), file.status)) + .collect() + } + + fn run_git(cwd: &Path, args: &[&str]) { + let status = Command::new("git") + .current_dir(cwd) + .args(args) + .status() + .expect("git command"); + assert!(status.success(), "git command failed: {args:?}"); + } +} diff --git a/crates/utils/git/src/git_op/ghost_commits.rs b/crates/utils/git/src/git_op/ghost_commits.rs index 08e1d37e..cdbb4d3a 100644 --- a/crates/utils/git/src/git_op/ghost_commits.rs +++ b/crates/utils/git/src/git_op/ghost_commits.rs @@ -423,6 +423,29 @@ pub fn create_ghost_commit_with_report( )) } +/// Diff two ghost commits using git's normal tree-to-tree diff machinery. +pub fn diff_ghost_commits( + repo_path: &Path, + before_commit: &GhostCommit, + after_commit: &GhostCommit, +) -> Result { + ensure_git_repository(repo_path)?; + let repo_root = resolve_repository_root(repo_path)?; + run_git_for_stdout_all( + repo_root.as_path(), + [ + "diff", + "--no-textconv", + "--no-ext-diff", + "--binary", + before_commit.id(), + after_commit.id(), + "--", + ], + /*env*/ None, + ) +} + /// Restore the working tree to match the provided ghost commit. pub fn restore_ghost_commit(repo_path: &Path, commit: &GhostCommit) -> Result<(), GitToolingError> { restore_ghost_commit_with_options(&RestoreGhostCommitOptions::new(repo_path), commit) diff --git a/crates/utils/git/src/git_op/mod.rs b/crates/utils/git/src/git_op/mod.rs index 23ab69ec..3d9e9b8a 100644 --- a/crates/utils/git/src/git_op/mod.rs +++ b/crates/utils/git/src/git_op/mod.rs @@ -26,6 +26,7 @@ pub use ghost_commits::RestoreGhostCommitOptions; pub use ghost_commits::capture_ghost_snapshot_report; pub use ghost_commits::create_ghost_commit; pub use ghost_commits::create_ghost_commit_with_report; +pub use ghost_commits::diff_ghost_commits; pub use ghost_commits::restore_ghost_commit; pub use ghost_commits::restore_ghost_commit_with_options; pub use ghost_commits::restore_to_commit; From 5548fd87dc60d2b7bfd26d49269e88aa9abe9bbc Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 26 Jun 2026 01:08:03 -1000 Subject: [PATCH 2/4] fix: improve desktop tool output display --- .../ui/src/components/ai-elements/message.tsx | 5 +- .../components/chat/chat-tool-call.test.ts | 87 ++++++++++++++++++- .../components/chat/chat-tool-call.tsx | 63 +++++++++----- .../renderer/components/chat/chat-turn.tsx | 32 +++++-- .../chat/message-response-style.test.ts | 24 +++++ .../components/chat/tool-paths.test.ts | 53 +++++++++++ .../renderer/components/chat/tool-paths.ts | 73 ++++++++++++++++ apps/desktop/src/renderer/index.css | 46 ++++++++++ 8 files changed, 356 insertions(+), 27 deletions(-) create mode 100644 apps/desktop/src/renderer/components/chat/message-response-style.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/tool-paths.test.ts create mode 100644 apps/desktop/src/renderer/components/chat/tool-paths.ts diff --git a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx index 3b0fcc21..0fae748a 100644 --- a/apps/desktop/packages/ui/src/components/ai-elements/message.tsx +++ b/apps/desktop/packages/ui/src/components/ai-elements/message.tsx @@ -277,7 +277,10 @@ const streamdownPlugins = { cjk, code, math, mermaid } export const MessageResponse = memo( ({ className, ...props }: MessageResponseProps) => ( *:first-child]:mt-0 [&>*:last-child]:mb-0", className)} + className={cn( + "devo-message-response size-full [&>*:first-child]:mt-0 [&>*:last-child]:mb-0", + className, + )} plugins={streamdownPlugins} {...props} /> diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts index fbf7fc41..670bf257 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.test.ts @@ -1,8 +1,10 @@ import { readFileSync } from "node:fs" import { describe, expect, test } from "bun:test" -import { getToolDuration, shouldDefaultOpen } from "./chat-tool-call" +import { getToolDuration, getToolSubtitle, shouldDefaultOpen } from "./chat-tool-call" const elapsedHookSource = readFileSync(new URL("../../hooks/use-elapsed-time.ts", import.meta.url), "utf8") +const chatToolCallSource = readFileSync(new URL("./chat-tool-call.tsx", import.meta.url), "utf8") +const rendererCssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8") describe("shouldDefaultOpen", () => { test("collapses tool output by default", () => { @@ -55,6 +57,89 @@ describe("getToolDuration", () => { }) }) +describe("getToolSubtitle", () => { + test("shows read paths relative to the project root", () => { + expect( + getToolSubtitle( + { + callID: "call-1", + id: "tool-1", + tool: "read", + type: "tool", + state: { + input: { filePath: "C:\\Users\\lenovo\\Desktop\\devo\\apps\\desktop\\src\\main.ts" }, + status: "completed", + time: { end: 1, start: 0 }, + output: "", + }, + } as any, + { projectRoot: "C:\\Users\\lenovo\\Desktop\\devo" }, + ), + ).toBe("apps/desktop/src/main.ts") + }) + + test("shows write paths relative to the project root", () => { + expect( + getToolSubtitle( + { + callID: "call-1", + id: "tool-1", + tool: "write", + type: "tool", + state: { + input: { path: "C:\\Users\\lenovo\\Desktop\\devo\\README.md" }, + status: "completed", + time: { end: 1, start: 0 }, + output: "", + }, + } as any, + { projectRoot: "C:\\Users\\lenovo\\Desktop\\devo" }, + ), + ).toBe("README.md") + }) + + test("shows apply_patch paths from patch input", () => { + expect( + getToolSubtitle( + { + callID: "call-1", + id: "tool-1", + tool: "apply_patch", + type: "tool", + state: { + input: { + patch: `*** Begin Patch +*** Update File: C:\\Users\\lenovo\\Desktop\\devo\\apps\\desktop\\src\\main.ts +@@ +*** End Patch`, + }, + status: "completed", + time: { end: 1, start: 0 }, + output: "", + }, + } as any, + { projectRoot: "C:\\Users\\lenovo\\Desktop\\devo" }, + ), + ).toBe("apps/desktop/src/main.ts") + }) +}) + +describe("read tool output density source", () => { + test("overrides CodeBlock internal text sizing for read output", () => { + expect({ + readClass: chatToolCallSource.includes("devo-read-output"), + preRule: rendererCssSource.includes(".devo-read-output pre"), + codeRule: rendererCssSource.includes(".devo-read-output code"), + lineHeight: rendererCssSource.includes("line-height: 1.35"), + }).toEqual({ + readClass: true, + preRule: true, + codeRule: true, + lineHeight: true, + }) + }) +}) + describe("useToolElapsedTime source", () => { test("uses tool state time without renderer first-seen timestamps", () => { expect({ diff --git a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx index aadc0e80..ceca91a5 100644 --- a/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-tool-call.tsx @@ -47,6 +47,12 @@ import { detectContentLanguage, detectLanguage, prettyPrintJson } from "../../li import type { FilePart, ToolPart, ToolStateCompleted } from "../../lib/types" import { SubAgentCard } from "./sub-agent-card" import { getToolCategory, ToolCard } from "./tool-card" +import { + formatToolPathForDisplay, + getFirstApplyPatchPath, + shortenPathForDisplay, + type ToolPathDisplayOptions, +} from "./tool-paths" // ============================================================ // Constants @@ -247,7 +253,10 @@ function getPendingLabel(tool: string): string { * Falls back to a "Preparing ..." label when the tool is in the `pending` state * and no input fields have been parsed yet (the model is still streaming arguments). */ -export function getToolSubtitle(part: ToolPart): string | undefined { +export function getToolSubtitle( + part: ToolPart, + options: ToolPathDisplayOptions = {}, +): string | undefined { const state = part.state const input = state.input const rawTitle = "title" in state && typeof state.title === "string" ? state.title : undefined @@ -258,8 +267,11 @@ export function getToolSubtitle(part: ToolPart): string | undefined { switch (part.tool) { case "read": subtitle = - shortenPath((input.filePath as string) ?? (input.path as string)) ?? - shortenPath(extractFromRaw(state, "filePath", "path")) + formatToolPathForDisplay( + (input.filePath as string | undefined) ?? (input.path as string | undefined), + options, + ) ?? + formatToolPathForDisplay(extractFromRaw(state, "filePath", "path"), options) break case "glob": subtitle = @@ -282,17 +294,32 @@ export function getToolSubtitle(part: ToolPart): string | undefined { break case "edit": subtitle = - shortenPath((input.filePath as string) ?? (input.path as string)) ?? - shortenPath(extractFromRaw(state, "filePath", "path")) + formatToolPathForDisplay( + (input.filePath as string | undefined) ?? (input.path as string | undefined), + options, + ) ?? + formatToolPathForDisplay(extractFromRaw(state, "filePath", "path"), options) break case "write": subtitle = - shortenPath((input.filePath as string) ?? (input.path as string)) ?? - shortenPath(extractFromRaw(state, "filePath", "path")) + formatToolPathForDisplay( + (input.filePath as string | undefined) ?? (input.path as string | undefined), + options, + ) ?? + formatToolPathForDisplay(extractFromRaw(state, "filePath", "path"), options) break - case "apply_patch": - subtitle = title + case "apply_patch": { + const rawPatch = extractFromRaw(state, "patch", "diff") + const patchPath = + (input.filePath as string | undefined) ?? + (input.path as string | undefined) ?? + getFirstApplyPatchPath(input.patch as string | undefined) ?? + getFirstApplyPatchPath(input.diff as string | undefined) ?? + getFirstApplyPatchPath(rawPatch?.replace(/\\r\\n|\\n/g, "\n")) ?? + extractFromRaw(state, "filePath", "path") + subtitle = formatToolPathForDisplay(patchPath, options) ?? title break + } case "webfetch": subtitle = (input.url as string) ?? extractFromRaw(state, "url") break @@ -347,14 +374,6 @@ function formatInputParams(input: Record): string | undefined { return `[${parts.join(", ")}]` } -/** Shorten a file path to just filename or last 2 segments */ -function shortenPath(path: string | undefined): string | undefined { - if (!path) return undefined - const parts = path.split("/") - if (parts.length <= 2) return path - return parts.slice(-2).join("/") -} - /** Compute completed tool duration from the SDK tool-state timestamps. */ export function getToolDuration(part: ToolPart): string | undefined { const state = part.state @@ -605,7 +624,7 @@ function ReadContent({ part }: { part: ToolPart }) { code={displayContent} language={language} showLineNumbers - className="max-h-96 border-0 shadow-none rounded-none text-[11px]" + className="devo-read-output max-h-96 border-0 shadow-none rounded-none" > - in: {shortenPath(path)} + in: {shortenPathForDisplay(path)} )} @@ -893,6 +912,8 @@ interface ChatToolCallProps { turnHasError?: boolean /** Delete this tool part (for error recovery) */ onDelete?: (part: ToolPart) => void + /** Project root used only for display-only path labels. */ + projectRoot?: string | null } /** @@ -937,6 +958,7 @@ export const ChatToolCall = memo( isActiveTurn = false, turnHasError = false, onDelete, + projectRoot, }: ChatToolCallProps) { const viewFileInDiff = useSetAtom(viewFileInDiffPanelAtom) @@ -1081,7 +1103,7 @@ export const ChatToolCall = memo( // --- All other tools (including todos): ToolCard --- const { icon: Icon, title } = getToolInfo(part.tool) - const subtitle = getToolSubtitle(part) + const subtitle = getToolSubtitle(part, { projectRoot }) const category = getToolCategory(part.tool) const hasContent = hasExpandableContent(part) const defaultOpen = isActiveTurn ? shouldDefaultOpen(part.tool, status) : false @@ -1116,6 +1138,7 @@ export const ChatToolCall = memo( if (!areToolPartsEqual(prev.part, next.part)) return false if (prev.isActiveTurn !== next.isActiveTurn) return false if (prev.turnHasError !== next.turnHasError) return false + if (prev.projectRoot !== next.projectRoot) return false // onDelete is a callback ref - skip reference comparison to avoid // re-renders from parent creating new closures return true diff --git a/apps/desktop/src/renderer/components/chat/chat-turn.tsx b/apps/desktop/src/renderer/components/chat/chat-turn.tsx index 28823aed..2a791b74 100644 --- a/apps/desktop/src/renderer/components/chat/chat-turn.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-turn.tsx @@ -457,13 +457,17 @@ function groupPartsForStream(ordered: RenderablePart[]): StreamItem[] { * Generates a human-readable summary for a group of tools in the same category. * Returns text like "Read 3 files", "Edited foo.tsx, bar.tsx", "Ran 2 commands". */ -function describeToolGroup(category: ToolCategory, tools: ToolPart[]): string { +function describeToolGroup( + category: ToolCategory, + tools: ToolPart[], + projectRoot?: string | null, +): string { const count = tools.length // For small groups, list specific targets if (count <= 3) { const details = tools - .map((t) => getToolSubtitle(t)) + .map((t) => getToolSubtitle(t, { projectRoot })) .filter(Boolean) .map((s) => { // Shorten file paths to just the filename @@ -535,13 +539,15 @@ const ToolGroupSummary = memo(function ToolGroupSummary({ category, tools, isActiveTurn, + projectRoot, }: { category: ToolCategory tools: ToolPart[] isActiveTurn: boolean + projectRoot?: string | null }) { const [expanded, setExpanded] = useState(false) - const description = describeToolGroup(category, tools) + const description = describeToolGroup(category, tools, projectRoot) const running = isGroupRunning(tools) const hasError = isGroupError(tools) const { icon: GroupIcon } = getToolInfo(tools[0].tool) @@ -575,7 +581,12 @@ const ToolGroupSummary = memo(function ToolGroupSummary({ {expanded && (
{tools.map((tool) => ( - + ))}
)} @@ -669,6 +680,7 @@ export const ChatTurnComponent = memo( const [uncontrolledStepsExpanded, setUncontrolledStepsExpanded] = useState(false) const [copied, setCopied] = useState(false) const displayMode = useDisplayMode() + const toolPathRoot = agent?.worktreePath ?? agent?.directory ?? agent?.projectDirectory const turnRef = useRef(null) const stepsExpanded = controlledStepsExpanded ?? uncontrolledStepsExpanded const setStepsExpanded = useCallback( @@ -949,7 +961,11 @@ export const ChatTurnComponent = memo( if (processItem.kind === "tool") { return (
- +
) } @@ -967,6 +983,7 @@ export const ChatTurnComponent = memo( key={item.tools[0].id} part={item.tools[0]} isActiveTurn={isActiveTurn} + projectRoot={toolPathRoot} /> ) } @@ -976,6 +993,7 @@ export const ChatTurnComponent = memo( category={item.category} tools={item.tools} isActiveTurn={isActiveTurn} + projectRoot={toolPathRoot} /> ) } @@ -1005,6 +1023,7 @@ export const ChatTurnComponent = memo( isActiveTurn={isActiveTurn} turnHasError={!!errorText} onDelete={onDeletePart ? handleDeleteToolPart : undefined} + projectRoot={toolPathRoot} /> ) } @@ -1129,6 +1148,9 @@ export const ChatTurnComponent = memo( if (prev.isLast !== next.isLast) return false if (prev.isWorking !== next.isWorking) return false if (prev.agent?.sessionId !== next.agent?.sessionId) return false + if (prev.agent?.directory !== next.agent?.directory) return false + if (prev.agent?.projectDirectory !== next.agent?.projectDirectory) return false + if (prev.agent?.worktreePath !== next.agent?.worktreePath) return false if (prev.isConnected !== next.isConnected) return false if (prev.stepsExpanded !== next.stepsExpanded) return false if ( diff --git a/apps/desktop/src/renderer/components/chat/message-response-style.test.ts b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts new file mode 100644 index 00000000..0a6f354c --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/message-response-style.test.ts @@ -0,0 +1,24 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const messageSource = readFileSync( + new URL("../../../../packages/ui/src/components/ai-elements/message.tsx", import.meta.url), + "utf8", +) +const rendererCssSource = readFileSync(new URL("../../index.css", import.meta.url), "utf8") + +describe("MessageResponse markdown surfaces", () => { + test("uses desktop dark theme surfaces for streamdown markdown cells", () => { + expect({ + responseClass: messageSource.includes("devo-message-response"), + codeBlockSurface: rendererCssSource.includes('[data-streamdown="code-block"]'), + codeBlockBodySurface: rendererCssSource.includes('[data-streamdown="code-block-body"]'), + tableHeaderSurface: rendererCssSource.includes('[data-streamdown="table-header"]'), + }).toEqual({ + responseClass: true, + codeBlockSurface: true, + codeBlockBodySurface: true, + tableHeaderSurface: true, + }) + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/tool-paths.test.ts b/apps/desktop/src/renderer/components/chat/tool-paths.test.ts new file mode 100644 index 00000000..c5a17204 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/tool-paths.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test } from "bun:test" +import { formatToolPathForDisplay, getFirstApplyPatchPath } from "./tool-paths" + +describe("formatToolPathForDisplay", () => { + test("shows project-relative Windows paths", () => { + expect( + formatToolPathForDisplay("C:\\Users\\lenovo\\Desktop\\devo\\src\\main.ts", { + projectRoot: "c:\\users\\lenovo\\desktop\\devo", + }), + ).toBe("src/main.ts") + }) + + test("shows project-relative POSIX paths", () => { + expect( + formatToolPathForDisplay("/home/lenovo/devo/src/main.ts", { + projectRoot: "/home/lenovo/devo", + }), + ).toBe("src/main.ts") + }) + + test("keeps already-relative paths", () => { + expect(formatToolPathForDisplay("apps/desktop/src/main.ts")).toBe( + "apps/desktop/src/main.ts", + ) + }) + + test("does not leak absolute paths outside the project root", () => { + expect( + formatToolPathForDisplay("C:\\Users\\lenovo\\Other\\secrets.ts", { + projectRoot: "C:\\Users\\lenovo\\Desktop\\devo", + }), + ).toBe("Other/secrets.ts") + }) +}) + +describe("getFirstApplyPatchPath", () => { + test("extracts paths from apply_patch input", () => { + expect( + getFirstApplyPatchPath(`*** Begin Patch +*** Update File: apps/desktop/src/main.ts +@@ +*** End Patch`), + ).toBe("apps/desktop/src/main.ts") + }) + + test("extracts paths from git diff input", () => { + expect( + getFirstApplyPatchPath(`diff --git a/apps/desktop/src/main.ts b/apps/desktop/src/main.ts +--- a/apps/desktop/src/main.ts ++++ b/apps/desktop/src/main.ts`), + ).toBe("apps/desktop/src/main.ts") + }) +}) diff --git a/apps/desktop/src/renderer/components/chat/tool-paths.ts b/apps/desktop/src/renderer/components/chat/tool-paths.ts new file mode 100644 index 00000000..b3208641 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/tool-paths.ts @@ -0,0 +1,73 @@ +export type ToolPathDisplayOptions = { + projectRoot?: string | null +} + +const WINDOWS_ABSOLUTE_PATH = /^[A-Za-z]:\// + +function normalizePathForDisplay(path: string): string { + const normalized = path.trim().replace(/\\/g, "/").replace(/\/+/g, "/") + if (normalized.length > 1) return normalized.replace(/\/+$/, "") + return normalized +} + +function isAbsolutePath(path: string): boolean { + return WINDOWS_ABSOLUTE_PATH.test(path) || path.startsWith("/") +} + +function stripProjectRoot(path: string, projectRoot: string): string | undefined { + const normalizedRoot = normalizePathForDisplay(projectRoot) + if (!normalizedRoot) return undefined + + const lowerPath = path.toLowerCase() + const lowerRoot = normalizedRoot.toLowerCase() + if (lowerPath === lowerRoot) return "." + if (!lowerPath.startsWith(`${lowerRoot}/`)) return undefined + + return path.slice(normalizedRoot.length + 1) +} + +export function shortenPathForDisplay(path: string | undefined): string | undefined { + if (!path) return undefined + const normalized = normalizePathForDisplay(path) + if (!normalized) return undefined + + const parts = normalized.split("/").filter(Boolean) + if (parts.length <= 2) return normalized + return parts.slice(-2).join("/") +} + +export function formatToolPathForDisplay( + path: string | undefined, + options: ToolPathDisplayOptions = {}, +): string | undefined { + if (!path) return undefined + const normalized = normalizePathForDisplay(path) + if (!normalized) return undefined + if (!isAbsolutePath(normalized)) return normalized + + if (options.projectRoot) { + const relativePath = stripProjectRoot(normalized, options.projectRoot) + if (relativePath) return relativePath + } + + return shortenPathForDisplay(normalized) +} + +export function getFirstApplyPatchPath(patch: string | undefined): string | undefined { + if (!patch) return undefined + + for (const line of patch.split(/\r?\n/)) { + const applyPatchMatch = line.match(/^\*\*\* (?:Add|Update|Delete) File:\s+(.+)$/) + if (applyPatchMatch?.[1]) return applyPatchMatch[1].trim() + + const gitDiffMatch = line.match(/^diff --git a\/(.+?) b\/(.+)$/) + if (gitDiffMatch?.[2]) return gitDiffMatch[2].trim() + + const fileHeaderMatch = line.match(/^(?:---|\+\+\+) [ab]\/(.+)$/) + if (fileHeaderMatch?.[1] && fileHeaderMatch[1] !== "dev/null") { + return fileHeaderMatch[1].trim() + } + } + + return undefined +} diff --git a/apps/desktop/src/renderer/index.css b/apps/desktop/src/renderer/index.css index 7ffd4367..73421b94 100644 --- a/apps/desktop/src/renderer/index.css +++ b/apps/desktop/src/renderer/index.css @@ -12,6 +12,52 @@ body { min-height: 100vh; } +:root.electron-transparent [data-slot="startup-overlay"], +:root.electron-vibrancy [data-slot="startup-overlay"] { + background: color-mix(in srgb, var(--background) var(--glass-body), transparent) !important; +} + +:root.dark .devo-message-response { + --devo-markdown-surface: color-mix(in srgb, var(--card) 92%, transparent); + --devo-markdown-surface-subtle: color-mix(in srgb, var(--muted) 86%, transparent); + --devo-markdown-code-bg: var(--background); +} + +:root.dark .devo-message-response [data-streamdown="code-block"], +:root.dark .devo-message-response [data-streamdown="mermaid-block"] { + background: var(--devo-markdown-surface); + border-color: var(--border); +} + +:root.dark .devo-message-response [data-streamdown="code-block-body"] { + background: var(--devo-markdown-code-bg); + border-color: var(--border); +} + +:root.dark .devo-message-response [data-streamdown="code-block-actions"], +:root.dark .devo-message-response [data-streamdown="mermaid-block-actions"] { + background: var(--devo-markdown-surface-subtle); + border-color: var(--border); +} + +:root.dark .devo-message-response [data-streamdown^="table"] { + border-color: var(--border); +} + +:root.dark .devo-message-response [data-streamdown="table-header"] { + background: var(--devo-markdown-surface-subtle); +} + +.devo-read-output pre, +.devo-read-output code { + font-size: 11px; + line-height: 1.35; +} + +.devo-read-output pre { + padding: 0.75rem; +} + .devo-splash-brand { display: inline-flex; align-items: center; From 7f6b178d4fe637d22daff790aea3ef2a36605e5f Mon Sep 17 00:00:00 2001 From: wangtsiao Date: Fri, 26 Jun 2026 01:08:17 -1000 Subject: [PATCH 3/4] fix: polish desktop window chrome --- apps/desktop/src/main/ipc-handlers.ts | 12 ++-- apps/desktop/src/main/liquid-glass.test.ts | 23 ++++++-- apps/desktop/src/main/liquid-glass.ts | 27 +++++---- .../components/startup-overlay.test.ts | 56 +++++++++++++++++++ .../renderer/components/startup-overlay.tsx | 2 +- apps/desktop/src/renderer/desktop-chrome.css | 13 +++++ .../src/renderer/desktop-chrome.test.ts | 26 ++++++++- apps/desktop/src/renderer/index.html | 14 ++++- 8 files changed, 145 insertions(+), 28 deletions(-) create mode 100644 apps/desktop/src/renderer/components/startup-overlay.test.ts diff --git a/apps/desktop/src/main/ipc-handlers.ts b/apps/desktop/src/main/ipc-handlers.ts index 20c48270..9cfb7b24 100644 --- a/apps/desktop/src/main/ipc-handlers.ts +++ b/apps/desktop/src/main/ipc-handlers.ts @@ -30,7 +30,7 @@ import { stashAndCheckout, stashPop, } from "./git-service" -import { getResolvedChromeTier, resolveWindowsTitleBarOverlay } from "./liquid-glass" +import { getResolvedChromeTier, resolveTitleBarOverlay } from "./liquid-glass" import { createLogger } from "./logger" import { readModelState, updateModelRecent } from "./model-state" import { dismissNotification, updateBadgeCount } from "./notifications" @@ -70,9 +70,9 @@ const log = createLogger("ipc") /** Read the opaque windows preference for use at window creation time. */ export { getOpaqueWindows as getOpaqueWindowsPref } from "./settings-store" -function updateWindowsTitleBarOverlay(): void { - if (process.platform !== "win32") return - const titleBarOverlay = resolveWindowsTitleBarOverlay(nativeTheme.shouldUseDarkColors) +function updateTitleBarOverlay(): void { + if (process.platform !== "win32" && process.platform !== "linux") return + const titleBarOverlay = resolveTitleBarOverlay(nativeTheme.shouldUseDarkColors) for (const win of BrowserWindow.getAllWindows()) { win.setTitleBarOverlay(titleBarOverlay) } @@ -490,10 +490,10 @@ export function registerIpcHandlers(): void { } else { nativeTheme.themeSource = "system" } - updateWindowsTitleBarOverlay() + updateTitleBarOverlay() }) - nativeTheme.on("updated", updateWindowsTitleBarOverlay) + nativeTheme.on("updated", updateTitleBarOverlay) // --- System accent color (macOS / Windows) --- diff --git a/apps/desktop/src/main/liquid-glass.test.ts b/apps/desktop/src/main/liquid-glass.test.ts index dae6a174..7504ea5e 100644 --- a/apps/desktop/src/main/liquid-glass.test.ts +++ b/apps/desktop/src/main/liquid-glass.test.ts @@ -1,8 +1,8 @@ import { describe, expect, test } from "bun:test" import { getResolvedChromeTier, + resolveTitleBarOverlay, resolveWindowChrome, - resolveWindowsTitleBarOverlay, } from "./liquid-glass" describe("resolveWindowChrome", () => { @@ -59,14 +59,25 @@ describe("resolveWindowChrome", () => { expect(getResolvedChromeTier()).toBe("opaque") }) - test("keeps Linux opaque even when opaque windows are disabled", async () => { - const chrome = await resolveWindowChrome({ isOpaque: false, platform: "linux" }) + test("uses hidden titlebar overlay on Linux while keeping the opaque tier", async () => { + const chrome = await resolveWindowChrome({ + isOpaque: false, + isDarkMode: true, + platform: "linux", + }) expect(chrome).toEqual({ tier: "opaque", usesTransparentWindow: false, usesTransparentBackground: false, - options: {}, + options: { + titleBarStyle: "hidden", + titleBarOverlay: { + color: "#00000000", + symbolColor: "#f4f4f5", + height: 40, + }, + }, }) expect(getResolvedChromeTier()).toBe("opaque") }) @@ -86,8 +97,8 @@ describe("resolveWindowChrome", () => { expect(getResolvedChromeTier()).toBe("opaque") }) - test("uses dark titlebar overlay symbols in light mode on Windows", () => { - expect(resolveWindowsTitleBarOverlay(false)).toEqual({ + test("uses dark titlebar overlay symbols in light mode", () => { + expect(resolveTitleBarOverlay(false)).toEqual({ color: "#00000000", symbolColor: "#111111", height: 40, diff --git a/apps/desktop/src/main/liquid-glass.ts b/apps/desktop/src/main/liquid-glass.ts index 1bd1b52e..ff05d07d 100644 --- a/apps/desktop/src/main/liquid-glass.ts +++ b/apps/desktop/src/main/liquid-glass.ts @@ -33,18 +33,18 @@ export interface ResolveWindowChromeOptions { platform?: NodeJS.Platform } -const WINDOWS_TITLE_BAR_OVERLAY_HEIGHT = 40 -const WINDOWS_TITLE_BAR_OVERLAY_COLOR = "#00000000" -const WINDOWS_TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR = "#111111" -const WINDOWS_TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR = "#f4f4f5" +const TITLE_BAR_OVERLAY_HEIGHT = 40 +const TITLE_BAR_OVERLAY_COLOR = "#00000000" +const TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR = "#111111" +const TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR = "#f4f4f5" -export function resolveWindowsTitleBarOverlay(isDarkMode: boolean): TitleBarOverlay { +export function resolveTitleBarOverlay(isDarkMode: boolean): TitleBarOverlay { return { - color: WINDOWS_TITLE_BAR_OVERLAY_COLOR, + color: TITLE_BAR_OVERLAY_COLOR, symbolColor: isDarkMode - ? WINDOWS_TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR - : WINDOWS_TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR, - height: WINDOWS_TITLE_BAR_OVERLAY_HEIGHT, + ? TITLE_BAR_OVERLAY_LIGHT_SYMBOL_COLOR + : TITLE_BAR_OVERLAY_DARK_SYMBOL_COLOR, + height: TITLE_BAR_OVERLAY_HEIGHT, } } @@ -116,6 +116,7 @@ export async function resolveWindowChrome({ }: ResolveWindowChromeOptions): Promise { const isMac = platform === "darwin" const isWindows = platform === "win32" + const isLinux = platform === "linux" if (isWindows) { if (isOpaque) { @@ -127,7 +128,7 @@ export async function resolveWindowChrome({ usesTransparentBackground: false, options: { titleBarStyle: "hidden" as const, - titleBarOverlay: resolveWindowsTitleBarOverlay(isDarkMode), + titleBarOverlay: resolveTitleBarOverlay(isDarkMode), }, } } @@ -147,7 +148,7 @@ export async function resolveWindowChrome({ thickFrame: true, roundedCorners: true, titleBarStyle: "hidden" as const, - titleBarOverlay: resolveWindowsTitleBarOverlay(isDarkMode), + titleBarOverlay: resolveTitleBarOverlay(isDarkMode), }, } } @@ -161,6 +162,10 @@ export async function resolveWindowChrome({ usesTransparentWindow: false, usesTransparentBackground: false, options: { + ...(isLinux && { + titleBarStyle: "hidden" as const, + titleBarOverlay: resolveTitleBarOverlay(isDarkMode), + }), ...(isMac && { titleBarStyle: "hiddenInset" as const, trafficLightPosition: { x: 15, y: 15 }, diff --git a/apps/desktop/src/renderer/components/startup-overlay.test.ts b/apps/desktop/src/renderer/components/startup-overlay.test.ts new file mode 100644 index 00000000..f1aa6fbf --- /dev/null +++ b/apps/desktop/src/renderer/components/startup-overlay.test.ts @@ -0,0 +1,56 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const startupOverlaySource = readFileSync(new URL("./startup-overlay.tsx", import.meta.url), "utf8") +const indexHtmlSource = readFileSync(new URL("../index.html", import.meta.url), "utf8") +const indexCssSource = readFileSync(new URL("../index.css", import.meta.url), "utf8") + +describe("startup overlay background", () => { + test("uses the app background across the full React startup overlay", () => { + expect({ + hasStartupSlot: startupOverlaySource.includes('data-slot="startup-overlay"'), + hasBackgroundClass: startupOverlaySource.includes("bg-background text-foreground"), + keepsFullScreenOverlay: startupOverlaySource.includes("fixed inset-0"), + }).toEqual({ + hasStartupSlot: true, + hasBackgroundClass: true, + keepsFullScreenOverlay: true, + }) + }) + + test("uses a concrete pre-React splash background token", () => { + expect({ + definesBackground: indexHtmlSource.includes("--devo-startup-background: #181818"), + definesLightBackground: indexHtmlSource.includes("--devo-startup-background: #ffffff"), + appliesToHtml: indexHtmlSource.includes("html {"), + appliesToBody: indexHtmlSource.includes("body {"), + appliesToSplash: indexHtmlSource.includes( + "background: var(--devo-startup-background)", + ), + usesTransparentSplash: indexHtmlSource.includes("background: transparent"), + }).toEqual({ + definesBackground: true, + definesLightBackground: true, + appliesToHtml: true, + appliesToBody: true, + appliesToSplash: true, + usesTransparentSplash: false, + }) + }) + + test("keeps glass startup overlay background consistent with the content area", () => { + expect({ + transparentSelector: indexCssSource.includes( + ':root.electron-transparent [data-slot="startup-overlay"]', + ), + vibrancySelector: indexCssSource.includes( + ':root.electron-vibrancy [data-slot="startup-overlay"]', + ), + usesGlassBody: indexCssSource.includes("var(--background) var(--glass-body)"), + }).toEqual({ + transparentSelector: true, + vibrancySelector: true, + usesGlassBody: true, + }) + }) +}) diff --git a/apps/desktop/src/renderer/components/startup-overlay.tsx b/apps/desktop/src/renderer/components/startup-overlay.tsx index bd2df9f7..b04e173c 100644 --- a/apps/desktop/src/renderer/components/startup-overlay.tsx +++ b/apps/desktop/src/renderer/components/startup-overlay.tsx @@ -80,7 +80,7 @@ export function StartupOverlay() { return (
{ + test("Windows dark mode uses dark chrome background tokens", async () => { + const css = await readFile(cssPath, "utf8"); + const lightDeclarations = declarationsForSelector( + css, + ':root[data-platform="win32"]', + ); + const darkDeclarations = declarationsForSelector( + css, + ':root[data-platform="win32"].dark', + ); + + expect(lightDeclarations).toEqual({ + "--devo-titlebar-height": "40px", + "--devo-windows-focus-chrome-bg": "#ecf5f9", + "--devo-windows-unfocused-chrome-bg": "#f2f4f5", + }); + expect(darkDeclarations).toEqual({ + "--devo-windows-focus-chrome-bg": + "color-mix( in srgb, var(--background) 92%, var(--foreground) 8% )", + "--devo-windows-unfocused-chrome-bg": + "color-mix( in srgb, var(--background) 96%, var(--foreground) 4% )", + }); + }); + test("macOS glass sidebar inset extends to the right and bottom window edges", async () => { const css = await readFile(cssPath, "utf8"); const selectors = [ diff --git a/apps/desktop/src/renderer/index.html b/apps/desktop/src/renderer/index.html index a0142a76..66f59990 100644 --- a/apps/desktop/src/renderer/index.html +++ b/apps/desktop/src/renderer/index.html @@ -8,6 +8,16 @@ Devo