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/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/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/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/automations/create-automation-dialog.tsx b/apps/desktop/src/renderer/components/automations/create-automation-dialog.tsx index b7c1c745..3b08356f 100644 --- a/apps/desktop/src/renderer/components/automations/create-automation-dialog.tsx +++ b/apps/desktop/src/renderer/components/automations/create-automation-dialog.tsx @@ -40,7 +40,8 @@ import { toast } from "sonner" import type { Automation } from "../../../preload/api" import { discoveryProjectsAtom } from "../../atoms/discovery" import { - useModelState, + getModelVariants, + type ModelRef, useDevoAgents, useProviders, } from "../../hooks/use-devo-data" @@ -51,10 +52,8 @@ import { runAutomationNow, updateAutomation, } from "../../services/backend" -import type { ModelRef } from "../../hooks/use-devo-data" import { AgentSelector, ModelSelector, VariantSelector } from "../chat/prompt-toolbar" import { SchedulePicker } from "./schedule-picker" -import { getModelVariants } from "../../hooks/use-devo-data" interface CreateAutomationDialogProps { open: boolean @@ -124,7 +123,6 @@ export function CreateAutomationDialog({ const { data: providers } = useProviders(directory) const { agents } = useDevoAgents(directory) - const { recentModels } = useModelState() // Compute available variants for the selected model const variants = useMemo(() => { @@ -459,7 +457,6 @@ export function CreateAutomationDialog({ effectiveModel={selectedModel} hasOverride={selectedModel !== null} onSelectModel={setSelectedModel} - recentModels={recentModels} /> {variants.length > 0 && ( <> diff --git a/apps/desktop/src/renderer/components/chat/chat-input.tsx b/apps/desktop/src/renderer/components/chat/chat-input.tsx index f389fa64..edd28b20 100644 --- a/apps/desktop/src/renderer/components/chat/chat-input.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-input.tsx @@ -178,7 +178,7 @@ export function ChatInput({ const [selectedAgent, setSelectedAgent] = useState(null) const [selectedVariant, setSelectedVariant] = useState(undefined) - const { recentModels, addRecent: addRecentModel } = useModelState() + const { addRecent: addRecentModel } = useModelState() // Resolve effective model @@ -396,7 +396,6 @@ export function ChatInput({ if (m) addRecentModel(m) }) } - recentModels={recentModels} selectedVariant={selectedVariant} onSelectVariant={(v) => startTransition(() => setSelectedVariant(v))} disabled={!isConnected} 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/chat-view.tsx b/apps/desktop/src/renderer/components/chat/chat-view.tsx index ceb075e4..e71f095e 100644 --- a/apps/desktop/src/renderer/components/chat/chat-view.tsx +++ b/apps/desktop/src/renderer/components/chat/chat-view.tsx @@ -1114,7 +1114,7 @@ function ChatInputSection({ } }, [sessionMessages, agent.sessionId, agent.directory, projectModels]) - const { recentModels, addRecent: addRecentModel } = useModelState() + const { addRecent: addRecentModel } = useModelState() const activeDevoAgent = useMemo(() => { const agentName = selectedAgent ?? config?.defaultAgent @@ -1625,7 +1625,6 @@ function ChatInputSection({ effectiveModel={effectiveModel} hasModelOverride={!!selectedModel} onSelectModel={handleModelSelect} - recentModels={recentModels} selectedVariant={selectedVariant} onSelectVariant={setSelectedVariant} disabled={!isConnected} 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/prompt-toolbar.test.ts b/apps/desktop/src/renderer/components/chat/prompt-toolbar.test.ts new file mode 100644 index 00000000..3212f0b0 --- /dev/null +++ b/apps/desktop/src/renderer/components/chat/prompt-toolbar.test.ts @@ -0,0 +1,36 @@ +import { readFileSync } from "node:fs" +import { describe, expect, test } from "bun:test" + +const source = readFileSync(new URL("./prompt-toolbar.tsx", import.meta.url), "utf8") +const modelSelectorProps = source.match(/interface ModelSelectorProps \{[\s\S]*?\n\}/)?.[0] ?? "" +const promptToolbarProps = source.match(/export interface PromptToolbarProps \{[\s\S]*?\n\}/)?.[0] ?? "" + +describe("Model selector menu", () => { + test("does not expose a Last used presentation group", () => { + expect({ + omitsLastUsedHeading: !source.includes("Last used"), + omitsLastUsedPresentationState: !source.includes("lastUsedModels"), + modelSelectorPropRemoved: !modelSelectorProps.includes("recentModels"), + promptToolbarPropRemoved: !promptToolbarProps.includes("recentModels"), + }).toEqual({ + omitsLastUsedHeading: true, + omitsLastUsedPresentationState: true, + modelSelectorPropRemoved: true, + promptToolbarPropRemoved: true, + }) + }) + + test("keeps provider grouping and active model selection", () => { + expect({ + groupsFilteredModelsByProvider: source.includes("groupByProvider(filteredModels)"), + rendersProviderGroups: source.includes(" void - /** Recent models from model.json (most recently used first) */ - recentModels?: ModelRef[] variants?: string[] selectedVariant?: string | undefined currentVariant?: string | undefined @@ -207,7 +205,6 @@ export function ModelSelector({ providers, effectiveModel, onSelectModel, - recentModels, variants = [], selectedVariant, currentVariant, @@ -217,17 +214,6 @@ export function ModelSelector({ }: ModelSelectorProps) { const models = useMemo(() => (providers ? flattenModels(providers.providers) : []), [providers]) - // Build "Last used" group from recentModels (up to 3, only models that exist in providers) - const lastUsedModels = useMemo(() => { - if (!recentModels || recentModels.length === 0) return [] - return recentModels - .slice(0, 3) - .map((ref) => - models.find((m) => m.providerID === ref.providerID && m.modelID === ref.modelID), - ) - .filter((m): m is ModelOption => m != null) - }, [recentModels, models]) - const activeValue = effectiveModel ? `${effectiveModel.providerID}/${effectiveModel.modelID}` : null @@ -310,7 +296,6 @@ export function ModelSelector({ @@ -335,12 +320,10 @@ export function ModelSelector({ /** Inner list component — reads search from context */ function ModelSelectorList({ models, - lastUsedModels, activeValue, onSelect, }: { models: ModelOption[] - lastUsedModels: ModelOption[] activeValue: string | null onSelect: (value: string) => void }) { @@ -365,22 +348,6 @@ function ModelSelectorList({ No models found ) : ( <> - {/* Last used group — only shown when not searching */} - {!search && lastUsedModels.length > 0 && ( - - {lastUsedModels.map((model) => ( - onSelect(model.value)} - /> - ))} - - )} - {/* Provider-grouped models */} {Array.from(grouped.entries()).map(([providerName, providerModels]) => { // Get the provider ID from the first model in the group to look up the icon @@ -517,9 +484,6 @@ export interface PromptToolbarProps { hasModelOverride: boolean onSelectModel: (model: ModelRef | null) => void - /** Recent models from model.json */ - recentModels?: ModelRef[] - /** Currently selected variant */ selectedVariant: string | undefined onSelectVariant: (variant: string | undefined) => void @@ -540,7 +504,6 @@ export function PromptToolbar({ effectiveModel, hasModelOverride, onSelectModel, - recentModels, selectedVariant, onSelectVariant, disabled, @@ -588,7 +551,6 @@ export function PromptToolbar({ effectiveModel={effectiveModel} hasOverride={hasModelOverride} onSelectModel={onSelectModel} - recentModels={recentModels} variants={variants} selectedVariant={selectedVariant} currentVariant={currentVariant} 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/components/new-chat.tsx b/apps/desktop/src/renderer/components/new-chat.tsx index d7ea8c86..439e5f58 100644 --- a/apps/desktop/src/renderer/components/new-chat.tsx +++ b/apps/desktop/src/renderer/components/new-chat.tsx @@ -656,7 +656,6 @@ export function NewChat() { effectiveModel={effectiveModel} hasModelOverride={!!selectedModel} onSelectModel={handleModelSelect} - recentModels={recentModels} selectedVariant={selectedVariant} onSelectVariant={setSelectedVariant} disabled={launching || !selectedDirectory} 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/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/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/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; 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