From 15e45ab0e0e8bcaa7b155d02fe2c18cb9e7ab107 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 10 Aug 2026 20:07:43 +0200 Subject: [PATCH 01/20] feat(session): implement session-scoped diff from snapshots (#174) Replace the stub Session.diff with actual logic that computes a net diff (session-start snapshot vs current state) filtered to files the agent touched via PatchParts. - Add Snapshot.Service as a dependency of Session's layer - Query all messages for step-start hash and PatchPart file sets - Call snapshot.diffFull(from, current), filter to agent-touched files - Filter out zero-change entries (reverted edits) - Update HTTP handler to call Session.diff instead of per-message summary.diff - Update and add tests for the new session-scoped behavior --- .../instance/httpapi/handlers/session.ts | 2 +- packages/opencode/src/session/session.ts | 49 +++- .../server/session-diff-missing-patch.test.ts | 33 +-- .../test/server/session-diff-scoped.test.ts | 242 ++++++++++++++++++ 4 files changed, 305 insertions(+), 21 deletions(-) create mode 100644 packages/opencode/test/server/session-diff-scoped.test.ts diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts index f6ac905e4..4ffe5d922 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/session.ts @@ -100,7 +100,7 @@ export const sessionHandlers = HttpApiBuilder.group(InstanceHttpApi, "session", params: { sessionID: SessionID } query: typeof DiffQuery.Type }) { - return yield* summary.diff({ sessionID: ctx.params.sessionID, messageID: ctx.query.messageID }) + return yield* session.diff(ctx.params.sessionID) }) const messages = Effect.fn("SessionHttpApi.messages")(function* (ctx: { diff --git a/packages/opencode/src/session/session.ts b/packages/opencode/src/session/session.ts index 2ba5f9f50..0047f7b8f 100644 --- a/packages/opencode/src/session/session.ts +++ b/packages/opencode/src/session/session.ts @@ -488,7 +488,7 @@ export type Patch = Omit, "time" | "share" | "summary" | "revert" const layer: Layer.Layer< Service, never, - BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service + BackgroundJob.Service | RuntimeFlags.Service | Database.Service | EventV2Bridge.Service | Snapshot.Service > = Layer.effect( Service, Effect.gen(function* () { @@ -497,6 +497,7 @@ const layer: Layer.Layer< const background = yield* BackgroundJob.Service const events = yield* EventV2Bridge.Service const flags = yield* RuntimeFlags.Service + const snapshot = yield* Snapshot.Service const createNext = Effect.fn("Session.createNext")(function* (input: { id?: SessionID @@ -835,8 +836,48 @@ const layer: Layer.Layer< }) const diff = Effect.fn("Session.diff")(function* (sessionID: SessionID) { - void sessionID - return [] as Snapshot.FileDiff[] + const all = yield* messages({ sessionID }).pipe(Effect.orDie) + if (!all.length) return [] as Snapshot.FileDiff[] + + // Find the first step-start snapshot hash (session-start ref) + let from: string | undefined + // Collect all agent-touched files from PatchParts (absolute paths) + const agentFilesAbsolute = new Set() + + for (const msg of all) { + for (const part of msg.parts) { + if (!from && part.type === "step-start" && part.snapshot) { + from = part.snapshot + } + if (part.type === "patch" && part.files) { + for (const file of part.files) agentFilesAbsolute.add(file) + } + } + } + + // No snapshot or no agent-touched files → empty + if (!from || agentFilesAbsolute.size === 0) return [] as Snapshot.FileDiff[] + + // Get current state + const to = yield* snapshot.track() + if (!to) return [] as Snapshot.FileDiff[] + + // Normalize agent-touched files to relative paths (diffFull returns relative paths) + const ctx = yield* InstanceState.context + const worktree = ctx.worktree + const agentFiles = new Set() + for (const abs of agentFilesAbsolute) { + const rel = abs.startsWith(worktree) + ? abs.slice(worktree.length).replace(/^\//, "").replaceAll("\\", "/") + : abs.replaceAll("\\", "/") + agentFiles.add(rel) + } + + // Compute full diff and filter to agent-touched files + const allDiffs = yield* snapshot.diffFull(from, to) + return allDiffs.filter( + (d) => d.file && agentFiles.has(d.file) && (d.additions ?? 0) + (d.deletions ?? 0) > 0, + ) }) const messages: Interface["messages"] = Effect.fn("Session.messages")(function* (input) { @@ -1024,7 +1065,7 @@ function listByProject( export const node = LayerNode.make({ service: Service, layer: layer, - deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node], + deps: [BackgroundJob.node, RuntimeFlags.node, Database.node, EventV2Bridge.node, Snapshot.node], }) export * as Session from "./session" diff --git a/packages/opencode/test/server/session-diff-missing-patch.test.ts b/packages/opencode/test/server/session-diff-missing-patch.test.ts index d2a4211ff..475944a91 100644 --- a/packages/opencode/test/server/session-diff-missing-patch.test.ts +++ b/packages/opencode/test/server/session-diff-missing-patch.test.ts @@ -1,14 +1,14 @@ /** - * Regression test for the same bug class as #26574 (sibling of #26566 and - * #26553). The Desktop app calls GET /session//diff; before #26574 - * the response was Schema-encoded against `Snapshot.FileDiff` with - * `patch: Schema.String` (required), so any session whose stored - * `summary_diffs` had a row without `patch` returned HTTP 400 and the - * session never loaded. Legacy session-level diffs are no longer surfaced, - * but the endpoint remains compatible and must still return successfully. + * Tests for GET /session//diff endpoint. * - * This test inserts a session row with a missing-patch diff entry and - * asserts that GET /session//diff returns 200 with empty data. + * After #174, this endpoint returns session-scoped agent diffs computed from + * snapshots (the net diff from session start to current, filtered to files the + * agent touched via PatchParts). Legacy per-message summary diffs are no longer + * served through this endpoint. + * + * - A session with no snapshot parts returns []. + * - A session with legacy storage-based diffs (missing `patch`) returns []. + * - The messageID query param is accepted but ignored (backwards compat). */ import { afterEach, describe, expect } from "bun:test" import { LayerNode } from "@opencode-ai/core/effect/layer-node" @@ -39,17 +39,16 @@ function pathFor(template: string, params: Record) { const withSession = (input?: Parameters[0]) => Effect.acquireRelease(Session.use.create(input), (created) => Session.use.remove(created.id).pipe(Effect.ignore)) -describe("session diff with missing patch (#26574)", () => { +describe("session-scoped diff (#174)", () => { it.instance( - "GET /session//diff ignores legacy session-level diff storage", + "GET /session//diff returns [] for session with legacy storage diffs", () => Effect.gen(function* () { const test = yield* TestInstance const session = yield* withSession({ title: "missing-patch" }) // Mimic legacy/imported on-disk shape: a diff entry with no - // `patch` text. Pre-fix the typed response encoder rejects - // this and returns 400. + // `patch` text. The endpoint no longer reads from storage. yield* Storage.Service.use((storage) => storage.write(["session_diff", session.id], [{ file: "legacy.txt", additions: 1, deletions: 0 }]), ) @@ -66,11 +65,11 @@ describe("session diff with missing patch (#26574)", () => { ) it.instance( - "GET /session//diff returns requested turn diffs", + "GET /session//diff returns [] for session with no snapshot parts", () => Effect.gen(function* () { const test = yield* TestInstance - const session = yield* withSession({ title: "turn-diff" }) + const session = yield* withSession({ title: "no-snapshots" }) const messageID = MessageID.ascending() yield* Session.use.updateMessage({ id: messageID, @@ -84,13 +83,15 @@ describe("session diff with missing patch (#26574)", () => { }, } satisfies SessionV1.User) + // Even with messageID param, the endpoint returns session-scoped diffs + // (which is [] because there are no step-start/patch parts) const response = yield* requestInDirectory( `${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`, test.directory, ) expect(response.status).toBe(200) - expect(yield* response.json).toEqual([{ file: "turn.ts", additions: 1, deletions: 0, status: "modified" }]) + expect(yield* response.json).toEqual([]) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/opencode/test/server/session-diff-scoped.test.ts b/packages/opencode/test/server/session-diff-scoped.test.ts new file mode 100644 index 000000000..78d3b617f --- /dev/null +++ b/packages/opencode/test/server/session-diff-scoped.test.ts @@ -0,0 +1,242 @@ +/** + * Integration test for session-scoped diffs (#174). + * + * Verifies that GET /session/:id/diff returns the net diff (session-start + * snapshot vs current state) filtered to only files the agent touched (tracked + * via PatchParts). This replaces the old per-turn/per-message diff model. + */ +import { afterEach, describe, expect } from "bun:test" +import { LayerNode } from "@opencode-ai/core/effect/layer-node" +import { FSUtil } from "@opencode-ai/core/fs-util" +import path from "path" +import { Effect, Layer } from "effect" +import { Session } from "@/session/session" +import { Snapshot } from "@/snapshot" +import { Storage } from "@/storage/storage" +import { SessionPaths } from "@/server/routes/instance/httpapi/groups/session" +import { MessageID, PartID } from "@/session/schema" +import { ProviderV2 } from "@opencode-ai/core/provider" +import { ModelV2 } from "@opencode-ai/core/model" +import { SessionV1 } from "@opencode-ai/core/v1/session" +import { resetDatabase } from "../fixture/db" +import { disposeAllInstances, TestInstance } from "../fixture/fixture" +import { testEffect } from "../lib/effect" +import { httpApiLayer, requestInDirectory } from "./httpapi-layer" + +const it = testEffect( + Layer.mergeAll( + LayerNode.compile(LayerNode.group([Session.node, Snapshot.node, Storage.node, FSUtil.node])), + httpApiLayer, + ), +) + +afterEach(async () => { + await disposeAllInstances() + await resetDatabase() +}) + +function pathFor(template: string, params: Record) { + return Object.entries(params).reduce((result, [key, value]) => result.replace(`:${key}`, value), template) +} + +const withSession = (input?: Parameters[0]) => + Effect.acquireRelease(Session.use.create(input), (created) => Session.use.remove(created.id).pipe(Effect.ignore)) + +describe("Session.diff — session-scoped agent diffs (#174)", () => { + it.instance( + "returns [] for session with no messages", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "empty-session" }) + + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "returns [] when session has no step-start parts", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "no-snapshots" }) + const messageID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: messageID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + } satisfies SessionV1.User) + + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "returns net diff for agent-touched files across the session", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "agent-diffs" }) + const snapshot = yield* Snapshot.Service + const fs = yield* FSUtil.Service + + // Write an initial file before the session starts + yield* fs.writeWithDirs(path.join(test.directory, "existing.txt"), "original content") + // Take the session-start snapshot + const startHash = yield* snapshot.track() + expect(startHash).toBeTruthy() + + // Create a user message (parts are attached to it for simplicity) + const userMsgID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: userMsgID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + } satisfies SessionV1.User) + + // Attach step-start part (records session-start snapshot) + yield* Session.use.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: userMsgID, + type: "step-start", + snapshot: startHash!, + }) + + // Agent writes a new file and modifies an existing one + yield* fs.writeWithDirs(path.join(test.directory, "new-file.ts"), "export const x = 1") + yield* fs.writeWithDirs(path.join(test.directory, "existing.txt"), "modified content") + + // Also write a file that the agent did NOT touch (external change) + yield* fs.writeWithDirs(path.join(test.directory, "external.txt"), "external change") + + // Record a patch part with agent-touched files + const agentFiles = [ + path.join(test.directory, "new-file.ts"), + path.join(test.directory, "existing.txt"), + ] + yield* Session.use.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: userMsgID, + type: "patch", + hash: startHash!, + files: agentFiles, + }) + + // Query the session diff via the HTTP endpoint + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + expect(response.status).toBe(200) + const diffs = (yield* response.json) as Array<{ file: string; additions: number; deletions: number }> + + // Should include agent-touched files only (not external.txt) + const files = diffs.map((d) => d.file) + expect(files).toContain("new-file.ts") + expect(files).toContain("existing.txt") + expect(files).not.toContain("external.txt") + expect(diffs.length).toBe(2) + + // Each diff should have non-zero additions/deletions + for (const d of diffs) { + expect((d.additions ?? 0) + (d.deletions ?? 0)).toBeGreaterThan(0) + } + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "filters out files where agent edits were externally reverted (zero diff)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "reverted-diffs" }) + const snapshot = yield* Snapshot.Service + const fs = yield* FSUtil.Service + + // Write a file + yield* fs.writeWithDirs(path.join(test.directory, "reverted.txt"), "original") + const startHash = yield* snapshot.track() + expect(startHash).toBeTruthy() + + // Create a user message + const userMsgID = MessageID.ascending() + yield* Session.use.updateMessage({ + id: userMsgID, + sessionID: session.id, + role: "user", + time: { created: Date.now() }, + agent: "build", + model: { providerID: ProviderV2.ID.make("test"), modelID: ModelV2.ID.make("model") }, + } satisfies SessionV1.User) + + yield* Session.use.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: userMsgID, + type: "step-start", + snapshot: startHash!, + }) + + // Record the file as agent-touched + yield* Session.use.updatePart({ + id: PartID.ascending(), + sessionID: session.id, + messageID: userMsgID, + type: "patch", + hash: startHash!, + files: [path.join(test.directory, "reverted.txt")], + }) + + // File is back to its original content → net diff is zero + // (we didn't actually change it from the snapshot state) + + const response = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) + + it.instance( + "messageID query param is accepted but ignored (backwards compat)", + () => + Effect.gen(function* () { + const test = yield* TestInstance + const session = yield* withSession({ title: "compat" }) + + // Passing messageID should still work (200) but returns session-scoped results + const messageID = MessageID.ascending() + const response = yield* requestInDirectory( + `${pathFor(SessionPaths.diff, { sessionID: session.id })}?messageID=${messageID}`, + test.directory, + ) + expect(response.status).toBe(200) + expect(yield* response.json).toEqual([]) + }), + { git: true, config: { formatter: false, lsp: false } }, + ) +}) From ee0af8dc2e08f779d25dae3306788a5f233e9c18 Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Mon, 10 Aug 2026 20:20:34 +0200 Subject: [PATCH 02/20] feat(app): replace VCS mode selector with session-scoped diffs (#174) Remove the git/branch/turn mode system from the Files Changed panel. Replace it with a single query to GET /session/:id/diff which returns the net diff of all files the agent touched during the session. - Remove ChangeMode/VcsMode types and all mode-related signals - Remove vcsQuery, vcsKey, refreshVcs, changesOptions, turnDiffs, nogit - Add sessionDiffQuery that calls sdk().client.session.diff() - Simplify loadReviewDiff to look up from the already-fetched diffs - Replace mode selector dropdown with static 'Session changes' label - Simplify empty state (no more mode-specific messages) - Keep file tree and diff viewer components untouched --- packages/app/src/pages/session.tsx | 197 ++++------------------------- 1 file changed, 28 insertions(+), 169 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 183ae8e9e..245bb27a5 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import type { FilePart, Project, UserMessage, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FilePart, Project, SnapshotFileDiff, UserMessage } from "@opencode-ai/sdk/v2" import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createQuery, skipToken, useMutation, useQueryClient } from "@tanstack/solid-query" @@ -28,8 +28,6 @@ import { FileProvider, selectionFromLines, useFile, type FileSelection, type Sel import { createStore } from "solid-js/store" import type { SessionReviewLineComment } from "@opencode-ai/session-ui/session-review" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" -import { Select } from "@opencode-ai/ui/select" -import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { isScrollKeyTarget, scrollKey, scrollKeyOwner } from "@opencode-ai/ui/scroll-view" import { Tabs } from "@opencode-ai/ui/tabs" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" @@ -88,10 +86,8 @@ import { import { SessionSidePanel } from "@/pages/session/session-side-panel" import { sessionPanelLayout } from "@/pages/session/session-panel-layout" import { SessionReviewEmptyChangesV2 } from "@opencode-ai/session-ui/v2/session-review-empty-changes-v2" -import { SessionReviewEmptyNoGitV2 } from "@opencode-ai/session-ui/v2/session-review-empty-no-git-v2" import { ReviewPanelV2 } from "@/pages/session/v2/review-panel-v2" import { createReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" -import { reviewDiffDirectory, reviewDiffNeedsLoad, reviewRootDirectory } from "@/pages/session/v2/review-diff-kinds" import { TerminalPanel } from "@/pages/session/terminal-panel" import { TerminalPanelV2 } from "@/pages/session/terminal-panel-v2" import { useComposerCommands } from "@/pages/session/use-composer-commands" @@ -99,7 +95,6 @@ import { useSessionCommands } from "@/pages/session/use-session-commands" import { useAmicodeCommands } from "@/pages/session/use-amicode-commands" import { useSessionHashScroll } from "@/pages/session/use-session-hash-scroll" import { Identifier } from "@/utils/id" -import { diffs as list } from "@/utils/diffs" import { Persist, persisted } from "@/utils/persist" import { extractPromptFromParts } from "@/utils/prompt" import { formatServerError, isLocalSessionNotFoundError, isSessionNotFoundError } from "@/utils/server-errors" @@ -115,9 +110,6 @@ type FollowupItem = FollowupDraft & { id: string } type FollowupEdit = Pick const emptyFollowups: FollowupItem[] = [] -type ChangeMode = "git" | "branch" | "turn" -type VcsMode = "git" | "branch" - const sessionViewState = () => ({ messageId: undefined as string | undefined, mobileTab: "session" as "session" | "changes", @@ -378,7 +370,6 @@ export default function Page() { const location = useLocation() const navigate = useNavigate() const { params, sessionKey, workspaceKey, tabs, view } = useSessionLayout() - const reviewMode = () => view().review.mode() ?? "git" const reviewFile = () => view().review.file() const sessionOwnership = createSessionOwnership(sessionKey) const newSessionDesign = createMemo(() => settings.general.newLayoutDesigns()) @@ -675,22 +666,6 @@ export default function Page() { return open }, desktopReviewOpen()) - const turnDiffs = createMemo(() => list(lastUserMessage()?.summary?.diffs)) - const nogit = createMemo(() => { - const project = sync().project - return !!project && project.vcs !== "git" - }) - const changesOptions = createMemo(() => { - const list: ChangeMode[] = [] - const project = sync().project - const vcs = sync().data.vcs - if (project?.vcs === "git") list.push("git") - if (project?.vcs === "git" && vcs?.branch && vcs?.default_branch && vcs.branch !== vcs.default_branch) { - list.push("branch") - } - list.push("turn") - return list - }) const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") const wantsReview = createMemo(() => isDesktop() @@ -698,40 +673,27 @@ export default function Page() { (desktopReviewOpen() && (activeTab() === "review" || (newSessionDesign() && !!activeFileTab()))) : store.mobileTab === "changes", ) - const vcsMode = createMemo(() => { - const mode = reviewMode() - if (mode === "git" || mode === "branch") return mode - }) - const vcsKey = createMemo( - () => - ["session-vcs", sdk().directory, sync().data.vcs?.branch ?? "", sync().data.vcs?.default_branch ?? ""] as const, - ) - const vcsQuery = createQuery(() => { - const mode = vcsMode() - const enabled = wantsReview() && sync().project?.vcs === "git" - + const sessionDiffKey = () => ["session-diff", params.id ?? ""] as const + const sessionDiffQuery = createQuery(() => { + const sessionID = params.id + const enabled = !!sessionID && wantsReview() return { - queryKey: [...vcsKey(), mode] as const, + queryKey: sessionDiffKey(), enabled, - queryFn: mode + queryFn: sessionID ? () => sdk() - .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) - .then((result) => result.data) + .client.session.diff({ sessionID, directory: sdk().directory }) + .then((result) => result.data ?? []) .catch((error) => { - console.debug("[session-review] failed to load vcs diff", { mode, error }) + console.debug("[session-review] failed to load session diff", { error }) return [] }) : skipToken, } }) - const refreshVcs = debounce(() => void queryClient.invalidateQueries({ queryKey: vcsKey() }), 100) - const reviewDiffs = () => { - if (reviewMode() === "git" || reviewMode() === "branch") - // avoids suspense - return vcsQuery.isFetched ? (vcsQuery.data ?? []) : [] - return turnDiffs() - } + const refreshSessionDiff = debounce(() => void queryClient.invalidateQueries({ queryKey: sessionDiffKey() }), 100) + const reviewDiffs = () => (sessionDiffQuery.isFetched ? (sessionDiffQuery.data ?? []) : []) const activeReviewFile = () => { const diffs = reviewDiffs() const selected = reviewFile() @@ -740,53 +702,12 @@ export default function Page() { } const reviewCount = () => reviewDiffs().length const hasReview = () => reviewCount() > 0 - const reviewReady = () => { - if (reviewMode() === "git" || reviewMode() === "branch") return !vcsQuery.isPending - return true - } - const loadReviewDiff = async (file: string, version?: number): Promise => { - const mode = vcsMode() - if (!mode) return - const root = reviewRootDirectory(sync().project?.worktree ?? sdk().directory) - const directory = reviewDiffDirectory(root, file) - const source = reviewDiffs().find((diff) => diff.file === file) - const valid = (diff: VcsFileDiff | undefined) => { - if (!diff || !source) return - if (diff.additions !== source.additions || diff.deletions !== source.deletions) return - if (reviewDiffNeedsLoad(diff)) return - return diff - } - const request = (scope: string, context?: number) => - queryClient - .fetchQuery({ - queryKey: [serverSDK().scope, ...vcsKey(), mode, "directory", scope, context, version] as const, - staleTime: Number.POSITIVE_INFINITY, - retry: 2, - queryFn: () => - sdk() - .api.vcs.diff({ - location: { directory: scope }, - mode: mode === "git" ? "working" : mode, - context, - }) - .then((result) => result.data), - }) - .then((diffs) => diffs.find((diff) => diff.file === file)) - - if (directory !== root) { - try { - const scoped = valid(await request(directory)) - if (scoped) return scoped - } catch (error) { - console.debug("[session-review] failed to load scoped vcs diff", { mode, file, directory, error }) - } - } - try { - const bounded = valid(await request(root, 3)) - if (bounded) return bounded - } catch (error) { - console.debug("[session-review] failed to load bounded vcs diff", { mode, file, root, error }) - } + const reviewReady = () => !sessionDiffQuery.isPending + const loadReviewDiff = async (file: string, _version?: number): Promise<(SnapshotFileDiff & { file: string }) | undefined> => { + const diffs = reviewDiffs() + const found = diffs.find((d) => d.file === file) + if (found && found.file) return found as SnapshotFileDiff & { file: string } + return undefined } const newSessionWorktree = createMemo(() => { @@ -984,7 +905,7 @@ export default function Page() { : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return - refreshVcs() + refreshSessionDiff() }) onCleanup(stopVcs) @@ -1108,24 +1029,12 @@ export default function Page() { } } - createEffect(() => { - if (!layout.ready()) return - if (sync().status !== "complete") return - if (!sync().project) return - const list = changesOptions() - const mode = reviewMode() - if (list.includes(mode)) return - const next = list[0] - if (!next) return - view().review.setMode(next) - }) - createEffect( on( () => sync().data.session_status[params.id ?? ""]?.type, (next, prev) => { if (next !== "idle" || prev === undefined || prev === "idle") return - refreshVcs() + refreshSessionDiff() }, { defer: true }, ), @@ -1188,46 +1097,14 @@ export default function Page() { loadFile: file.load, }) - const changesLabel = (option: ChangeMode) => { - if (option === "git") return language.t("ui.sessionReview.title.git") - if (option === "branch") return language.t("ui.sessionReview.title.branch") - return language.t("ui.sessionReview.title.lastTurn") - } - const changesTitle = () => { - if (!canReview()) { - return null - } - - return ( -