From bb390f435c472a41ce7899edf80a3aafbf71cc11 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 28 Aug 2026 14:31:18 +1000 Subject: [PATCH] fix(session-ui): enable word diffs in unified view (#45833) --- .../session-ui/component-tests/file.spec.ts | 35 +++++++++ .../session-review-comments.spec.ts | 2 +- packages/session-ui/src/components/file.tsx | 5 +- .../src/components/session-review.stories.tsx | 78 +++++++++++++++++++ packages/session-ui/src/pierre/index.ts | 2 +- packages/session-ui/src/pierre/worker.ts | 23 +++--- .../src/v2/components/session-review-v2.tsx | 2 +- 7 files changed, 130 insertions(+), 17 deletions(-) create mode 100644 packages/session-ui/component-tests/file.spec.ts diff --git a/packages/session-ui/component-tests/file.spec.ts b/packages/session-ui/component-tests/file.spec.ts new file mode 100644 index 00000000000..c64ae01e88a --- /dev/null +++ b/packages/session-ui/component-tests/file.spec.ts @@ -0,0 +1,35 @@ +import { expect, story } from "../../storybook/playwright/story" + +for (const split of [false, true]) { + for (const theme of ["light", "dark"]) { + story(`highlights changed words in ${split ? "split" : "unified"} ${theme} diffs`, async ({ mount }) => { + const root = await mount("components-session-review--inline-changes", { args: { split }, globals: { theme } }) + const diffs = root.locator("diffs-container") + await expect(diffs).toHaveCount(2) + for (const diff of await diffs.all()) { + await expect(diff.locator('[data-line] [style*="--syntax-"]')).not.toHaveCount(0) + } + const additions = root.locator('[data-line-type="change-addition"] [data-diff-span]') + await expect(additions).toHaveText(["select-text"]) + await expect(root.locator('[data-line-type="context"] [data-diff-span]')).toHaveCount(0) + await expect(root.locator('[data-line-type="change-deletion"] [data-diff-span]')).toHaveText([ + '"http" in', + "? error.reason.", + "response?.", + ": undefined", + ]) + await expect(additions).not.toHaveCSS("background-color", "rgba(0, 0, 0, 0)") + }) + } + + for (const source of ["files", "metadata"]) { + story(`skips word diffs for large ${split ? "split" : "unified"} ${source}`, async ({ mount }) => { + const root = await mount("components-session-review--large-file", { args: { split, source } }) + const line = root.locator('[data-line][data-line-type="change-addition"]') + await expect(line).toHaveText("export const value = 'after'") + // Plain first paint is not proof that the worker kept inline diffs disabled. + await expect(line.locator('[style*="--syntax-"]')).not.toHaveCount(0) + await expect(root.locator("[data-diff-span]")).toHaveCount(0) + }) + } +} diff --git a/packages/session-ui/component-tests/session-review-comments.spec.ts b/packages/session-ui/component-tests/session-review-comments.spec.ts index 0924f1bc958..14b01d728bd 100644 --- a/packages/session-ui/component-tests/session-review-comments.spec.ts +++ b/packages/session-ui/component-tests/session-review-comments.spec.ts @@ -4,7 +4,7 @@ import { expect, story } from "../../storybook/playwright/story" story("opens the comment editor when code is clicked", async ({ mount }) => { const root = await mount("components-session-review--interactive-comments") const review = root.locator('[data-component="session-review"]') - await review.getByText("export const value = 'after'", { exact: true }).click() + await review.locator('[data-line-type="change-addition"] [data-diff-span]').click() await expect(review.getByRole("textbox")).toBeVisible() await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") }) diff --git a/packages/session-ui/src/components/file.tsx b/packages/session-ui/src/components/file.tsx index 4876731cbd3..470248fa071 100644 --- a/packages/session-ui/src/components/file.tsx +++ b/packages/session-ui/src/components/file.tsx @@ -906,7 +906,7 @@ function TextViewer(props: TextFileProps) { createEffect(() => { const opts = options() - const workerPool = getWorkerPool("unified") + const workerPool = getWorkerPool() const virtualizer = virtuals.get() renderViewer({ @@ -1111,7 +1111,8 @@ function DiffViewer(props: DiffFileProps) { createEffect(() => { const opts = options() - const workerPool = large() ? getWorkerPool("unified") : getWorkerPool(props.diffStyle) + // Worker render options override per-viewer options, including the large-file fallback. + const workerPool = getWorkerPool(large() ? "none" : "word-alt") const virtualizer = virtuals.get() const beforeContents = typeof local.before?.contents === "string" ? local.before.contents : "" const afterContents = typeof local.after?.contents === "string" ? local.after.contents : "" diff --git a/packages/session-ui/src/components/session-review.stories.tsx b/packages/session-ui/src/components/session-review.stories.tsx index d78a8f05af2..1efb104943b 100644 --- a/packages/session-ui/src/components/session-review.stories.tsx +++ b/packages/session-ui/src/components/session-review.stories.tsx @@ -1,6 +1,8 @@ import { createStore } from "solid-js/store" +import { parseDiffFromFile } from "@pierre/diffs" import { CurrentSessionProviders } from "../storybook/current-session-story" import { editThenTestDocument, reviewDiffs } from "../storybook/current-session-fixtures" +import { File } from "./file" import { SessionReview, type SessionReviewComment } from "./session-review" function ReviewStory(props: { split?: boolean }) { @@ -77,3 +79,79 @@ function InteractiveCommentsStory() { } export const InteractiveComments = { render: () => } + +const gitDiffs = [ + { + // OpenCode 93e1f383dd79683af4fc5ad139cea0516603c838, unchanged git-show output. + file: "packages/session-ui/src/components/file.tsx", + additions: 1, + deletions: 1, + patch: `diff --git a/packages/session-ui/src/components/file.tsx b/packages/session-ui/src/components/file.tsx +index 704971b014..4876731cbd 100644 +--- a/packages/session-ui/src/components/file.tsx ++++ b/packages/session-ui/src/components/file.tsx +@@ -702,7 +702,7 @@ function ViewerShell(props: { + data-mode={props.mode} + dir="ltr" + style={styleVariables} +- class="relative outline-none" ++ class="relative select-text outline-none" + classList={{ + ...props.classList, + [props.class ?? ""]: !!props.class, +`, + }, + { + // OpenCode 497a24c17d, unchanged git-show output. + file: "packages/core/src/session/runner/retry.ts", + additions: 1, + deletions: 1, + patch: `diff --git a/packages/core/src/session/runner/retry.ts b/packages/core/src/session/runner/retry.ts +index 10f6680097..ef26792ffe 100644 +--- a/packages/core/src/session/runner/retry.ts ++++ b/packages/core/src/session/runner/retry.ts +@@ -15,7 +15,7 @@ export interface Input { + } +\x20 + export function isRetryable(error: AIError) { +- const override = "http" in error.reason ? error.reason.http?.response?.headers["x-should-retry"] : undefined ++ const override = error.reason.http?.headers["x-should-retry"] + if (override === "true") return true + if (override === "false") return false + switch (error.reason._tag) { +`, + }, +] + +export const InlineChanges = { + args: { split: false }, + render: (args: { split: boolean }) => ( + +
+ diff.file)} + split={args.split} + /> +
+
+ ), +} + +export const LargeFile = { + args: { split: false, source: "files" }, + argTypes: { source: { control: "select", options: ["files", "metadata"] } }, + render: (args: { split: boolean; source: string }) => { + // Cross the viewer's 500,000-character limit without long changed lines or 1,000 total lines. + const padding = `// ${"unchanged ".repeat(70)}\n`.repeat(800) + const before = { name: "large.ts", contents: `export const value = 'before'\n${padding}` } + const after = { name: "large.ts", contents: `export const value = 'after'\n${padding}` } + const input = args.source === "metadata" ? { fileDiff: parseDiffFromFile(before, after) } : { before, after } + return ( +
+ +
+ ) + }, +} diff --git a/packages/session-ui/src/pierre/index.ts b/packages/session-ui/src/pierre/index.ts index 714110d92a2..9f038f139a4 100644 --- a/packages/session-ui/src/pierre/index.ts +++ b/packages/session-ui/src/pierre/index.ts @@ -197,7 +197,7 @@ export function createDefaultOptions(style: FileDiffOptions["diffStyle"]) disableBackground: false, expansionLineCount: 20, hunkSeparators: "line-info-basic", - lineDiffType: style === "split" ? "word-alt" : "none", + lineDiffType: "word-alt", maxLineDiffLength: 1000, maxLineLengthForHighlighting: 1000, disableFileHeader: true, diff --git a/packages/session-ui/src/pierre/worker.ts b/packages/session-ui/src/pierre/worker.ts index 1c1bebb40b5..d092dae43e7 100644 --- a/packages/session-ui/src/pierre/worker.ts +++ b/packages/session-ui/src/pierre/worker.ts @@ -4,8 +4,6 @@ import { registerOpenCodeTheme } from "@opencode-ai/ui/context/marked-theme-regi registerOpenCodeTheme() -export type WorkerPoolStyle = "unified" | "split" - export function workerFactory(): Worker { return new Worker(ShikiWorkerUrl, { type: "module" }) } @@ -32,24 +30,25 @@ function createPool(lineDiffType: "none" | "word-alt") { return pool } -let unified: WorkerPoolManager | undefined -let split: WorkerPoolManager | undefined +let plain: WorkerPoolManager | undefined +let diff: WorkerPoolManager | undefined -export function getWorkerPool(style: WorkerPoolStyle | undefined): WorkerPoolManager | undefined { +export function getWorkerPool(lineDiffType: "none" | "word-alt" = "word-alt"): WorkerPoolManager | undefined { if (typeof window === "undefined") return - if (style === "split") { - if (!split) split = createPool("word-alt") - return split + if (lineDiffType === "none") { + if (!plain) plain = createPool("none") + return plain } - if (!unified) unified = createPool("none") - return unified + if (!diff) diff = createPool("word-alt") + return diff } export function getWorkerPools() { + const pool = getWorkerPool() return { - unified: getWorkerPool("unified"), - split: getWorkerPool("split"), + unified: pool, + split: pool, } } diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index cc90adff61f..cf4aef28caa 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -152,7 +152,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { const locale = useLocale() createEffect(() => { - getWorkerPool(props.diffStyle) + getWorkerPool() }) const fileIndex = () => {