From b1219e82826e99f51ccc02a6322a3bcdccf5d36b Mon Sep 17 00:00:00 2001 From: JJ Lee Date: Thu, 3 Sep 2026 08:33:52 -0400 Subject: [PATCH] feat(review): add refresh button to Files Changed toolbar (#744) Add a circular-arrow refresh icon to the icon sprite and a refresh button in the panel toolbar (after the Unified/Split toggle). Clicking it bumps diff_version for the active session, triggering a full diff refetch. The button is visible even in the empty state (toolbar shows when onRefresh is provided). Closes harmoniqs/amicode#744 --- packages/app/src/pages/session.tsx | 16 +++++-- .../pages/session/v2/accumulate-diffs.test.ts | 20 ++++++++ .../src/pages/session/v2/review-panel-v2.tsx | 2 + .../test/server/session-diff-scoped.test.ts | 9 ++++ .../session-review-file-preview-v2.tsx | 46 +++++++++++++++++-- .../src/v2/components/session-review-v2.css | 2 +- packages/ui/src/v2/components/icon.tsx | 4 ++ 7 files changed, 91 insertions(+), 8 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 208a214c3..3334c7532 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,7 +1,7 @@ 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 } from "@tanstack/solid-query" +import { createQuery, keepPreviousData, skipToken, useMutation } from "@tanstack/solid-query" import { batch, ErrorBoundary, @@ -695,7 +695,7 @@ export default function Page() { return { queryKey: sessionDiffKey(), enabled: !!sessionID, - placeholderData: [] as SnapshotFileDiff[], + placeholderData: keepPreviousData, queryFn: sessionID ? () => sdk() @@ -708,7 +708,11 @@ export default function Page() { const reviewDiffs = createMemo(() => { // Server endpoint returns the authoritative full-session diff (queries all messages). const serverDiffs = sessionDiffQuery.data ?? [] - if (serverDiffs.length > 0) { + // Once the server has responded at least once, trust it — even if it returned []. + // The client-side fallback is only for the initial load before any server response, + // never during refetches (where keepPreviousData already preserves the last result). + const serverResponded = sessionDiffQuery.status === "success" || sessionDiffQuery.isPlaceholderData + if (serverDiffs.length > 0 || serverResponded) { // Server paths are relative to the project root — prefix with ~/project-path const dir = sdk().directory const home = typeof globalThis.process !== "undefined" ? globalThis.process.env?.HOME : undefined @@ -761,7 +765,7 @@ export default function Page() { return { queryKey: ["session-touched-files", sessionID ?? "", sessionDiffVersion()] as const, enabled: !!sessionID, - placeholderData: [] as Array<{ file: string; status: string }>, + placeholderData: keepPreviousData, staleTime: 30_000, queryFn: sessionID ? async () => { @@ -1315,6 +1319,10 @@ export default function Page() { }, onDiffStyleChange: layout.review.setDiffStyle, state: reviewV2State, + onRefresh: () => { + const id = params.id + if (id) sync().set("diff_version", id, (v: number | undefined) => (v ?? 0) + 1) + }, onLineComment: (comment: SessionReviewLineComment) => addCommentToContext({ ...comment, origin: "review" }), onLineCommentUpdate: updateCommentInContext, onLineCommentDelete: removeCommentFromContext, diff --git a/packages/app/src/pages/session/v2/accumulate-diffs.test.ts b/packages/app/src/pages/session/v2/accumulate-diffs.test.ts index 422abf52e..cec265861 100644 --- a/packages/app/src/pages/session/v2/accumulate-diffs.test.ts +++ b/packages/app/src/pages/session/v2/accumulate-diffs.test.ts @@ -91,4 +91,24 @@ describe("accumulateDiffs", () => { test("empty input returns empty output", () => { expect(accumulateDiffs([])).toEqual([]) }) + + test("multi-edit sums diverge from net diff (documents flash risk)", () => { + // A file edited 3 times: +3/-1, +2/-4, +1/-0 + // accumulateDiffs sums: +6/-5 + // A real git net diff might be +2/-1 (or anything else) + // This divergence is what the user sees as a "flash" when the client + // fallback briefly replaces the server's net diff during a refetch. + const result = accumulateDiffs([ + edit("src/a.ts", { additions: 3, deletions: 1, patch: "p1" }), + edit("src/a.ts", { additions: 2, deletions: 4, patch: "p2" }), + edit("src/a.ts", { additions: 1, deletions: 0, patch: "p3" }), + ]) + // The fallback SUMS, not nets — this is the documented behavior that + // makes the flash visible (different numbers than the server response). + expect(result[0].additions).toBe(6) + expect(result[0].deletions).toBe(5) + // A hypothetical net diff would be lower — the client must never show + // this stale/inflated data during a refetch. The fix is keepPreviousData + // on the query so the fallback never fires while server data exists. + }) }) diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 9767b0c19..a10099bbc 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -46,6 +46,7 @@ export type ReviewPanelV2Props = { diffStyle: SessionReviewDiffStyle onDiffStyleChange?: (style: SessionReviewDiffStyle) => void state: ReviewPanelV2State + onRefresh?: () => void onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void @@ -152,6 +153,7 @@ export function ReviewPanelV2(props: ReviewPanelV2Props) { diffStyle={props.diffStyle} expandMode={props.state.expandMode()} readFile={readFile} + onRefresh={props.onRefresh} filePicker={({ onSelect }) => { const files = filteredFiles() diff --git a/packages/opencode/test/server/session-diff-scoped.test.ts b/packages/opencode/test/server/session-diff-scoped.test.ts index 56f5b5a4f..0331ee842 100644 --- a/packages/opencode/test/server/session-diff-scoped.test.ts +++ b/packages/opencode/test/server/session-diff-scoped.test.ts @@ -207,6 +207,15 @@ describe("Session.diff — session-scoped agent diffs (#174)", () => { for (const d of diffs) { expect((d.additions ?? 0) + (d.deletions ?? 0)).toBeGreaterThan(0) } + + // Idempotency: a second call returns the exact same result (#744 flash fix) + const response2 = yield* requestInDirectory( + pathFor(SessionPaths.diff, { sessionID: session.id }), + test.directory, + ) + expect(response2.status).toBe(200) + const diffs2 = yield* response2.json + expect(diffs2).toEqual(diffs) }), { git: true, config: { formatter: false, lsp: false } }, ) diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 4f3e28f09..7adcd82e6 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -5,6 +5,8 @@ import { FileIcon } from "@opencode-ai/ui/file-icon" import { useFileComponent } from "@opencode-ai/ui/context/file" import { useI18n } from "@opencode-ai/ui/context/i18n" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { Icon } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import { copyTextToClipboard } from "../../util/clipboard" @@ -40,6 +42,7 @@ export type SessionReviewFilePreviewV2Props = { readFile?: (path: string) => Promise filePicker?: (pickerProps: { onSelect: (path: string) => void }) => JSX.Element onSelectFile?: (file: string) => void + onRefresh?: () => void onLineComment?: (comment: SessionReviewLineComment) => void onLineCommentUpdate?: (comment: SessionReviewCommentUpdate) => void onLineCommentDelete?: (comment: SessionReviewCommentDelete) => void @@ -55,6 +58,12 @@ function statusLabel(status: ViewDiff["status"]) { return "M" } +function statusTooltip(status: ViewDiff["status"]) { + if (status === "added") return "Added" + if (status === "deleted") return "Deleted" + return "Modified" +} + function statusType(status: ViewDiff["status"]) { if (status === "added") return "added" if (status === "deleted") return "deleted" @@ -258,11 +267,42 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop return ( <>
+ + {(handler) => ( + + + + )} + -
- {statusLabel(view().status)} -
+ +
+ {statusLabel(view().status)} +
+
`, }, + refresh: { + viewBox: "0 0 16 16", + body: ``, + }, archive: { viewBox: "0 0 16 16", body: ``,