From e9d58abd99c7c6bb769a6344d92c9e7fc173c960 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:05:31 +1000 Subject: [PATCH 01/14] test(app): stabilize file expansion anchor (#44293) --- .../session-timeline-file-projection.spec.ts | 59 ++++++++++++++++--- 1 file changed, 50 insertions(+), 9 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts index fb32e43f9b4..df0d8c77c98 100644 --- a/packages/app/e2e/regression/session-timeline-file-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-file-projection.spec.ts @@ -3,9 +3,9 @@ import { createTwoFilesPatch } from "diff" import { assistantMessage, setupTimeline, - textPart, toolPart, userMessage, + userText, } from "../performance/timeline-stability/fixture" test("renders completed write content", async ({ page }) => { @@ -82,9 +82,9 @@ test("keeps an expanded file diff header at the same viewport position", async ( const id = "prt_file_projection_anchored_patch" const before = Array.from({ length: 80 }, (_, index) => `export const value${index} = ${index}\n`).join("") const after = before.replaceAll(" = ", " = compute(").replaceAll("\n", ")\n") - const timeline = await setupTimeline(page, { + await setupTimeline(page, { messages: [ - userMessage(), + userMessage([userText("Preceding context ".repeat(120))]), assistantMessage([ toolPart( id, @@ -105,7 +105,6 @@ test("keeps an expanded file diff header at the same viewport position", async ( }, }, ), - textPart("prt_after_anchored_patch", "The diff is ready.\n\n".repeat(4)), ]), ], viewport: { width: 1200, height: 600 }, @@ -113,7 +112,21 @@ test("keeps an expanded file diff header at the same viewport position", async ( const scroller = page.locator(".scroll-view__viewport", { has: page.locator("[data-timeline-row]") }) const wrapper = page.locator(`[data-timeline-part-id="${id}"]`) + const row = page.locator("[data-timeline-key]", { has: wrapper }) const trigger = wrapper.getByRole("button") + await expect + .poll(() => + row.evaluate((element) => { + const measured = element.querySelector("[data-index]") + return measured + ? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height) + : Number.POSITIVE_INFINITY + }), + ) + .toBeLessThanOrEqual(1) + await expect + .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight)) + .toBeGreaterThan(1) await scroller.evaluate((element) => { element.scrollTop = element.scrollHeight - element.clientHeight - 0.25 }) @@ -121,11 +134,28 @@ test("keeps an expanded file diff header at the same viewport position", async ( await expect .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) .toBeLessThanOrEqual(0.5) - await trigger.dispatchEvent("wheel", { deltaY: -1, deltaMode: 0 }) - await trigger.dispatchEvent("pointerdown") + const bottomScrollTop = await scroller.evaluate((element) => element.scrollTop) + await scroller.hover() + await page.mouse.wheel(0, -20) + await expect + .poll(() => scroller.evaluate((element, bottom) => bottom - element.scrollTop, bottomScrollTop)) + .toBeGreaterThan(0) const y = await trigger.evaluate((element) => element.getBoundingClientRect().y) - await trigger.dispatchEvent("click") + const collapsedHeight = await row.evaluate((element) => element.getBoundingClientRect().height) + await trigger.click() await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() + await expect + .poll(() => + row.evaluate((element, collapsed) => { + const measured = element.querySelector("[data-index]") + const allocatedHeight = element.getBoundingClientRect().height + return { + grew: allocatedHeight > collapsed + 1, + measured: measured ? Math.abs(allocatedHeight - measured.getBoundingClientRect().height) <= 1 : false, + } + }, collapsedHeight), + ) + .toEqual({ grew: true, measured: true }) await expect .poll(() => trigger.evaluate((element, initialY) => Math.abs(element.getBoundingClientRect().y - initialY), y)) .toBeLessThanOrEqual(5) @@ -133,9 +163,10 @@ test("keeps an expanded file diff header at the same viewport position", async ( const scrollTop = await scroller.evaluate((element) => element.scrollTop) await scroller.hover() await page.mouse.wheel(0, 200) - await timeline.settle(40) + await expect + .poll(() => scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop)) + .toBeGreaterThan(50) const scrolled = await scroller.evaluate((element, initial) => element.scrollTop - initial, scrollTop) - expect(scrolled).toBeGreaterThan(50) expect(scrolled).toBeLessThan(400) const expandedY = await trigger.evaluate((element) => element.getBoundingClientRect().y) @@ -150,6 +181,16 @@ test("keeps an expanded file diff header at the same viewport position", async ( await trigger.click() await expect(wrapper.locator('[data-component="apply-patch-file-diff"]')).toBeVisible() + await expect + .poll(() => + row.evaluate((element) => { + const measured = element.querySelector("[data-index]") + return measured + ? Math.abs(element.getBoundingClientRect().height - measured.getBoundingClientRect().height) + : Number.POSITIVE_INFINITY + }), + ) + .toBeLessThanOrEqual(1) await expect .poll(() => scroller.evaluate((element) => element.scrollHeight - element.clientHeight - element.scrollTop)) .toBeLessThanOrEqual(1) From a82318019f0b4ecbb73f93fed0873b7c2880ff04 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:35:05 +1000 Subject: [PATCH 02/14] fix(app): skip service worker in development (#44302) --- packages/app/src/entry.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 943e42ff9ce..4c58374b7b4 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -42,7 +42,7 @@ const clearAuthToken = () => { const web = createWebPlatform(pkg.version) -if ("serviceWorker" in navigator) { +if (import.meta.env.PROD && "serviceWorker" in navigator) { window.addEventListener("load", () => void navigator.serviceWorker.register("/sw.js"), { once: true }) } From ad59ccc2e2fde41cbe66831d913528b65fcf4235 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Sun, 23 Aug 2026 13:47:17 +1000 Subject: [PATCH 03/14] fix(ui): standardize safe text metrics (#44106) --- packages/app/AGENTS.md | 6 ++++ .../session-timeline-notices.spec.ts | 8 +++-- .../session-timeline-tool-projection.spec.ts | 5 ++-- packages/app/src/home/sessions/view.tsx | 2 +- packages/app/src/new-session/view.tsx | 2 +- packages/app/src/providers/connect/dialog.tsx | 4 +-- packages/app/src/servers/wsl/dialog.css | 2 +- .../src/session/timeline/message-timeline.tsx | 2 +- packages/app/src/settings/settings.css | 30 +++++++++---------- .../settings/workspaces/project-dialog.css | 2 +- .../settings/workspaces/project-dialog.tsx | 4 +-- packages/app/src/shell/debug/debug-bar.tsx | 6 ++-- .../app/src/shell/titlebar/tab-popover.css | 2 +- packages/session-ui/AGENTS.md | 7 +++++ .../session-ui/src/components/basic-tool.css | 5 ++-- .../src/components/message-part.css | 5 ++-- .../src/timeline/session-timeline-row.tsx | 6 ++-- .../session-ui/src/tools/tool-renderer.tsx | 7 ++--- .../src/v2/components/session-review-v2.css | 10 +++---- packages/ui/AGENTS.md | 7 +++++ .../line-comment/line-comment.css | 6 ++-- packages/ui/src/forms/field/field.css | 2 +- .../ui/src/forms/text-input/text-input.css | 2 +- packages/ui/src/navigation/menu/menu.css | 2 +- .../segmented-control/segmented-control.css | 2 +- .../ui/src/navigation/tabs/tabs-current.css | 2 +- packages/ui/src/styles/tailwind/index.css | 3 ++ packages/ui/src/styles/tokens/theme.css | 3 ++ 28 files changed, 86 insertions(+), 58 deletions(-) diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 2af9fb558b8..41cdb347daf 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -19,6 +19,12 @@ - Always prefer `createStore` over multiple `createSignal` calls +## Typography + +- Use `--line-height-compact` (`16px`) for `13px` compact UI text and `--line-height-base` (`20px`) for body text. +- Do not use `leading-none`, `line-height: 1`, or a `13px` line height for normal text. Inter descenders clip inside truncation and overflow containers. +- Keep control and row heights explicit. Fix font metrics directly rather than using transforms, negative margins, or clip-padding compensation. + ## Localization - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. diff --git a/packages/app/e2e/regression/session-timeline-notices.spec.ts b/packages/app/e2e/regression/session-timeline-notices.spec.ts index ae1e9ffdc3d..bc420ff26c0 100644 --- a/packages/app/e2e/regression/session-timeline-notices.spec.ts +++ b/packages/app/e2e/regression/session-timeline-notices.spec.ts @@ -86,10 +86,12 @@ test("shows a delegating row while subagent input streams", async ({ page }) => const delegating = page.locator('[data-component="task-tool-delegating"]') await expect(delegating).toBeVisible() - await expect(delegating.locator('[data-component="text-shimmer"]')).toHaveAttribute( + const shimmer = delegating.locator('[data-component="text-shimmer"]') + await expect(shimmer).toHaveAttribute( "aria-label", "Delegating agent...", ) + await expect(shimmer).toHaveCSS("line-height", "16px") const icon = delegating.locator('[data-slot="icon-svg"]') await expect(icon.locator('use[href="#opencode-v2-icon-subagent"]')).toBeVisible() await expect(icon).toHaveCSS("color", "rgb(174, 174, 174)") @@ -127,11 +129,11 @@ test("renders the moved location notice in its compact timeline style", async ({ await expect(notice).toHaveCSS("padding-bottom", "4px") await expect(label).toHaveCSS("font-size", "13px") await expect(label).toHaveCSS("font-weight", "530") - await expect(label).toHaveCSS("line-height", "13px") + await expect(label).toHaveCSS("line-height", "16px") await expect(label).toHaveCSS("color", "rgb(128, 128, 128)") await expect(value).toHaveCSS("font-size", "13px") await expect(value).toHaveCSS("font-weight", "440") - await expect(value).toHaveCSS("line-height", "13px") + await expect(value).toHaveCSS("line-height", "16px") await expect(value).toHaveCSS("color", "rgb(128, 128, 128)") await expect(value).toHaveCSS("text-overflow", "ellipsis") await expect(value).toHaveCSS("white-space", "nowrap") diff --git a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts index ec745c44b82..ab8af77c726 100644 --- a/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts +++ b/packages/app/e2e/regression/session-timeline-tool-projection.spec.ts @@ -121,19 +121,20 @@ test("labels skill tools from IDs and result metadata", async ({ page }) => { messages: [ userMessage(), assistantMessage([ - toolPart(pending, "skill", "running", { id: "sample-skill" }), + toolPart(pending, "skill", "running", { id: "frontend-design" }), toolPart(completed, "skill", "completed", { id: "opencode" }, { metadata: { name: "OpenCode" } }), ]), ], }) for (const [id, name] of [ - [pending, "sample-skill"], + [pending, "frontend-design"], [completed, "OpenCode"], ] as const) { const skill = page.locator(`[data-timeline-part-id="${id}"]`) const loaded = skill.locator('[data-component="tool-loaded-item"]') await expect(loaded).toHaveAttribute("aria-label", `Loaded ${name} skill`) + await expect(loaded).toHaveCSS("line-height", "16px") await expect(loaded.locator('[data-slot="tool-loaded-label"]')).toHaveText("Loaded") await expect(loaded.locator('[data-slot="tool-loaded-kind"]')).toHaveText("skill") await expect(loaded.locator('[data-component="text-shimmer"]')).toHaveAttribute("aria-label", name) diff --git a/packages/app/src/home/sessions/view.tsx b/packages/app/src/home/sessions/view.tsx index a4515e31e87..60a4c8f4320 100644 --- a/packages/app/src/home/sessions/view.tsx +++ b/packages/app/src/home/sessions/view.tsx @@ -509,7 +509,7 @@ function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnT
diff --git a/packages/app/src/new-session/view.tsx b/packages/app/src/new-session/view.tsx index a639f9d26b2..fe1e3b374d9 100644 --- a/packages/app/src/new-session/view.tsx +++ b/packages/app/src/new-session/view.tsx @@ -139,7 +139,7 @@ function ProviderTip() { > - -
- -
- + {language.t("session.child.promptDisabled")} + + + + + + diff --git a/packages/app/src/session/handoff.ts b/packages/app/src/session/handoff.ts index f8fbd4a7665..128bcade43c 100644 --- a/packages/app/src/session/handoff.ts +++ b/packages/app/src/session/handoff.ts @@ -1,7 +1,6 @@ import type { SelectedLineRange } from "@/workspaces/files/model" type HandoffSession = { - prompt: string files: Record } @@ -23,7 +22,7 @@ const touch = (map: Map, key: K, value: V) => { } export const setSessionHandoff = (key: string, patch: Partial) => { - const prev = store.session.get(key) ?? { prompt: "", files: {} } + const prev = store.session.get(key) ?? { files: {} } touch(store.session, key, { ...prev, ...patch }) } diff --git a/packages/app/src/session/story-model.tsx b/packages/app/src/session/story-model.tsx index 47679380c22..74ca3c9d119 100644 --- a/packages/app/src/session/story-model.tsx +++ b/packages/app/src/session/story-model.tsx @@ -208,8 +208,6 @@ function SessionSurfaceState(props: SessionPreviewProps & { onReset: () => void parentID: () => props.child?.parentID, child: () => !!props.child, showComposer: () => true, - handoffPrompt: () => undefined, - promptReady: () => true, } satisfies SessionComposerRegionViewController return ( From 79d5359d110edcdd9f95e73dd0cc9407118ba9dd Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:50:32 +0800 Subject: [PATCH 11/14] fix(app): align background hint spacing (#44329) --- packages/app/src/session/timeline/message-timeline.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index 3e008879116..bc680bad000 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -505,7 +505,7 @@ function MessageTimelineView( ref={setBackgroundHintRef} class="duration-150 motion-reduce:animate-none" classList={{ - [`flex h-8 items-start pt-2 ${turnPadding()}`]: true, + [`flex h-9 items-start pt-3 ${turnPadding()}`]: true, "animate-in fade-in": backgroundHintVisibility().animate && backgroundHintVisibility().show, "animate-out fade-out fill-mode-forwards": backgroundHintVisibility().animate && !backgroundHintVisibility().show, From 9692bcbc21e8fb2b9d52ca01a65699d8cd92d42c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 14:55:53 +0800 Subject: [PATCH 12/14] fix(session-ui): align status tool typography (#44330) --- .../src/components/session-turn.css | 6 +- .../src/timeline/session-timeline-row.tsx | 40 +++++++--- .../session-ui/src/tools/tool-renderer.tsx | 77 +++++++++---------- 3 files changed, 72 insertions(+), 51 deletions(-) diff --git a/packages/session-ui/src/components/session-turn.css b/packages/session-ui/src/components/session-turn.css index cb0f9c47106..58b274babe8 100644 --- a/packages/session-ui/src/components/session-turn.css +++ b/packages/session-ui/src/components/session-turn.css @@ -43,11 +43,15 @@ align-self: stretch; } + [data-slot="session-turn-thinking-row"] { + width: 100%; + margin-top: 12px; + } + [data-slot="session-turn-thinking"] { display: flex; align-items: center; gap: 8px; - margin-top: 12px; width: 100%; min-width: 0; color: var(--text-weak); diff --git a/packages/session-ui/src/timeline/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 1982e2edd2c..7133c811451 100644 --- a/packages/session-ui/src/timeline/session-timeline-row.tsx +++ b/packages/session-ui/src/timeline/session-timeline-row.tsx @@ -11,6 +11,7 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { Tooltip } from "@opencode-ai/ui/tooltip" import { For, Show, createMemo, type Accessor, type JSX } from "solid-js" import type { SessionUserActions, SessionUserComment } from "../actions" +import { BasicTool } from "../components/basic-tool" import { MessageDivider, SessionAssistantContent, @@ -442,16 +443,35 @@ export function createSessionTimelineRowRenderer(input: { return (
-
- - - - +
+ +
+
+ + + + + + + + +
+
+
+ } + />
diff --git a/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index d03eccb7b1e..a6249cb4e77 100644 --- a/packages/session-ui/src/tools/tool-renderer.tsx +++ b/packages/session-ui/src/tools/tool-renderer.tsx @@ -487,45 +487,42 @@ export function CurrentContextToolGroup(props: { } return ( - tool.id).join(",")} - > - -
- - - +
tool.id).join(",")}> + + + + + + + + - - - - - -
- - +
+ } + >
{(tool) => { @@ -557,8 +554,8 @@ export function CurrentContextToolGroup(props: { }}
- -
+ + ) } From 6cd3da1d4cdc9f39afc932adc9ab35d292b6c77a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sun, 23 Aug 2026 15:00:29 +0800 Subject: [PATCH 13/14] fix(app): preserve scroll across history prepends (#44317) --- .../session-timeline-collapse-state.spec.ts | 2 +- .../session-timeline-history-root.spec.ts | 2 +- .../app/src/session/timeline/virtualizer.tsx | 10 +- .../app/test-browser/solid-virtual.test.ts | 34 +++ packages/client/src/solid/data.ts | 5 +- packages/client/test/solid-data.test.ts | 32 +++ .../src/timeline/projection.test.ts | 29 ++- .../session-ui/src/timeline/projection.ts | 10 +- .../src/timeline/rows-current.test.ts | 16 +- .../session-ui/src/timeline/timeline-row.ts | 5 +- patches/@tanstack%2Fvirtual-core@3.17.8.patch | 233 +++++++++++++++++- 11 files changed, 332 insertions(+), 46 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts index ad47380c2ce..8f467a0f09d 100644 --- a/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-collapse-state.spec.ts @@ -139,7 +139,7 @@ test.describe("regression: session timeline local row state", () => { expect(siblingProbe).toEqual({ fileMarker: "before", frameMarker: "before", - rowKey: `assistant-part:${userMessageID}:part:${assistantMessageID}:${editPartID}`, + rowKey: `assistant-part:part:${assistantMessageID}:${editPartID}`, rowMarker: "before", shadowRoots: 0, toolMarker: "before", diff --git a/packages/app/e2e/regression/session-timeline-history-root.spec.ts b/packages/app/e2e/regression/session-timeline-history-root.spec.ts index 86a5175eb8c..6bd37df115b 100644 --- a/packages/app/e2e/regression/session-timeline-history-root.spec.ts +++ b/packages/app/e2e/regression/session-timeline-history-root.spec.ts @@ -17,7 +17,7 @@ import { mockOpenCodeServer } from "../utils/mock-server" import { installSseTransport } from "../utils/sse-transport" import { expectSessionTitle } from "../utils/waits" -const messagePageSize = 200 +const messagePageSize = 20 const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` const messages = Array.from({ length: messagePageSize / 2 + 1 }, (_, index) => { const id = `msg_${String(index + 1001).padStart(4, "0")}_history_root_user` diff --git a/packages/app/src/session/timeline/virtualizer.tsx b/packages/app/src/session/timeline/virtualizer.tsx index 02a3eaa0761..afbd01f776a 100644 --- a/packages/app/src/session/timeline/virtualizer.tsx +++ b/packages/app/src/session/timeline/virtualizer.tsx @@ -149,9 +149,13 @@ export function createTimelineVirtualizer(input: Input) { if (listRoot() && input.pinned()) anchorResizedBottom() } virtualizer.shouldAdjustScrollPositionOnItemSizeChange = (item, _delta, instance) => { - if (!instance.itemSizeCache.has(item.key) && addedKeys.delete(String(item.key))) { - return item.start < (instance.scrollOffset ?? 0) + instance.scrollAdjustments - } + // Prepended rows can resize more than once as deferred content mounts. Keep + // compensating while they remain entirely above the visible content fold. + if (addedKeys.has(String(item.key))) + return ( + item.end <= + (instance.scrollOffset ?? 0) + instance.scrollAdjustments + instance.options.scrollMargin + ) const first = instance.range?.startIndex return first !== undefined && item.index < first } diff --git a/packages/app/test-browser/solid-virtual.test.ts b/packages/app/test-browser/solid-virtual.test.ts index 6796d9c2d1c..b61b2db5822 100644 --- a/packages/app/test-browser/solid-virtual.test.ts +++ b/packages/app/test-browser/solid-virtual.test.ts @@ -55,6 +55,40 @@ test("start anchoring preserves a stable visible item across prepends", () => { expect(writes.at(-1)).toBe(150) }) +// A pagination boundary can re-key the row at the viewport top when the truncated +// leading turn regroups under its freshly loaded user message. The anchor must fall +// back to the next surviving key instead of leaving the offset on the new content. +test("prepend anchoring survives when the nearest keys are re-keyed", () => { + const root = document.createElement("div") + const writes: number[] = [] + const options = (keys: string[]) => ({ + count: keys.length, + estimateSize: () => 50, + initialOffset: 50, + initialRect: { width: 400, height: 100 }, + anchorTo: "start" as const, + getItemKey: (index: number) => keys[index]!, + getScrollElement: () => root, + scrollToFn: (offset: number) => writes.push(offset), + observeElementRect: () => {}, + observeElementOffset: (_element: HTMLDivElement, callback: (offset: number, isScrolling: boolean) => void) => { + callback(50, false) + }, + }) + // Viewport sits at offset 50: rows "orphan-c" (anchor) and "d" visible. + const virtualizer = new Virtualizer(options(["orphan-c", "d", "e"])) + virtualizer._willUpdate() + virtualizer.getVirtualItems() + + // Prepend re-keys the boundary row ("orphan-c" -> "c") while "d" and "e" survive. + virtualizer.setOptions(options(["a", "b", "c", "d", "e"])) + virtualizer._willUpdate() + + // "d" was 50px below the anchor at old start 50; restored at new start 150 => offset 150. + expect(virtualizer.getScrollOffset()).toBe(150) + expect(writes.at(-1)).toBe(150) +}) + test("reactive count updates preserve measured row sizes", () => { createRoot((dispose) => { const [count, setCount] = createSignal(2) diff --git a/packages/client/src/solid/data.ts b/packages/client/src/solid/data.ts index 3ec0427071f..1fab136c843 100644 --- a/packages/client/src/solid/data.ts +++ b/packages/client/src/solid/data.ts @@ -60,6 +60,7 @@ export type CreateDataInput = { } const messageIDFromEvent = (eventID: string) => eventID.replace(/^evt_/, "msg_") +const messagePageLimit = 20 // Global MCP elicitations temporarily use "global" instead of a real session ID, so the // server cannot recover their Location when settling them. Preserve the event Location @@ -1318,7 +1319,7 @@ export function createData(config: CreateDataInput) { }, sync(sessionID: string) { return sync.run(`session.message:${sessionID}`, async () => { - const response = await api().message.list({ sessionID, limit: 200, order: "desc" }) + const response = await api().message.list({ sessionID, limit: messagePageLimit, order: "desc" }) const fetched = response.data.toReversed() // Same protection as the pending sync: a re-fetch racing an // admission must not wipe its local transcript row. @@ -1348,7 +1349,7 @@ export function createData(config: CreateDataInput) { if (!cursor || store.session.messageLoading[sessionID]) return setStore("session", "messageLoading", sessionID, true) const response = await api() - .message.list({ sessionID, limit: 200, cursor }) + .message.list({ sessionID, limit: messagePageLimit, cursor }) .finally(() => setStore("session", "messageLoading", sessionID, false)) const older = response.data.toReversed() const existing = store.session.message[sessionID] ?? [] diff --git a/packages/client/test/solid-data.test.ts b/packages/client/test/solid-data.test.ts index c15d31578bf..5f41d92c071 100644 --- a/packages/client/test/solid-data.test.ts +++ b/packages/client/test/solid-data.test.ts @@ -103,6 +103,38 @@ test("reports optimistic sessions as creating until the request settles", async } }) +test("loads bounded message pages", async () => { + const requests: URL[] = [] + const api = OpenCode.make({ + baseUrl: "http://opencode.local", + fetch: async (input, init) => { + const request = input instanceof Request ? input : new Request(input, init) + const url = new URL(request.url) + requests.push(url) + return Response.json({ data: [], cursor: requests.length === 1 ? { next: "next" } : {} }) + }, + }) + const setup = createRoot((dispose) => ({ + data: createData({ + api: () => api, + directory: "/project", + event: { on: () => () => {}, listen: () => () => {} }, + }), + dispose, + })) + + try { + await setup.data.session.message.sync("ses_refresh") + await setup.data.session.message.loadMore("ses_refresh") + + expect(requests).toHaveLength(2) + expect(Object.fromEntries(requests[0].searchParams)).toEqual({ limit: "20", order: "desc" }) + expect(Object.fromEntries(requests[1].searchParams)).toEqual({ cursor: "next", limit: "20" }) + } finally { + setup.dispose() + } +}) + async function wait(check: () => boolean) { const started = Date.now() while (!check()) { diff --git a/packages/session-ui/src/timeline/projection.test.ts b/packages/session-ui/src/timeline/projection.test.ts index 0594e76a548..550d1984546 100644 --- a/packages/session-ui/src/timeline/projection.test.ts +++ b/packages/session-ui/src/timeline/projection.test.ts @@ -33,63 +33,65 @@ describe("reuseTimelineRows", () => { name: "reuses an unchanged context group", previous: [context("context:a", ["a", "b"])], rows: [context("context:a", ["a", "b"])], - expected: ["assistant-part:user-1:context:a"], + expected: ["assistant-part:context:a"], reused: [[0, 0]], }, { name: "preserves the group key when a member is appended", previous: [context("context:a", ["a"])], rows: [context("context:a", ["a", "b"])], - expected: ["assistant-part:user-1:context:a"], + expected: ["assistant-part:context:a"], reused: [], }, { name: "preserves a patch group key when a member is appended", previous: [patch("patch:a", ["a"])], rows: [patch("patch:a", ["a", "b"])], - expected: ["assistant-part:user-1:patch:a"], + expected: ["assistant-part:patch:a"], reused: [], }, { name: "preserves the group key when the first member is removed", previous: [context("context:a", ["a", "b"])], rows: [context("context:b", ["b"])], - expected: ["assistant-part:user-1:context:a"], + expected: ["assistant-part:context:a"], reused: [], }, { name: "lets only the natural owner retain an old key after a split", previous: [context("context:a", ["a", "b"])], rows: [context("context:a", ["a"]), context("context:b", ["b"])], - expected: ["assistant-part:user-1:context:a", "assistant-part:user-1:context:b"], + expected: ["assistant-part:context:a", "assistant-part:context:b"], reused: [], }, { name: "chooses the earliest prior key when groups merge", previous: [context("context:a", ["a"]), context("context:b", ["b"])], rows: [context("context:b", ["b", "a"])], - expected: ["assistant-part:user-1:context:a"], + expected: ["assistant-part:context:a"], reused: [], }, { name: "reserves an old key for its natural owner when two new groups compete", previous: [context("context:a", ["a", "b"])], rows: [context("context:b", ["b"]), context("context:a", ["a"])], - expected: ["assistant-part:user-1:context:b", "assistant-part:user-1:context:a"], + expected: ["assistant-part:context:b", "assistant-part:context:a"], reused: [], }, { - name: "does not reuse context identity across user messages", + // A history prepend can regroup a page-boundary turn under its real user + // message; the same parts must keep their identity across that move. + name: "reuses context identity when the same parts move to another user message", previous: [context("context:a", ["a", "b"], { userMessageID: "user-1" })], rows: [context("context:b", ["b"], { userMessageID: "user-2" })], - expected: ["assistant-part:user-2:context:b"], + expected: ["assistant-part:context:a"], reused: [], }, { name: "does not reuse context identity across assistant messages", previous: [context("context:assistant-1:a", ["a"], { messageID: "assistant-1" })], rows: [context("context:assistant-2:a", ["a"], { messageID: "assistant-2" })], - expected: ["assistant-part:user-1:context:assistant-2:a"], + expected: ["assistant-part:context:assistant-2:a"], reused: [], }, { @@ -103,11 +105,7 @@ describe("reuseTimelineRows", () => { name: "does not create accidental key collisions", previous: [context("context:a", ["a", "b", "c"])], rows: [context("context:b", ["b"]), context("context:a", ["a"]), context("context:c", ["c"])], - expected: [ - "assistant-part:user-1:context:b", - "assistant-part:user-1:context:a", - "assistant-part:user-1:context:c", - ], + expected: ["assistant-part:context:b", "assistant-part:context:a", "assistant-part:context:c"], reused: [], }, ])("$name", ({ previous, rows, expected, reused }) => { @@ -197,4 +195,5 @@ describe("createTimelineProjection", () => { expect(second.rows[0]).toBe(first.rows[0]) expect(second.rows[1]).toBe(first.rows[1]) }) + }) diff --git a/packages/session-ui/src/timeline/projection.ts b/packages/session-ui/src/timeline/projection.ts index 2cd259cd388..3412de5bbed 100644 --- a/packages/session-ui/src/timeline/projection.ts +++ b/packages/session-ui/src/timeline/projection.ts @@ -318,7 +318,7 @@ export function reuseTimelineRows(previous: TimelineRow.TimelineRow[] | undefine const groupByPart = new Map() previous.forEach((row, index) => { if (row._tag !== "AssistantPart" || row.group.type === "part") return - row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(row.userMessageID, ref), { index, row })) + row.group.refs.forEach((ref) => groupByPart.set(groupPartKey(ref), { index, row })) }) const reserved = new Map() rows.forEach((row, index) => { @@ -413,7 +413,7 @@ function stabilizeGroupKey( ) { if (row._tag !== "AssistantPart" || row.group.type === "part") return row const existing = row.group.refs.reduce((result, ref) => { - const candidate = groupByPart.get(groupPartKey(row.userMessageID, ref)) + const candidate = groupByPart.get(groupPartKey(ref)) if (!candidate) return result const key = TimelineRow.key(candidate.row) if (claimed.has(key)) return result @@ -432,8 +432,10 @@ function stabilizeGroupKey( }) } -function groupPartKey(userMessageID: string, ref: PartRef) { - return `${userMessageID}:${ref.messageID}:${ref.partID}` +// Part refs are globally unique; keying by the turn would break reuse when a +// page-boundary turn regroups under its real user message after a history prepend. +function groupPartKey(ref: PartRef) { + return `${ref.messageID}:${ref.partID}` } function renderable(content: Content, showReasoning: boolean) { diff --git a/packages/session-ui/src/timeline/rows-current.test.ts b/packages/session-ui/src/timeline/rows-current.test.ts index 5afe0b8a712..d93b66c3cee 100644 --- a/packages/session-ui/src/timeline/rows-current.test.ts +++ b/packages/session-ui/src/timeline/rows-current.test.ts @@ -29,10 +29,10 @@ describe("current session timeline rows", () => { expect(result.activeMessageID).toBe("msg_3") expect(result.rows.map(TimelineRow.key)).toEqual([ "user-message:msg_1", - "assistant-part:msg_1:part:msg_2:msg_2:text:0", + "assistant-part:part:msg_2:msg_2:text:0", "turn-gap:msg_3", "user-message:msg_3", - "assistant-part:msg_3:part:msg_4:msg_4:reasoning:0", + "assistant-part:part:msg_4:msg_4:reasoning:0", ]) }) @@ -79,7 +79,7 @@ describe("current session timeline rows", () => { expect(result.activeMessageID).toBe("msg_assistant") expect(result.rows.map(TimelineRow.key)).toEqual([ "notice:msg_notice", - "assistant-part:msg_assistant:part:msg_assistant:msg_assistant:text:0", + "assistant-part:part:msg_assistant:msg_assistant:text:0", ]) }) @@ -140,10 +140,10 @@ describe("current session timeline rows", () => { expect(result.rows.map(TimelineRow.key)).toEqual([ "user-message:msg_user", "notice:msg_agent", - "assistant-part:msg_user:part:msg_assistant_1:msg_assistant_1:text:0", + "assistant-part:part:msg_assistant_1:msg_assistant_1:text:0", "notice:msg_background", "notice:msg_model", - "assistant-part:msg_user:part:msg_assistant_2:msg_assistant_2:text:0", + "assistant-part:part:msg_assistant_2:msg_assistant_2:text:0", "notice:msg_restart", "notice:msg_skill", "notice:msg_compaction", @@ -414,9 +414,9 @@ describe("current session timeline rows", () => { expect(keys).toEqual([ "user-message:msg_user", - "assistant-part:msg_user:context:msg_assistant_1:tool_0", - "assistant-part:msg_user:part:msg_assistant_2:tool_0", - "assistant-part:msg_user:context:msg_assistant_3:tool_0", + "assistant-part:context:msg_assistant_1:tool_0", + "assistant-part:part:msg_assistant_2:tool_0", + "assistant-part:context:msg_assistant_3:tool_0", ]) }) diff --git a/packages/session-ui/src/timeline/timeline-row.ts b/packages/session-ui/src/timeline/timeline-row.ts index 192d2b7ddd5..2fd5cea6236 100644 --- a/packages/session-ui/src/timeline/timeline-row.ts +++ b/packages/session-ui/src/timeline/timeline-row.ts @@ -88,8 +88,11 @@ export namespace TimelineRow { return `notice:${row.messageID}` case "TurnDivider": return `turn-divider:${row.userMessageID}` + // Keyed by part identity alone: a page boundary can truncate the leading turn, + // and its rows regroup under the real user message once older history loads. + // The group key already carries the owning message and part IDs. case "AssistantPart": - return `assistant-part:${row.userMessageID}:${row.group.key}` + return `assistant-part:${row.group.key}` case "Thinking": return `thinking:${row.userMessageID}` case "Error": diff --git a/patches/@tanstack%2Fvirtual-core@3.17.8.patch b/patches/@tanstack%2Fvirtual-core@3.17.8.patch index 82cf1a97285..0470a6a9260 100644 --- a/patches/@tanstack%2Fvirtual-core@3.17.8.patch +++ b/patches/@tanstack%2Fvirtual-core@3.17.8.patch @@ -1,5 +1,5 @@ diff --git a/dist/cjs/index.cjs b/dist/cjs/index.cjs -index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281ee30ba28a 100644 +index e470032a9572b3ced764ca02238a8c6be435a9d4..93770cdc02c570ce6aaa2ce256792b940c25e4d3 100644 --- a/dist/cjs/index.cjs +++ b/dist/cjs/index.cjs @@ -289,7 +289,7 @@ class Virtualizer { @@ -11,16 +11,78 @@ index e470032a9572b3ced764ca02238a8c6be435a9d4..65cd7ba4159d47d4c0cfb1adea69281e const prevCount = prevOptions.count; const nextCount = merged.count; const measurements = this.getMeasurements(); -@@ -303,7 +303,7 @@ class Virtualizer { +@@ -299,11 +299,20 @@ class Virtualizer { + const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey); + if (didEdgeKeysChange) { + edgeKeysChanged = true; ++ // A data change can legitimately re-key the rows around the current offset ++ // (e.g. a truncated leading chat turn regrouping once a prepended page loads ++ // its parent). Capture fallback anchors below the primary one so the scroll ++ // position survives even when the nearest keys disappear. + const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null; if (item) { - anchor = [item.key, this.getScrollOffset() - item.start]; +- anchor = [item.key, this.getScrollOffset() - item.start]; ++ anchor = []; ++ for (let i = item.index; i < prevCount && anchor.length < 100; i++) { ++ const candidate = measurements[i]; ++ if (!candidate) break; ++ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]); ++ } } - const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null; + const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null; if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) { followOnAppend = behavior; } -@@ -725,17 +725,20 @@ class Virtualizer { +@@ -316,30 +325,31 @@ class Virtualizer { + } + let anchorResolved = false; + let anchorDelta = 0; +- if (anchor && this.scrollOffset !== null) { +- const [anchorKey, anchorOffset] = anchor; ++ let resolvedAnchor = null; ++ if (anchor && anchor.length > 0 && this.scrollOffset !== null) { + const newMeasurements = this.getMeasurements(); + const { count, getItemKey } = this.options; +- let idx = 0; +- while (idx < count && getItemKey(idx) !== anchorKey) { +- idx++; +- } +- if (idx < count) { ++ const indexByKey = new Map(); ++ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i); ++ for (const [anchorKey, anchorOffset] of anchor) { ++ const idx = indexByKey.get(anchorKey); ++ if (idx === void 0) continue; + const anchorItem = newMeasurements[idx]; +- if (anchorItem) { +- const newOffset = Math.max(0, anchorItem.start + anchorOffset); +- if (newOffset !== this.scrollOffset) { +- anchorDelta = newOffset - this.scrollOffset; +- this.scrollOffset = newOffset; +- anchorResolved = true; +- } ++ if (!anchorItem) continue; ++ resolvedAnchor = [anchorKey, anchorOffset]; ++ const newOffset = Math.max(0, anchorItem.start + anchorOffset); ++ if (newOffset !== this.scrollOffset) { ++ anchorDelta = newOffset - this.scrollOffset; ++ this.scrollOffset = newOffset; ++ anchorResolved = true; + } ++ break; + } + } + if (anchorResolved || followOnAppend) { + this.pendingScrollAnchor = [ +- anchorResolved ? anchor[0] : null, +- anchorResolved ? anchor[1] : 0, ++ anchorResolved ? resolvedAnchor[0] : null, ++ anchorResolved ? resolvedAnchor[1] : 0, + followOnAppend, + anchorDelta + ]; +@@ -725,17 +735,20 @@ class Virtualizer { this.getMeasurements(), this.getSize(), this.getScrollOffset(), @@ -71,7 +133,7 @@ index 6b43c0aea7ed9eeef75cbfb1351fcbd243913bdd..7be2680967934ddfbc4583a210a3d11e getVirtualIndexes: { (): number[]; diff --git a/dist/esm/index.js b/dist/esm/index.js -index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39a5b0b899 100644 +index 2495b26cf2c3589213546b3958eaadf2eb6b751d..dc062edd6438f315bd8dddcf231986ad72158477 100644 --- a/dist/esm/index.js +++ b/dist/esm/index.js @@ -287,7 +287,7 @@ class Virtualizer { @@ -83,16 +145,78 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39 const prevCount = prevOptions.count; const nextCount = merged.count; const measurements = this.getMeasurements(); -@@ -301,7 +301,7 @@ class Virtualizer { +@@ -297,11 +297,20 @@ class Virtualizer { + const didEdgeKeysChange = didCountChange || prevCount > 0 && nextCount > 0 && (merged.getItemKey(0) !== prevFirstKey || merged.getItemKey(nextCount - 1) !== prevLastKey); + if (didEdgeKeysChange) { + edgeKeysChanged = true; ++ // A data change can legitimately re-key the rows around the current offset ++ // (e.g. a truncated leading chat turn regrouping once a prepended page loads ++ // its parent). Capture fallback anchors below the primary one so the scroll ++ // position survives even when the nearest keys disappear. + const item = prevCount > 0 ? this.getVirtualItemForOffset(this.getScrollOffset()) ?? measurements[0] : null; if (item) { - anchor = [item.key, this.getScrollOffset() - item.start]; +- anchor = [item.key, this.getScrollOffset() - item.start]; ++ anchor = []; ++ for (let i = item.index; i < prevCount && anchor.length < 100; i++) { ++ const candidate = measurements[i]; ++ if (!candidate) break; ++ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]); ++ } } - const behavior = merged.followOnAppend === true ? "auto" : merged.followOnAppend || null; + const behavior = merged.anchorTo === "end" ? merged.followOnAppend === true ? "auto" : merged.followOnAppend || null : null; if (behavior && nextCount > prevCount && this.isAtEnd(prevOptions.scrollEndThreshold) && (prevCount === 0 || merged.getItemKey(nextCount - 1) !== prevLastKey)) { followOnAppend = behavior; } -@@ -723,17 +723,20 @@ class Virtualizer { +@@ -314,30 +323,31 @@ class Virtualizer { + } + let anchorResolved = false; + let anchorDelta = 0; +- if (anchor && this.scrollOffset !== null) { +- const [anchorKey, anchorOffset] = anchor; ++ let resolvedAnchor = null; ++ if (anchor && anchor.length > 0 && this.scrollOffset !== null) { + const newMeasurements = this.getMeasurements(); + const { count, getItemKey } = this.options; +- let idx = 0; +- while (idx < count && getItemKey(idx) !== anchorKey) { +- idx++; +- } +- if (idx < count) { ++ const indexByKey = new Map(); ++ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i); ++ for (const [anchorKey, anchorOffset] of anchor) { ++ const idx = indexByKey.get(anchorKey); ++ if (idx === void 0) continue; + const anchorItem = newMeasurements[idx]; +- if (anchorItem) { +- const newOffset = Math.max(0, anchorItem.start + anchorOffset); +- if (newOffset !== this.scrollOffset) { +- anchorDelta = newOffset - this.scrollOffset; +- this.scrollOffset = newOffset; +- anchorResolved = true; +- } ++ if (!anchorItem) continue; ++ resolvedAnchor = [anchorKey, anchorOffset]; ++ const newOffset = Math.max(0, anchorItem.start + anchorOffset); ++ if (newOffset !== this.scrollOffset) { ++ anchorDelta = newOffset - this.scrollOffset; ++ this.scrollOffset = newOffset; ++ anchorResolved = true; + } ++ break; + } + } + if (anchorResolved || followOnAppend) { + this.pendingScrollAnchor = [ +- anchorResolved ? anchor[0] : null, +- anchorResolved ? anchor[1] : 0, ++ anchorResolved ? resolvedAnchor[0] : null, ++ anchorResolved ? resolvedAnchor[1] : 0, + followOnAppend, + anchorDelta + ]; +@@ -723,17 +733,20 @@ class Virtualizer { this.getMeasurements(), this.getSize(), this.getScrollOffset(), @@ -117,9 +241,18 @@ index 2495b26cf2c3589213546b3958eaadf2eb6b751d..01373d68c540e022bd7be59d307a3e39 // Pass the typed array so binary search + forward-walk can read // start/end directly from Float64Array, skipping the Proxy traps. diff --git a/src/index.ts b/src/index.ts -index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed4cf9a00f 100644 +index dc6f1010c4d4758de9c46fb8d69209e582e47171..5d8bf755e285e4d0688d5e41ed768c60e4267131 100644 --- a/src/index.ts +++ b/src/index.ts +@@ -567,7 +567,7 @@ export class Virtualizer< + const prevOptions = this.options as + | Required> + | undefined +- let anchor: [Key, number] | null = null ++ let anchor: Array<[Key, number]> | null = null + let followOnAppend: ScrollBehavior | null = null + let edgeKeysChanged = false + @@ -575,7 +575,6 @@ export class Virtualizer< prevOptions !== undefined && prevOptions.enabled && @@ -128,7 +261,28 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed this.scrollElement !== null ) { const prevCount = prevOptions.count -@@ -611,9 +610,11 @@ export class Virtualizer< +@@ -600,6 +599,10 @@ export class Virtualizer< + + if (didEdgeKeysChange) { + edgeKeysChanged = true ++ // A data change can legitimately re-key the rows around the current offset ++ // (e.g. a truncated leading chat turn regrouping once a prepended page loads ++ // its parent). Capture fallback anchors below the primary one so the scroll ++ // position survives even when the nearest keys disappear. + const item = + prevCount > 0 + ? (this.getVirtualItemForOffset(this.getScrollOffset()) ?? +@@ -607,13 +610,20 @@ export class Virtualizer< + : null + + if (item) { +- anchor = [item.key, this.getScrollOffset() - item.start] ++ anchor = [] ++ for (let i = item.index; i < prevCount && anchor.length < 100; i++) { ++ const candidate = measurements[i] ++ if (!candidate) break ++ anchor.push([candidate.key, this.getScrollOffset() - candidate.start]) ++ } } const behavior = @@ -143,7 +297,64 @@ index dc6f1010c4d4758de9c46fb8d69209e582e47171..6988f58f7406ee64789ad9ad44f519ed if ( behavior && -@@ -1410,16 +1411,25 @@ export class Virtualizer< +@@ -646,35 +656,36 @@ export class Virtualizer< + // frame, producing a visible "jump" on prepend with dynamic sizes. + let anchorResolved = false + let anchorDelta = 0 +- if (anchor && this.scrollOffset !== null) { +- const [anchorKey, anchorOffset] = anchor ++ let resolvedAnchor: [Key, number] | null = null ++ if (anchor && anchor.length > 0 && this.scrollOffset !== null) { + const newMeasurements = this.getMeasurements() + const { count, getItemKey } = this.options +- let idx = 0 +- while (idx < count && getItemKey(idx) !== anchorKey) { +- idx++ +- } +- if (idx < count) { ++ const indexByKey = new Map() ++ for (let i = 0; i < count; i++) indexByKey.set(getItemKey(i), i) ++ for (const [anchorKey, anchorOffset] of anchor) { ++ const idx = indexByKey.get(anchorKey) ++ if (idx === undefined) continue + const anchorItem = newMeasurements[idx] +- if (anchorItem) { +- // Clamp to the reachable range's lower bound — anchorOffset may +- // have been derived from a transiently negative scrollOffset +- // (rubber-band), and a negative tracked offset never self-heals +- // when the element cannot scroll (#1229). +- const newOffset = Math.max(0, anchorItem.start + anchorOffset) +- if (newOffset !== this.scrollOffset) { +- anchorDelta = newOffset - this.scrollOffset +- this.scrollOffset = newOffset +- anchorResolved = true +- } ++ if (!anchorItem) continue ++ resolvedAnchor = [anchorKey, anchorOffset] ++ // Clamp to the reachable range's lower bound — anchorOffset may ++ // have been derived from a transiently negative scrollOffset ++ // (rubber-band), and a negative tracked offset never self-heals ++ // when the element cannot scroll (#1229). ++ const newOffset = Math.max(0, anchorItem.start + anchorOffset) ++ if (newOffset !== this.scrollOffset) { ++ anchorDelta = newOffset - this.scrollOffset ++ this.scrollOffset = newOffset ++ anchorResolved = true + } ++ break + } + } + + if (anchorResolved || followOnAppend) { + this.pendingScrollAnchor = [ +- anchorResolved ? anchor![0] : null, +- anchorResolved ? anchor![1] : 0, ++ anchorResolved ? resolvedAnchor![0] : null, ++ anchorResolved ? resolvedAnchor![1] : 0, + followOnAppend, + anchorDelta, + ] +@@ -1410,16 +1421,25 @@ export class Virtualizer< this.getSize(), this.getScrollOffset(), this.options.lanes, From 853d3534e5b87eb567a93e2cdf96b4cb8cd682ad Mon Sep 17 00:00:00 2001 From: Simon Klee Date: Sun, 23 Aug 2026 09:11:54 +0200 Subject: [PATCH 14/14] cli: share app archive across Node builds (#44331) --- .github/workflows/publish.yml | 36 ++++++++++++++++++++++++++++--- packages/cli/script/build-node.ts | 16 ++++++++++---- 2 files changed, 45 insertions(+), 7 deletions(-) diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 0ce8621ef72..dbb13829459 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -195,9 +195,33 @@ jobs: path: packages/cli/dist/cli-* if-no-files-found: error - build-node-cli: + build-node-app-archive: needs: version - if: github.repository == 'anomalyco/opencode' && false # Temporarily disabled + runs-on: blacksmith-4vcpu-ubuntu-2404 + timeout-minutes: 30 + if: github.repository == 'anomalyco/opencode' + steps: + - uses: actions/checkout@f43a0e5ff2bd294095638e18286ca9a3d1956744 # v3.6.0 + + - uses: ./.github/actions/setup-bun + + - name: Build app archive + run: bun packages/cli/script/build-node.ts --app-archive-only --app-archive=.cache/app-archive.bin --skip-install + env: + OPENCODE_VERSION: ${{ needs.version.outputs.version }} + OPENCODE_RELEASE: ${{ needs.version.outputs.release }} + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4.6.2 + with: + name: opencode-node-app-archive + path: packages/cli/.cache/app-archive.bin + if-no-files-found: error + + build-node-cli: + needs: + - version + - build-node-app-archive + if: github.repository == 'anomalyco/opencode' strategy: fail-fast: false matrix: @@ -227,8 +251,13 @@ jobs: with: node-version: "26.4.0" + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4.3.0 + with: + name: opencode-node-app-archive + path: packages/cli/.cache + - name: Build - run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node + run: bun packages/cli/script/build-node.ts --target=${{ matrix.settings.target }} --skip-install --outdir=dist/node --app-archive=.cache/app-archive.bin env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_RELEASE: ${{ needs.version.outputs.release }} @@ -543,6 +572,7 @@ jobs: - version - build-cli - sign-cli-macos + - build-node-app-archive - build-node-cli - sign-cli-windows - build-electron diff --git a/packages/cli/script/build-node.ts b/packages/cli/script/build-node.ts index 294e567d661..e283199d817 100644 --- a/packages/cli/script/build-node.ts +++ b/packages/cli/script/build-node.ts @@ -27,6 +27,9 @@ if (outdir === path.join(dir, "dist-node")) { const bundleOnly = process.argv.includes("--bundle-only") const single = process.argv.includes("--single") const skipInstall = process.argv.includes("--skip-install") +const appArchiveOnly = process.argv.includes("--app-archive-only") +const requestedArchive = process.argv.find((arg) => arg.startsWith("--app-archive="))?.slice("--app-archive=".length) +const archivePath = requestedArchive ? path.resolve(dir, requestedArchive) : undefined const requested = process.argv.find((arg) => arg.startsWith("--target="))?.slice("--target=".length) const allTargets = [ nodeTarget("linux", "arm64"), @@ -41,6 +44,14 @@ const targets = requested ? [nodeTarget(process.platform, process.arch)] : allTargets +process.chdir(dir) +if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"]) +if (appArchiveOnly) { + if (!archivePath) throw new Error("--app-archive-only requires --app-archive=") + await mkdir(path.dirname(archivePath), { recursive: true }) + await writeFile(archivePath, await buildAppArchive(Script.channel)) + process.exit(0) +} if (targets.length === 0) { if (requested === "darwin-x64") throw new Error("Node 26.4 SEA does not support macOS x64") throw new Error(`Unknown Node target: ${requested}`) @@ -48,15 +59,12 @@ if (targets.length === 0) { if (!bundleOnly && targets.some((target) => target.platform === "darwin" && target.arch === "x64")) { throw new Error("Node 26.4 SEA does not support macOS x64") } - -process.chdir(dir) -if (!skipInstall) run(process.execPath, ["install", "--os=*", "--cpu=*"]) +const appArchive = archivePath ? (await Bun.file(archivePath).text()).trim() : await buildAppArchive(Script.channel) if (!bundleOnly) await rm(outdir, { recursive: true, force: true }) const builder = !bundleOnly || targets.some((target) => target.platform === process.platform && target.arch === process.arch) ? await resolveHostNode() : undefined -const appArchive = await buildAppArchive(Script.channel) // Vite silently rewrites text imports of known asset types (.txt) to asset // URL strings when the raw-text plugin doesn't intercept them first — the