From a4493ceb39a9e2619837876adb14f33e432fe3da Mon Sep 17 00:00:00 2001 From: creatixchu Date: Wed, 16 Sep 2026 20:12:32 +0800 Subject: [PATCH 1/5] fix(deliverables): scroll the split review columns together on both axes Each unwrapped split column is its own scroll container, and only the horizontal offset was mirrored onto the other side, so wheel-scrolling one column left the other behind and the paired rows drifted apart. Mirror the vertical offset the same way. --- .../src/client/ReviewTab.module.css | 2 +- .../ui-deliverables/src/client/ReviewTab.tsx | 12 +++++++----- .../tests/review-tab.client.spec.tsx | 14 ++++++++------ 3 files changed, 16 insertions(+), 12 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 5ec23f198b..0405eda4fb 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -1,4 +1,4 @@ -/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll sideways together; wrapped ones share rows so the sides stay aligned. */ +/** Review tab: a file selector and icon tools in the document preview's header row, then numbered diff lines in the code font. Unwrapped split columns clip their lines and scroll together on both axes; wrapped ones share rows so the sides stay aligned. */ .root { display: flex; flex-direction: column; box-sizing: border-box; width: 100%; height: 100%; min-height: 0; color: var(--dsw-alias-label-primary); } .header { display: flex; flex: 0 0 auto; gap: 6px; align-items: center; box-sizing: border-box; height: 38px; padding: 0 6px 0 8px; border-bottom: 0.5px solid var(--dsw-alias-border-l3); } .selector { flex: 0 1 auto; min-width: 0; } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 35a4350e6c..dbf2a7b40a 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -272,17 +272,19 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { /** * The side-by-side view without wrapping: two columns that clip their long - * lines and scroll sideways together, so a long line on one side never runs - * under the other and both sides show the same columns of text. Every line is - * one fixed-height row, which keeps the sides aligned. + * lines and scroll together on both axes, so a long line on one side never + * runs under the other and both sides show the same rows and columns of text. + * Every line is one fixed-height row, which keeps the sides aligned. */ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) const columns = useRef>({ left: null, right: null }) - // Mirror one side's horizontal offset onto the other; the mirrored side's own scroll event then finds nothing to change. + // Mirror one side's offsets onto the other; the mirrored side's own scroll event then finds nothing to change. const follow = (side: 'left' | 'right') => (event: UIEvent): void => { const other = columns.current[side === 'left' ? 'right' : 'left'] - if (other !== null && other.scrollLeft !== event.currentTarget.scrollLeft) other.scrollLeft = event.currentTarget.scrollLeft + for (const axis of ['scrollLeft', 'scrollTop'] as const) { + if (other !== null && other[axis] !== event.currentTarget[axis]) other[axis] = event.currentTarget[axis] + } } return (
diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 84e76ddc3a..92088e4622 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -238,14 +238,16 @@ describe('ReviewTab', () => { expect(side('left').map(line => line.getAttribute('data-diff-line'))).toEqual(['context', 'del', 'add', 'context', 'del', 'del']) expect(side('left').map(line => line.textContent)).toEqual(['1a', '2b', '', '3d', '10x', '20z']) expect(side('right').map(line => line.textContent)).toEqual(['1a', '2B', '3c', '4d', '11y', '']) - // The two sides scroll sideways together, whichever side the reader drags. + // The two sides scroll together on both axes, whichever side the reader drags. const [left, right] = ['left', 'right'].map(name => view.container.querySelector(`[data-diff-side="${name}"]`) as HTMLDivElement) - fireEvent.scroll(left!, { target: { scrollLeft: 40 } }) - expect(right!.scrollLeft).toBe(40) + fireEvent.scroll(left!, { target: { scrollLeft: 40, scrollTop: 66 } }) + expect([right!.scrollLeft, right!.scrollTop]).toEqual([40, 66]) fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) - expect(left!.scrollLeft).toBe(15) - fireEvent.scroll(right!, { target: { scrollLeft: 15 } }) - expect(left!.scrollLeft).toBe(15) + expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 66]) + fireEvent.scroll(right!, { target: { scrollTop: 22 } }) + expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22]) + fireEvent.scroll(right!, { target: { scrollLeft: 15, scrollTop: 22 } }) + expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22]) expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') // Wrapped lines vary in height, so both sides share one row per pair. fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) From b5cbcad85481eb0fef1fc39bd2a1712da45947b7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 18 Sep 2026 15:17:13 +0800 Subject: [PATCH 2/5] fix(web): preserve split diff scroll offsets across clamped peers --- apps/web/tests/changed-files-turn.e2e.ts | 37 +++++++++++++++++++ .../client/ui-deliverables/README.i18n.yaml | 4 +- packages/client/ui-deliverables/README.md | 2 + packages/client/ui-deliverables/README.zh.md | 2 + .../src/client/ReviewTab.module.css | 2 +- .../ui-deliverables/src/client/ReviewTab.tsx | 14 +++++-- .../tests/review-tab.client.spec.tsx | 8 ++++ 7 files changed, 63 insertions(+), 6 deletions(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index 295c8d65fb..c7e94f0dec 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -157,6 +157,43 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await review.locator('[data-review-view="split"]').waitFor({ state: 'visible' }) await expect.poll(() => drawn(review.locator('[data-diff-side="left"]'))).toEqual(['del:1# 示例项目', 'context:2', 'context:3一个用于演示的仓库。']) expect(await drawn(review.locator('[data-diff-side="right"]'))).toEqual(['del:1# 项目说明', 'context:2', 'context:3一个用于演示的仓库。']) + // Constrain the recorded three-row diff to exercise unequal horizontal ranges and classic scrollbars. + const scrollLayout = await page.addStyleTag({ content: ` + [data-changes-review] { height: 120px !important; } + [data-diff-side] { scrollbar-width: auto; } + [data-diff-side]::-webkit-scrollbar { width: 16px; height: 16px; } + [data-diff-side="left"] { width: 65px; } + ` }) + try { + const left = review.locator('[data-diff-side="left"]') + const right = review.locator('[data-diff-side="right"]') + const metrics = (side: typeof left) => side.evaluate(element => ({ + x: element.scrollLeft, y: element.scrollTop, + maxX: element.scrollWidth - element.clientWidth, + maxY: element.scrollHeight - element.clientHeight, + bottom: element.lastElementChild!.getBoundingClientRect().bottom, + })) + const initialLeft = await metrics(left) + const initialRight = await metrics(right) + expect(initialLeft.maxX).toBeGreaterThan(0) + expect(initialRight.maxX).toBe(0) + expect(initialLeft.maxY).toBeGreaterThan(0) + expect(initialLeft.maxY).toBe(initialRight.maxY) + await left.evaluate((element) => { element.scrollLeft = element.scrollWidth }) + await expect.poll(async () => (await metrics(left)).x).toBe(initialLeft.maxX) + await left.evaluate((element) => { element.scrollTop = element.scrollHeight }) + await expect.poll(async () => (await metrics(right)).y).toBe(initialLeft.maxY) + // Two frames allow the browser-generated peer event to run before checking the source. + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + expect((await metrics(left)).x).toBe(initialLeft.maxX) + expect((await metrics(left)).y).toBe(initialLeft.maxY) + expect((await metrics(left)).bottom).toBeCloseTo((await metrics(right)).bottom, 1) + await right.evaluate((element) => { element.scrollTop = 0 }) + await expect.poll(async () => (await metrics(left)).y).toBe(0) + expect((await metrics(left)).x).toBe(initialLeft.maxX) + } finally { + await scrollLayout.evaluate(element => element.remove()) + } await review.getByRole('button', { name: '自动换行' }).click() await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' }) await expect.poll(() => drawn(review)).toEqual(['del:1# 示例项目1# 项目说明', 'context:22', 'context:3一个用于演示的仓库。3一个用于演示的仓库。']) diff --git a/packages/client/ui-deliverables/README.i18n.yaml b/packages/client/ui-deliverables/README.i18n.yaml index e1d9351ebc..457551a057 100644 --- a/packages/client/ui-deliverables/README.i18n.yaml +++ b/packages/client/ui-deliverables/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/client/ui-deliverables/README.md -README.md: 393fc8919bf64d71f5b61628871358aac0fc53d5 -README.zh.md: 1deeb28a04d7bfe07403559c14eb19da021b7e65 +README.md: a5d333353bd9c342be0549c70773ed38123e0ba8 +README.zh.md: 9704b73e5377491d6379bf8cf3424b4f30b2fda0 diff --git a/packages/client/ui-deliverables/README.md b/packages/client/ui-deliverables/README.md index 393fc8919b..a5d333353b 100644 --- a/packages/client/ui-deliverables/README.md +++ b/packages/client/ui-deliverables/README.md @@ -44,6 +44,8 @@ The card renders the summary the Host serves for the turn's latest `workspace/ch A row opens the turn's `changes-review` tab, addressed by the viewed Session and the announcing event's sequence and titled by the turn, on that row's file; another row of the same card reveals the same tab on its file. The header's file selector lists every recorded file with its counts and switches the comparison; the selected file's counts follow it. The header's tools switch between the unified view and a side-by-side view that pairs each run of deletions with the additions that follow it, switch line wrapping, open the whole current file in the Sidebar, and, with a Host desktop available, open it in the default application with the same pending and retryable-error states as the cards; the view and wrap choices are kept per tab. The tab reads the summary and each comparison once through the authenticated routes. A text comparison lists its hunks with the old and new line numbers of every line, additions and deletions in the success and error colours, and a note when the file was created or deleted in the turn, when both sides hold the same lines, when the Host's line comparison timed out and every line shows as replaced, or when the tab stopped drawing at 5,000 lines. A binary or oversized file, a comparison the Host no longer serves, and a failed read each show one line instead; a failed read offers Retry. The comparison is the turn's snapshot of the file, not its current content. +Without wrapping, the two columns synchronize vertical scrolling and horizontal offsets up to each side’s available range. Both horizontal scrollbars remain at the bottom of the visible comparison; scrolling vertically preserves a long line’s horizontal offset when the other side fits without horizontal scrolling. + ### Inline-code links The closing prose links produced or delivered paths: an inline-code token resolves by exact path, or by being exactly the basename of exactly one such path — a basename two paths share stays inert rather than guessing, so a mention can never open the wrong file. A resolved mention keeps its code chip and takes the markdown sheet's link language, with the full path as its title. diff --git a/packages/client/ui-deliverables/README.zh.md b/packages/client/ui-deliverables/README.zh.md index 1deeb28a04..9704b73e53 100644 --- a/packages/client/ui-deliverables/README.zh.md +++ b/packages/client/ui-deliverables/README.zh.md @@ -44,6 +44,8 @@ Web 的 `standard`、`ptc` 与 `cordis` preset 提供 `present` 用于声明交 行在该行的文件上打开本轮的 `changes-review` tab,其地址由当前查看的 Session 和宣告事件的序号组成,以轮号作标题;同一张卡片的另一行会在其文件上显示同一个 tab。头部的文件选择器列出所有记录的文件及其行数,用于切换对比;所选文件的行数跟在后面。头部的工具在单栏视图和左右视图之间切换,后者把每一段删除与紧随其后的新增逐行配对;切换自动换行;在 Sidebar 中打开当前整个文件;Host 有桌面时用默认应用打开它,等待与可重试错误状态与卡片一致。视图与换行的选择按 tab 保留。tab 通过经过认证的路由各读取一次摘要和每个对比。文本对比逐个列出 hunk,每一行带旧侧和新侧的行号,新增与删除分别用成功色和错误色,并在文件是本轮新建或删除、两侧内容相同、Host 的逐行对比超时而按整文件替换显示,或 tab 在 5000 行处停止绘制时给出一行说明。二进制或过大的文件、Host 已不再提供的对比,以及读取失败各显示一行提示;读取失败时提供重试。对比是本轮对该文件的快照,不是它当前的内容。 +关闭自动换行时,两列同步纵向滚动,横向偏移同步到各自可滚动范围内。两侧横向滚动条始终位于可见对比区域底部;如果只有一侧需要横向滚动,上下滚动会保留该侧的横向偏移。 + ### 行内代码链接 收尾正文链接产出或已交付的路径:行内代码 token 按精确路径解析,或当它恰好等于其中某条路径的 basename 且该路径唯一时解析——两条路径共享同一 basename 时保持不可点击而不作猜测,因此提及绝不打开错误的文件。解析成功的提及保留代码标签,并采用 Markdown 样式表的链接样式,完整路径作为其 `title`。 diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.module.css b/packages/client/ui-deliverables/src/client/ReviewTab.module.css index 0405eda4fb..aef4abf94f 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.module.css +++ b/packages/client/ui-deliverables/src/client/ReviewTab.module.css @@ -22,7 +22,7 @@ .status { display: flex; gap: 12px; align-items: center; margin: 0; padding: 16px; font-size: 13px; color: var(--dsw-alias-label-secondary); } .body { display: flex; flex: 1 1 auto; flex-direction: column; min-height: 0; overflow: auto; padding: 8px 0 16px; font: var(--dsw-font-markdown-code-block); } .columns { display: grid; flex: 1 1 auto; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); min-height: 0; } -.column { min-width: 0; overflow-x: auto; } +.column { min-width: 0; overflow-x: scroll; } .column + .column { border-left: 0.5px solid var(--dsw-alias-border-l3); } .sideLine { display: grid; grid-template-columns: 3.5em max-content; box-sizing: border-box; min-width: 100%; width: max-content; min-height: 22px; line-height: 22px; white-space: pre; } .note { margin: 0; padding: 4px 16px 8px; font: var(--dsw-font-xs-13); color: var(--dsw-alias-label-tertiary); } diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 8572eae364..7749023260 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -280,11 +280,19 @@ function hunkHeader(hunk: WorkspaceDiffHunk): string { function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): ReactNode { const paired = useMemo(() => hunks.map(hunk => ({ header: hunkHeader(hunk), rows: splitRows(hunk) })), [hunks]) const columns = useRef>({ left: null, right: null }) - // Mirror one side's offsets onto the other; the mirrored side's own scroll event then finds nothing to change. + const offsets = useRef({ left: { scrollLeft: 0, scrollTop: 0 }, right: { scrollLeft: 0, scrollTop: 0 } }) const follow = (side: 'left' | 'right') => (event: UIEvent): void => { - const other = columns.current[side === 'left' ? 'right' : 'left'] + const peer = side === 'left' ? 'right' : 'left' + const other = columns.current[peer] for (const axis of ['scrollLeft', 'scrollTop'] as const) { - if (other !== null && other[axis] !== event.currentTarget[axis]) other[axis] = event.currentTarget[axis] + const value = event.currentTarget[axis] + if (offsets.current[side][axis] === value) continue + offsets.current[side][axis] = value + if (other !== null) { + other[axis] = value + // Record the browser-clamped offset so its scroll event cannot pull the source back. + offsets.current[peer][axis] = other[axis] + } } } return ( diff --git a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx index 92088e4622..5ae0970da4 100644 --- a/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx +++ b/packages/client/ui-deliverables/tests/review-tab.client.spec.tsx @@ -248,6 +248,14 @@ describe('ReviewTab', () => { expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22]) fireEvent.scroll(right!, { target: { scrollLeft: 15, scrollTop: 22 } }) expect([left!.scrollLeft, left!.scrollTop]).toEqual([15, 22]) + // A short peer clamps horizontal writes to zero, including the resulting scroll event. + Object.defineProperty(right, 'scrollLeft', { configurable: true, get: () => 0, set: () => {} }) + fireEvent.scroll(left!, { target: { scrollLeft: 100 } }) + fireEvent.scroll(right!) + expect(left!.scrollLeft).toBe(100) + fireEvent.scroll(left!, { target: { scrollTop: 44 } }) + fireEvent.scroll(right!) + expect([left!.scrollLeft, right!.scrollTop]).toEqual([100, 44]) expect(view.getByRole('button', { name: en['review.splitAria'] }).getAttribute('aria-pressed')).toBe('true') // Wrapped lines vary in height, so both sides share one row per pair. fireEvent.click(view.getByRole('button', { name: en['review.wrapAria'] })) From cc9caed57b2b4c2b40f9a40325ffd0a22fa825e7 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 18 Sep 2026 15:19:50 +0800 Subject: [PATCH 3/5] fix(web): type browser regression style cleanup as Node --- apps/web/tests/changed-files-turn.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index c7e94f0dec..aa5729df55 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -192,7 +192,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await expect.poll(async () => (await metrics(left)).y).toBe(0) expect((await metrics(left)).x).toBe(initialLeft.maxX) } finally { - await scrollLayout.evaluate(element => element.remove()) + await scrollLayout.evaluate(element => element.parentNode!.removeChild(element)) } await review.getByRole('button', { name: '自动换行' }).click() await review.locator('[data-review-view][data-review-wrap]').waitFor({ state: 'visible' }) From 419d7c57c7332af7158658383e9d99e52750fb85 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 18 Sep 2026 15:23:00 +0800 Subject: [PATCH 4/5] fix(web): make scroll frame callback return explicit --- apps/web/tests/changed-files-turn.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/web/tests/changed-files-turn.e2e.ts b/apps/web/tests/changed-files-turn.e2e.ts index aa5729df55..fd85c388f8 100644 --- a/apps/web/tests/changed-files-turn.e2e.ts +++ b/apps/web/tests/changed-files-turn.e2e.ts @@ -184,7 +184,7 @@ describe('web e2e: a git workspace turn ends with its changed files', () => { await left.evaluate((element) => { element.scrollTop = element.scrollHeight }) await expect.poll(async () => (await metrics(right)).y).toBe(initialLeft.maxY) // Two frames allow the browser-generated peer event to run before checking the source. - await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => resolve())))) + await page.evaluate(() => new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))) expect((await metrics(left)).x).toBe(initialLeft.maxX) expect((await metrics(left)).y).toBe(initialLeft.maxY) expect((await metrics(left)).bottom).toBeCloseTo((await metrics(right)).bottom, 1) From 8f2a1835145ce1c5b13680be4a78f37341d4564a Mon Sep 17 00:00:00 2001 From: creatixchu Date: Fri, 18 Sep 2026 15:26:22 +0800 Subject: [PATCH 5/5] fix(web): document paired column ref lifetime --- .../client/ui-deliverables/src/client/ReviewTab.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-deliverables/src/client/ReviewTab.tsx b/packages/client/ui-deliverables/src/client/ReviewTab.tsx index 7749023260..cebabaccd3 100644 --- a/packages/client/ui-deliverables/src/client/ReviewTab.tsx +++ b/packages/client/ui-deliverables/src/client/ReviewTab.tsx @@ -284,15 +284,15 @@ function SplitColumns({ hunks }: { hunks: readonly WorkspaceDiffHunk[] }): React const follow = (side: 'left' | 'right') => (event: UIEvent): void => { const peer = side === 'left' ? 'right' : 'left' const other = columns.current[peer] + /* v8 ignore next -- Both column refs are attached before browser scroll events can run. */ + if (other === null) return for (const axis of ['scrollLeft', 'scrollTop'] as const) { const value = event.currentTarget[axis] if (offsets.current[side][axis] === value) continue offsets.current[side][axis] = value - if (other !== null) { - other[axis] = value - // Record the browser-clamped offset so its scroll event cannot pull the source back. - offsets.current[peer][axis] = other[axis] - } + other[axis] = value + // Record the browser-clamped offset so its scroll event cannot pull the source back. + offsets.current[peer][axis] = other[axis] } } return (