Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 12 additions & 4 deletions packages/app/src/pages/session.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -695,7 +695,7 @@ export default function Page() {
return {
queryKey: sessionDiffKey(),
enabled: !!sessionID,
placeholderData: [] as SnapshotFileDiff[],
placeholderData: keepPreviousData,
queryFn: sessionID
? () =>
sdk()
Expand All @@ -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
Expand Down Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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,
Expand Down
20 changes: 20 additions & 0 deletions packages/app/src/pages/session/v2/accumulate-diffs.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
})
})
2 changes: 2 additions & 0 deletions packages/app/src/pages/session/v2/review-panel-v2.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()

Expand Down
9 changes: 9 additions & 0 deletions packages/opencode/test/server/session-diff-scoped.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 } },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -40,6 +42,7 @@ export type SessionReviewFilePreviewV2Props = {
readFile?: (path: string) => Promise<FileContent | undefined>
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
Expand All @@ -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"
Expand Down Expand Up @@ -258,11 +267,42 @@ export function SessionReviewFilePreviewV2(props: SessionReviewFilePreviewV2Prop
return (
<>
<div data-slot="session-review-v2-file-header">
<Show when={props.onRefresh}>
{(handler) => (
<TooltipV2 openDelay={500} value="Refresh">
<button
type="button"
aria-label="Refresh"
onClick={handler()}
style={{
display: "flex",
"align-items": "center",
"justify-content": "center",
width: "10px",
height: "10px",
padding: "0",
margin: "0 -4px 0 0",
border: "none",
background: "none",
cursor: "pointer",
color: "var(--icon-base)",
"flex-shrink": "0",
}}
onMouseEnter={(e) => (e.currentTarget.style.color = "var(--icon-hover, var(--icon-base))")}
onMouseLeave={(e) => (e.currentTarget.style.color = "var(--icon-base)")}
>
<Icon name="refresh" size="small" />
</button>
</TooltipV2>
)}
</Show>
<MenuV2.Context>
<MenuV2.Context.Trigger as="div" data-slot="session-review-v2-file-title">
<div data-slot="session-review-v2-file-status" data-type={statusType(view().status)}>
{statusLabel(view().status)}
</div>
<TooltipV2 openDelay={500} value={statusTooltip(view().status)}>
<div data-slot="session-review-v2-file-status" data-type={statusType(view().status)}>
{statusLabel(view().status)}
</div>
</TooltipV2>
<FileIcon node={{ path: props.file, type: "file" }} />
<FileNameWithPicker
file={props.file}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -313,7 +313,7 @@
display: flex;
align-items: center;
gap: 8px;
padding: 8px 16px;
padding: 8px 16px 8px 8px;
flex-shrink: 0;
border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak));
}
Expand Down
4 changes: 4 additions & 0 deletions packages/ui/src/v2/components/icon.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,10 @@ const icons = {
viewBox: "0 0 20 20",
body: `<path d="M5.83333 4.16406L2.5 7.4974L5.83333 10.8307M3.33333 7.4974H17.9167V15.4141H10" stroke="currentColor" stroke-linecap="square"/>`,
},
refresh: {
viewBox: "0 0 16 16",
body: `<path d="M12.5 8A4.5 4.5 0 1 1 8 3.5" stroke="currentColor" stroke-width="1.25" stroke-linecap="round"/><path d="M6 1.5L8.5 3.5L6 5.5" transform="rotate(-15 8.5 3.5)" stroke="currentColor" stroke-width="1.25" stroke-linecap="round" stroke-linejoin="round"/>`,
},
archive: {
viewBox: "0 0 16 16",
body: `<path d="M13.1112 13.5555V14.0555H13.6112V13.5555H13.1112ZM2.889 13.5555H2.389L2.389 14.0555H2.889V13.5555ZM3.38901 5.55546L3.38901 5.05546L2.38901 5.05546L2.38901 5.55546L2.88901 5.55546L3.38901 5.55546ZM14.4446 2.44434H14.9446V1.94434L14.4446 1.94434L14.4446 2.44434ZM14.4446 5.55545L14.4446 6.05545L14.9446 6.05545V5.55545H14.4446ZM1.55566 5.55546L1.05566 5.55545L1.05566 6.05546L1.55566 6.05546L1.55566 5.55546ZM1.5557 2.44436L1.5557 1.94436L1.05571 1.94436L1.0557 2.44436ZM13.1112 5.55546H12.6112V13.5555H13.1112H13.6112V5.55546H13.1112ZM2.889 13.5555H3.389L3.38901 5.55546L2.88901 5.55546L2.889 13.5555H2.889ZM14.4446 2.44434H13.9446V5.55545H14.4446H14.9446V2.44434H14.4446ZM1.55566 5.55546L2.05566 5.55547L2.0557 2.44436L1.5557 2.44436L1.05571 2.44435L1.05566 5.55546ZM6.22234 8.22213V8.72213H9.7779V8.22213V7.72213H6.22234V8.22213ZM13.1112 13.5555V13.0555H2.889V13.5555V14.0555H13.1112V13.5555ZM1.5557 2.44436L1.5557 2.94436L14.4446 2.94434L14.4446 2.44434L14.4446 1.94434L1.5557 1.94436L1.5557 2.44436ZM14.4446 5.55545L14.4446 5.05545L1.55566 5.05546L1.55566 6.05546L14.4446 6.05545L14.4446 5.55545Z" fill="currentColor"/>`,
Expand Down
Loading