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/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/performance/timeline/first-navigation-benchmark.spec.ts b/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts index d14ad96cb77..a2adf9b1599 100644 --- a/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/first-navigation-benchmark.spec.ts @@ -53,6 +53,37 @@ benchmark.describe("performance: first navigation paint", () => { expect(result.summary.unknownSamples).toBe(0) }) + benchmark("opens a session from the new session page without a blank frame", async ({ page, report }) => { + await mockStressTimeline(page) + await installTimelineSettings(page) + await installStressSessionTabs(page, { draftID }) + await page.goto("/") + + const draftHref = stressDraftHref(draftID) + const draftTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${draftHref}"]`) + await expect(draftTab).toHaveCount(1) + await draftTab.click() + await expect(page.locator('[data-component="new-session"]')).toBeVisible() + + const href = stressSessionHref(fixture.targetID) + const sessionTab = page.locator(`[data-slot="titlebar-tabs"] a[href="${href}"]`) + await expect(sessionTab).toHaveCount(1) + const result = await measureFirstNavigation(page, { + href, + destinationPath: href, + sourceSelector: '[data-component="new-session"]', + destinationSelector: messageSelector(fixture.expected.targetMessageIDs.at(-1)!), + contentSelector, + navigate: async () => { + await sessionTab.click() + await expectSessionTitle(page, fixture.expected.targetTitle) + }, + }) + report(result) + expect(result.summary.blankSamples).toBe(0) + expect(result.summary.unknownSamples).toBe(0) + }) + benchmark("opens a child session without a blank frame", async ({ page, report }) => { await setup(page) const href = stressSessionHref(fixture.childID) 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-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) 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/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/composer/adapter.ts b/packages/app/src/composer/adapter.ts index 59f5583c2b1..a84e7f42caf 100644 --- a/packages/app/src/composer/adapter.ts +++ b/packages/app/src/composer/adapter.ts @@ -51,6 +51,7 @@ export type ComposerSession = { location: { command: Pick } session: { prompt: (input: Parameters[0]) => Promise + setStatus: Data["session"]["setStatus"] } } current: Accessor<{ agent?: string; model?: { id: string; providerID: string; variant?: string } } | undefined> @@ -77,7 +78,7 @@ export type NewSessionComposerAdapter = ComposerAdapterBase & { start: ( selection: ComposerSelection, submission: ReturnType, - ) => Promise + ) => Promise<{ session: ComposerSession; cleanupReady: Promise } | undefined> } export type ComposerAdapter = ActiveComposerAdapter | NewSessionComposerAdapter diff --git a/packages/app/src/composer/composer.tsx b/packages/app/src/composer/composer.tsx index bffdf7dec6e..cd85ffda2d5 100644 --- a/packages/app/src/composer/composer.tsx +++ b/packages/app/src/composer/composer.tsx @@ -29,7 +29,7 @@ export function Composer(props: { accentSubmit={props.accentSubmit} borderUnderlay={props.borderUnderlay} class={props.class} - variantControlVisible={!props.model.model.loading} + modelControlsVisible={!props.model.model.loading} attachKeybind={command.keybindParts("file.attach")} attachShortcut={command.keybind("file.attach")} modelControl={ diff --git a/packages/app/src/composer/editor/editor.tsx b/packages/app/src/composer/editor/editor.tsx index 77ba13391b6..2ada26b2726 100644 --- a/packages/app/src/composer/editor/editor.tsx +++ b/packages/app/src/composer/editor/editor.tsx @@ -42,7 +42,7 @@ export type ComposerEditorProps = { borderUnderlay?: boolean class?: string modelControl?: JSX.Element - variantControlVisible?: boolean + modelControlsVisible?: boolean attachKeybind?: string[] attachShortcut?: string } @@ -233,17 +233,19 @@ export function ComposerEditor(props: ComposerEditorProps) { /> )} - {props.modelControl} - - {(control) => ( - 1}> - - - )} + + {props.modelControl} + + {(control) => ( + 1}> + + + )} + [0]) => Promise + statuses?: ("idle" | "running")[] current?: ComposerSession["current"] admitted?: (messageID: string) => boolean shell?: () => Promise @@ -92,6 +93,7 @@ function session(input: { data: { location: { command: { list: () => [] } }, session: { + setStatus: (_sessionID, status) => input.statuses?.push(status), prompt: async (value) => { input.calls.push("prompt") await input.prompt(value) @@ -140,10 +142,12 @@ describe("Composer submission", () => { test("starts and promotes a New Session once before admitting its first prompt", async () => { const draft = createMemoryComposerState({ prompt: "first prompt" }).capture() - const promoted = createMemoryComposerState().capture() + const promoted = createMemoryComposerState({ prompt: "restored draft" }).capture() const calls: string[] = [] + const statuses: ("idle" | "running")[] = [] const admitted = Promise.withResolvers[0]>() - const target = session({ calls, prompt: async (value) => admitted.resolve(value) }) + const cleanupReady = Promise.withResolvers() + const target = session({ calls, statuses, prompt: async (value) => admitted.resolve(value) }) const adapter: NewSessionComposerAdapter = { kind: "new-session", state: draft, @@ -156,14 +160,20 @@ describe("Composer submission", () => { async start(_selection, submission) { calls.push("start") submission.retarget(promoted) - return target + return { session: target, cleanupReady: cleanupReady.promise } }, } - await submitInput(adapter).submit(new Event("submit")) + const submitted = submitInput(adapter).submit(new Event("submit")) const request = await admitted.promise - expect(calls).toEqual(["start", "submitted", "switch-agent", "switch-model", "prompt"]) + expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt"]) + expect(statuses).toEqual(["running"]) + expect(promoted.current()).toMatchObject([{ type: "text", content: "restored draft" }]) + cleanupReady.resolve() + await submitted + + expect(calls).toEqual(["start", "switch-agent", "switch-model", "prompt", "submitted"]) expect(request.delivery).toBe("steer") expect(request.text).toBe("first prompt") expect(draft.current()).toEqual([{ type: "text", content: "", start: 0, end: 0 }]) @@ -233,7 +243,7 @@ describe("Composer submission", () => { submitted() {}, async start(_selection, submission) { submission.retarget(promoted) - return target + return { session: target, cleanupReady: Promise.resolve() } }, } @@ -250,10 +260,12 @@ describe("Composer submission", () => { test("reuses the message ID when an unacknowledged admission is retried", async () => { const state = createMemoryComposerState({ prompt: "retry me" }).capture() const attempts: string[] = [] + const statuses: ("idle" | "running")[] = [] const first = Promise.withResolvers() const second = Promise.withResolvers() const target = session({ calls: [], + statuses, prompt: async (value) => { attempts.push(value.id ?? "") throw new Error("network unavailable") @@ -283,6 +295,7 @@ describe("Composer submission", () => { expect(attempts).toHaveLength(4) expect(new Set(attempts).size).toBe(1) + expect(statuses).toEqual(["running", "idle", "running", "idle"]) expect(state.current()).toMatchObject([{ type: "text", content: "retry me" }]) }) diff --git a/packages/app/src/composer/submit.ts b/packages/app/src/composer/submit.ts index 10879171880..4e6edc4c0ff 100644 --- a/packages/app/src/composer/submit.ts +++ b/packages/app/src/composer/submit.ts @@ -65,15 +65,42 @@ export function createComposerSubmit(input: ComposerSubmitInput) { const comments = input.comments.capture() try { - const session = + const started = input.adapter.kind === "active-session" - ? input.adapter.session() + ? { session: input.adapter.session(), cleanupReady: Promise.resolve() } : await input.adapter.start(value.selection, submission) - if (!session) return + if (!started) return + const session = started.session input.addToHistory(value.prompt, value.mode) input.resetHistory() const restore = () => restoreSubmission(input, submission, value, comments) + + const command = value.mode === "normal" ? findCommand(session, value.text) : undefined + if (value.mode === "normal" && !command) { + const optimisticBusy = !input.adapter.working() + if (optimisticBusy) session.data.session.setStatus(session.id, "running") + const sending = sendPrompt(session, value).then( + () => ({ ok: true as const }), + (error) => ({ ok: false as const, error }), + ) + await started.cleanupReady + input.adapter.submitted() + submission.context + .filter((item) => !!item.comment?.trim()) + .forEach((item) => submission.target().context.remove(item.key)) + input.comments.clear() + clearSubmission(input, submission) + void sending.then((result) => { + if (!result.ok) + failSubmission(input, session, "prompt", result.error, restore, value.id, () => { + if (optimisticBusy) session.data.session.setStatus(session.id, "idle") + }) + }) + return + } + + await started.cleanupReady input.adapter.submitted() if (value.mode === "shell") { @@ -82,7 +109,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) { return } - const command = findCommand(session, value.text) if (command) { clearSubmission(input, submission) void sendCommand(session, value, command).catch((error) => @@ -91,14 +117,6 @@ export function createComposerSubmit(input: ComposerSubmitInput) { return } - submission.context - .filter((item) => !!item.comment?.trim()) - .forEach((item) => submission.target().context.remove(item.key)) - input.comments.clear() - clearSubmission(input, submission) - void sendPrompt(session, value).catch((error) => - failSubmission(input, session, "prompt", error, restore, value.id), - ) } finally { submitting.delete(input.adapter.state) } @@ -313,8 +331,10 @@ function failSubmission( error: unknown, restore: () => boolean, messageID?: string, + rollback?: () => void, ) { if (messageID && session.admitted(messageID)) return + rollback?.() restore() input.notify.failed(kind, error) } 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 }) } 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/composer-adapter.ts b/packages/app/src/new-session/composer-adapter.ts index 548669aa9fc..7aa678e15de 100644 --- a/packages/app/src/new-session/composer-adapter.ts +++ b/packages/app/src/new-session/composer-adapter.ts @@ -53,26 +53,32 @@ export function createNewSessionComposerAdapter(props: { }) if (!sessionDirectory) return - const created = await serverSDK.api.session - .create({ - agent: selection.agent, - model: { - id: selection.model.modelID, - providerID: selection.model.providerID, - variant: selection.variant, - }, - location: { directory: sessionDirectory }, - }) - .catch((error) => { + const created = data.session.create({ + agent: selection.agent, + model: { + id: selection.model.modelID, + providerID: selection.model.providerID, + variant: selection.variant, + }, + location: { directory: sessionDirectory }, + }) + const creation = created.request.then( + () => ({ ok: true as const }), + (error) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), description: errorMessage(language, error), }) - }) - if (!created) return + return { ok: false as const, error } + }, + ) + const afterCreation = async (run: () => Promise) => { + const result = await creation + if (!result.ok) throw result.error + return run() + } - data.session.remember(created) - await startTransition(() => { + const cleanupReady = startTransition(() => { tabs.updateDraft(props.draftID, { worktree: undefined }) if (permission.isAutoAcceptingDirectory(projectDirectory)) { permission.enableAutoAccept(created.id, sessionDirectory) @@ -92,13 +98,31 @@ export function createNewSessionComposerAdapter(props: { }) return { - id: created.id, - directory: sessionDirectory, - api: serverSDK.api.session, - data, - current: () => data.session.get(created.id) ?? created, - admitted: (messageID) => - data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID), + cleanupReady, + session: { + id: created.id, + directory: sessionDirectory, + api: { + command: (input) => afterCreation(() => serverSDK.api.session.command(input)), + shell: (input) => afterCreation(() => serverSDK.api.session.shell(input)), + switchAgent: (input) => afterCreation(() => serverSDK.api.session.switchAgent(input)), + switchModel: (input) => afterCreation(() => serverSDK.api.session.switchModel(input)), + }, + data: { + location: data.location, + session: { + setStatus: data.session.setStatus, + prompt: (input) => + data.session.prompt({ + ...input, + gate: Promise.all([input.gate, afterCreation(async () => undefined)]), + }), + }, + }, + current: () => data.session.get(created.id), + admitted: (messageID) => + data.session.input.has(created.id, messageID) || !!data.session.message.get(created.id, messageID), + }, } }, } 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/route.tsx b/packages/app/src/session/route.tsx index c5d040bf815..86319ef5a61 100644 --- a/packages/app/src/session/route.tsx +++ b/packages/app/src/session/route.tsx @@ -15,7 +15,7 @@ import { SessionUIProvider } from "@/shell/routes/session-ui-provider" import { useTabs } from "@/shell/tabs/tabs" import { requireServerKey } from "@/shell/routes/session" import { useSessionModel } from "./model" -import { SessionPanelFrame, SessionRouteFrame } from "./session-frame" +import { SessionPanelFrame } from "./session-frame" import { IncompatibleServerPanel } from "./incompatible-server-panel" import { SessionErrorFallback } from "./route-error" import { createSessionResolution } from "./session-resolution" @@ -31,7 +31,7 @@ export function TargetSessionRouteContent() { params.id} /> - + @@ -45,16 +45,14 @@ function TargetSessionSettingsCommand() { } function SessionRouteErrorBoundary( - props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key; padded?: boolean }>, + props: ParentProps<{ sessionID?: string; serverKey?: ServerConnection.Key }>, ) { return ( ( - - - - - + + + )} > {props.children} @@ -78,16 +76,14 @@ function ResolvedTargetSessionRoute() { - - tabs.removeSessionTab({ server: server.key, sessionId: params.id })} - /> - - + + tabs.removeSessionTab({ server: server.key, sessionId: params.id })} + /> + } > - + }> {(value) => ( @@ -100,6 +96,14 @@ function ResolvedTargetSessionRoute() { ) } +function SessionStatePanel(props: ParentProps) { + return ( +
+ {props.children} +
+ ) +} + function TargetSessionPage() { const location = useWorkspaceLocation() const server = useServerSDK() diff --git a/packages/app/src/session/screen.tsx b/packages/app/src/session/screen.tsx index 122b9775ddc..99bb5cdeaac 100644 --- a/packages/app/src/session/screen.tsx +++ b/packages/app/src/session/screen.tsx @@ -8,7 +8,7 @@ import { useSettings } from "@/settings/model" import { MessageTimeline } from "@/session/timeline/message-timeline" import type { SessionModel } from "@/session/model" import { SESSION_PANEL_WIDTH_MIN } from "@/session/session-panel-width" -import { SessionPanelFrame, SessionRouteFrame } from "@/session/session-frame" +import { SessionPanelFrame } from "@/session/session-frame" import { TerminalPanel } from "@/session/terminal/panel" import { useUsageExceededDialogs } from "./usage-exceeded-dialogs" import { SessionErrorFallback } from "./route-error" @@ -58,10 +58,15 @@ export function SessionScreen(props: { session: SessionModel }) { const sessionPanelContent = () => ( <> - {timeline.resource() ?? ""} + {/* Surface query errors without suspending session metadata while messages load. */} + + {(error) => { + throw error() + }} +
@@ -117,7 +122,7 @@ export function SessionScreen(props: { session: SessionModel }) { ) return ( - + <>
@@ -220,6 +225,6 @@ export function SessionScreen(props: { session: SessionModel }) {
-
+ ) } 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 ( diff --git a/packages/app/src/session/timeline/message-timeline.tsx b/packages/app/src/session/timeline/message-timeline.tsx index fab1ff612b2..bc680bad000 100644 --- a/packages/app/src/session/timeline/message-timeline.tsx +++ b/packages/app/src/session/timeline/message-timeline.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, For, on, Show, type Accessor } from "solid-js" +import { createEffect, createMemo, createSignal, For, on, Show, type Accessor, type JSX } from "solid-js" import createPresence from "solid-presence" import { createStore } from "solid-js/store" import type { SessionUserActions } from "@opencode-ai/session-ui/actions" @@ -17,7 +17,7 @@ import { getFilename } from "@opencode-ai/util/path" import { Popover } from "@kobalte/core/popover" import { SessionContextUsage } from "@/session/timeline/session-context-usage" import { useLanguage } from "@/runtime/i18n/language" -import { useData } from "@/runtime/server/current" +import { useData, useServer } from "@/runtime/server/current" import { useWorkspaceLocation } from "@/workspaces/location" import { Timeline, TimelineRow } from "@opencode-ai/session-ui/timeline/projection" import { createSessionTimelineRowRenderer } from "@opencode-ai/session-ui/timeline/row" @@ -26,9 +26,10 @@ import { createTimelineVirtualizer } from "./virtualizer" import { containsDirectory, isWorkspaceDirectory, workspaceDirectories } from "@/workspaces/paths" import { SessionWorkspaceMenu } from "@/session/timeline/session-workspace-menu" import { getProjectAvatarVariant } from "@/shell/state/layout" -import { displayName, getProjectAvatarSource } from "@/shell/layout/helpers" +import { displayName, getProjectAvatarSource, projectForSession } from "@/shell/layout/helpers" import { parseCommentNote, readPromptPresentation } from "@/composer/comment-note" import { useCommand } from "@/shell/commands/command" +import { useSettings } from "@/settings/model" type BackgroundTask = { id: string @@ -180,6 +181,7 @@ function WorkspaceMoveAction(props: { function SessionSummaryPanel(props: { project: Project + avatar?: JSX.Element directory: string local: boolean branch?: string @@ -206,11 +208,13 @@ function SessionSummaryPanel(props: {
- + {props.avatar ?? ( + + )} {displayName(props.project)}
isWorkspaceDirectory(project(), sessionDirectory())) + const showProjectIcon = () => + import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" && settings.general.showProjectIcon() + const avatarProject = createMemo(() => { + if (!showProjectIcon()) return + const session = props.session.data.info() + if (!session) return + return projectForSession(session, server.ctx.projects.list()) + }) + const projectAvatar = () => ( + + ) createEffect(() => { const directory = project()?.worktree if (!directory) return @@ -484,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, @@ -513,7 +534,9 @@ function MessageTimelineView( when={workspaceSession()} fallback={ - + }> + {projectAvatar()} + } > @@ -525,9 +548,14 @@ function MessageTimelineView( - + }> + {projectAvatar()} + @@ -613,6 +641,7 @@ function MessageTimelineView( { - 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/src/settings/general/general.tsx b/packages/app/src/settings/general/general.tsx index 721d31432e8..6b664fa7da0 100644 --- a/packages/app/src/settings/general/general.tsx +++ b/packages/app/src/settings/general/general.tsx @@ -338,6 +338,20 @@ export const SettingsGeneral: Component<{
+ + +
+ settings.general.setShowProjectIcon(checked)} + /> +
+
+
+ store.general?.showProjectIcon, defaultSettings.general.showProjectIcon), + setShowProjectIcon(value: boolean) { + setStore("general", "showProjectIcon", value) + }, showTerminal: withFallback(() => store.general?.showTerminal, defaultSettings.general.showTerminal), setShowTerminal(value: boolean) { setStore("general", "showTerminal", value) diff --git a/packages/app/src/settings/settings.css b/packages/app/src/settings/settings.css index a2e8d3d78fd..5c6063d93e3 100644 --- a/packages/app/src/settings/settings.css +++ b/packages/app/src/settings/settings.css @@ -134,7 +134,7 @@ font-style: normal; font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; @@ -281,7 +281,7 @@ padding-block: 20px; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-muted); } @@ -289,7 +289,7 @@ padding-inline-end: 12px; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-muted); opacity: 0; transition: opacity 200ms ease; @@ -307,7 +307,7 @@ background: transparent; font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-accent); cursor: pointer; text-align: start; @@ -329,7 +329,7 @@ padding-bottom: 0; font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); } .settings-providers .settings-section-title + [data-component="settings-list"] { @@ -478,7 +478,7 @@ padding-block: 48px; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-muted); text-align: center; } @@ -495,7 +495,7 @@ padding-bottom: 0; font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); } .settings-shortcuts [data-component="settings-list"] { @@ -525,7 +525,7 @@ .settings-shortcuts [data-component="settings-list"] > div > span { font-weight: 440; font-size: 13px; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; @@ -577,7 +577,7 @@ padding-block: 48px; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-muted); text-align: center; } @@ -654,7 +654,7 @@ .settings-servers-name { font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-base); } @@ -674,7 +674,7 @@ padding-block: 48px; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-muted); text-align: center; } @@ -784,7 +784,7 @@ font-family: inherit; font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; text-overflow: ellipsis; white-space: nowrap; @@ -796,7 +796,7 @@ .settings-workspaces-meta { font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-faint); } @@ -856,7 +856,7 @@ padding-block: 48px; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-muted); } @@ -933,7 +933,7 @@ .settings-server-dialog-label { font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); color: var(--v2-text-text-base); } diff --git a/packages/app/src/settings/workspaces/project-dialog.css b/packages/app/src/settings/workspaces/project-dialog.css index 0f4ed4d4a55..d83fb06f947 100644 --- a/packages/app/src/settings/workspaces/project-dialog.css +++ b/packages/app/src/settings/workspaces/project-dialog.css @@ -124,7 +124,7 @@ color: var(--v2-text-text-base); font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); } .project-settings-extension-section-header > :last-child { diff --git a/packages/app/src/settings/workspaces/project-dialog.tsx b/packages/app/src/settings/workspaces/project-dialog.tsx index b9bd741931e..a3c87930abb 100644 --- a/packages/app/src/settings/workspaces/project-dialog.tsx +++ b/packages/app/src/settings/workspaces/project-dialog.tsx @@ -96,7 +96,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
-
+
{language.t("dialog.project.edit.icon")}
@@ -150,7 +150,7 @@ function ProjectSettingsDialog(props: { project: LocalProject; server: ServerCon
-
+
{language.t("dialog.project.edit.color")}
diff --git a/packages/app/src/shell/debug/debug-bar.tsx b/packages/app/src/shell/debug/debug-bar.tsx index 86e73353687..8eac4caf331 100644 --- a/packages/app/src/shell/debug/debug-bar.tsx +++ b/packages/app/src/shell/debug/debug-bar.tsx @@ -88,9 +88,9 @@ function Cell(props: {
( - - - + + + +
+ } + > + + + + + )} /> diff --git a/packages/app/src/shell/titlebar/tab-popover.css b/packages/app/src/shell/titlebar/tab-popover.css index e3b96dcaa4f..a213e520310 100644 --- a/packages/app/src/shell/titlebar/tab-popover.css +++ b/packages/app/src/shell/titlebar/tab-popover.css @@ -88,7 +88,7 @@ [data-slot="server"] { font-weight: 440; font-size: 13px; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-muted); overflow: hidden; 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/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 diff --git a/packages/cli/src/services/web-ui.ts b/packages/cli/src/services/web-ui.ts index 368e6eb48e9..c7306a6bc2a 100644 --- a/packages/cli/src/services/web-ui.ts +++ b/packages/cli/src/services/web-ui.ts @@ -42,7 +42,9 @@ function serveUI(request: HttpServerRequest.HttpServerRequest, url: URL, assets: "x-content-type-options": "nosniff", } return Effect.succeed( - request.method === "HEAD" ? HttpServerResponse.empty({ headers }) : HttpServerResponse.raw(file, { headers }), + request.method === "HEAD" + ? HttpServerResponse.empty({ headers }) + : HttpServerResponse.raw(file, { headers, contentType: headers["content-type"] }), ) } diff --git a/packages/cli/test/web-ui.test.ts b/packages/cli/test/web-ui.test.ts index 1aab6239785..773d4fbb8e2 100644 --- a/packages/cli/test/web-ui.test.ts +++ b/packages/cli/test/web-ui.test.ts @@ -55,6 +55,7 @@ describe("web UI", () => { const script = yield* Effect.promise(() => fetch(`${origin}/app.js`)) expect(yield* Effect.promise(() => script.text())).toBe("console.log('embedded')") + expect(script.headers.get("content-type")).toContain("javascript") expect(script.headers.get("cache-control")).toBe("public, max-age=31536000, immutable") const worker = yield* Effect.promise(() => fetch(`${origin}/sw.js`)) @@ -64,6 +65,7 @@ describe("web UI", () => { expect(registration.headers.get("cache-control")).toBe("no-cache") const font = yield* Effect.promise(() => fetch(`${origin}/font.woff2`)) + expect(font.headers.get("content-type")).toBe("font/woff2") expect(new Uint8Array(yield* Effect.promise(() => font.arrayBuffer()))).toEqual( new Uint8Array([0, 1, 2, 255]), ) 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/server/src/process.ts b/packages/server/src/process.ts index fd2f2a7549e..05814cfe7a9 100644 --- a/packages/server/src/process.ts +++ b/packages/server/src/process.ts @@ -4,7 +4,14 @@ import { NodeHttpServer } from "@effect/platform-node" import { SessionRestart } from "@opencode-ai/core/session/execution/restart" import { hasPtyConnectTicketURL } from "@opencode-ai/protocol/groups/pty" import { Cause, Context, Effect, Exit, Latch, Layer, Option, Ref, Scope } from "effect" -import { HttpMiddleware, HttpRouter, HttpServer, HttpServerRequest, HttpServerResponse } from "effect/unstable/http" +import { + HttpMiddleware, + HttpPlatform, + HttpRouter, + HttpServer, + HttpServerRequest, + HttpServerResponse, +} from "effect/unstable/http" import { createServer } from "node:http" import { ServerAuth } from "./auth" import { isAllowedCorsOrigin } from "./cors" @@ -90,7 +97,7 @@ export const start = Effect.fn("ServerProcess.start")(function* ( const host = address.family === "IPv6" ? `[${address.address}]` : address.address return ServerInfo.connectionURLs(`http://${host}:${address.port}`, hostname) }, - ).pipe(Layer.provide(NodeHttpServer.layerHttpServices)), + ).pipe(Layer.provideMerge(NodeHttpServer.layerHttpServices)), applicationScope, ) if (lifecycle) { @@ -98,7 +105,12 @@ export const start = Effect.fn("ServerProcess.start")(function* ( Effect.provideService(Scope.Scope, applicationScope), ) } - const app = Context.get(context, HttpRouter.HttpRouter).asHttpEffect() + const app = Context.get(context, HttpRouter.HttpRouter) + .asHttpEffect() + .pipe( + HttpMiddleware.compression(), + Effect.provideService(HttpPlatform.HttpPlatform, Context.get(context, HttpPlatform.HttpPlatform)), + ) yield* Ref.set(application, Option.some(transform ? transform(app) : app)) yield* status.ready return { address: bound.http.address, shutdown: shutdown.await } diff --git a/packages/server/test/process.test.ts b/packages/server/test/process.test.ts index 71125a1d998..9deabc24ea0 100644 --- a/packages/server/test/process.test.ts +++ b/packages/server/test/process.test.ts @@ -6,6 +6,7 @@ import { ServerProcess } from "../src/process" it.live("allows browser preflight requests without credentials", () => Effect.gen(function* () { + const fallback = "fallback".repeat(256) const server = yield* ServerProcess.start( { hostname: "127.0.0.1", @@ -19,7 +20,7 @@ it.live("allows browser preflight requests without credentials", () => api.pipe( Effect.catchIf( (error) => error instanceof HttpServerError.HttpServerError && error.reason._tag === "RouteNotFound", - () => Effect.succeed(HttpServerResponse.text("fallback")), + () => Effect.succeed(HttpServerResponse.raw(fallback, { contentType: "text/plain" })), ), ), ) @@ -51,12 +52,30 @@ it.live("allows browser preflight requests without credentials", () => expect(health.headers.get("access-control-allow-origin")).toBe("http://localhost:3000") expect(yield* Effect.promise(() => health.json())).toMatchObject({ version: "test-version" }) + const event = yield* Effect.promise(() => + fetch(new URL("/api/event", HttpServer.formatAddress(server.address)), { + headers: { + "accept-encoding": "br", + authorization: `Basic ${btoa("opencode:secret")}`, + }, + }), + ) + expect(event.status).toBe(200) + expect(event.headers.get("content-encoding")).toBeNull() + yield* Effect.promise(() => event.body?.cancel() ?? Promise.resolve()) + const missing = yield* Effect.promise(() => fetch(new URL("/missing", HttpServer.formatAddress(server.address)), { - headers: { authorization: `Basic ${btoa("opencode:secret")}` }, + headers: { + "accept-encoding": "br", + authorization: `Basic ${btoa("opencode:secret")}`, + }, }), ) expect(missing.status).toBe(200) - expect(yield* Effect.promise(() => missing.text())).toBe("fallback") + expect(missing.headers.get("content-encoding")).toBe("br") + expect(missing.headers.get("content-type")).toBe("text/plain") + expect(missing.headers.get("vary")?.toLowerCase()).toContain("accept-encoding") + expect(yield* Effect.promise(() => missing.text())).toBe(fallback) }), ) diff --git a/packages/session-ui/AGENTS.md b/packages/session-ui/AGENTS.md index e6af6bed781..98a8baece80 100644 --- a/packages/session-ui/AGENTS.md +++ b/packages/session-ui/AGENTS.md @@ -7,3 +7,10 @@ - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. - Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. + +## Typography + +- Use `--line-height-compact` (`16px`) for `13px` transcript, tool, notice, and truncation text. Use `--line-height-base` (`20px`) for body text. +- Never copy Figma's generated `leading-none` onto text. Inter descenders clip when a solid `13px` line box meets `overflow: hidden`, `overflow: clip`, or truncation. +- Keep fixed row dimensions explicit; correct inner line metrics do not require transforms, negative margins, or paint-space compensation. +- `TextShimmer` inherits font metrics, so put typography overrides on its parent. diff --git a/packages/session-ui/src/components/basic-tool.css b/packages/session-ui/src/components/basic-tool.css index 9bd2f94dd46..5db915b9df6 100644 --- a/packages/session-ui/src/components/basic-tool.css +++ b/packages/session-ui/src/components/basic-tool.css @@ -182,7 +182,8 @@ font-family: var(--v2-font-family-sans); font-size: 13px; font-weight: 530; - line-height: var(--v2-line-height-compact, 16px); + /* Keep compact text on the shared metric; solid 13px line boxes clip Inter descenders. */ + line-height: var(--line-height-compact); letter-spacing: -0.04px; } @@ -190,7 +191,7 @@ font-family: var(--v2-font-family-sans); font-size: 13px; font-weight: 440; - line-height: var(--v2-line-height-compact, 16px); + line-height: var(--line-height-compact); letter-spacing: -0.04px; } } diff --git a/packages/session-ui/src/components/message-part.css b/packages/session-ui/src/components/message-part.css index 9e0151d6ed3..dc14f3ea855 100644 --- a/packages/session-ui/src/components/message-part.css +++ b/packages/session-ui/src/components/message-part.css @@ -609,7 +609,7 @@ font-family: var(--font-family-sans); font-size: 13px; font-weight: var(--font-weight-regular, 440); - line-height: 13px; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-faint, #808080); user-select: none; @@ -1347,7 +1347,8 @@ max-width: 100%; font-family: var(--font-family-sans); font-size: 13px; - line-height: 13px; + /* Loaded values truncate, so they require the full compact line box for descenders. */ + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); 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/components/tool-error-card.css b/packages/session-ui/src/components/tool-error-card.css index cb6f2e91fd7..9a6d5395d03 100644 --- a/packages/session-ui/src/components/tool-error-card.css +++ b/packages/session-ui/src/components/tool-error-card.css @@ -1,6 +1,6 @@ [data-component="card"][data-kind="tool-error-card"] { - --card-pad-y: 8px; - --card-line-pad: 12px; + --card-pad-y: 0px; + --card-line-pad: 4px; [data-slot="basic-tool-tool-title"] { color: var(--v2-text-text-base); 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/session-timeline-row.tsx b/packages/session-ui/src/timeline/session-timeline-row.tsx index 7b5c9714628..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, @@ -354,7 +355,7 @@ export function createSessionTimelineRowRenderer(input: {
{content().label} @@ -362,7 +363,7 @@ export function createSessionTimelineRowRenderer(input: { {(item) => ( {item} @@ -379,7 +380,7 @@ export function createSessionTimelineRowRenderer(input: {
-
- - - - +
+ +
+
+ + + + + + + + +
+
+
+ } + />
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/packages/session-ui/src/tools/tool-renderer.tsx b/packages/session-ui/src/tools/tool-renderer.tsx index 50a709d07e2..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: { }}
- -
+ +
) } @@ -1175,13 +1172,10 @@ ToolRegistry.register({ >
- +
) diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index ac899be9884..231daeee97e 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -257,7 +257,7 @@ font-style: normal; font-weight: 440; font-size: 11px; - line-height: 100%; + line-height: var(--line-height-tight); letter-spacing: 0.05px; text-transform: uppercase; font-variant-numeric: tabular-nums lining-nums; @@ -398,7 +398,7 @@ flex: none; font-size: 13px; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; font-variant-numeric: tabular-nums lining-nums; color: var(--v2-text-text-base); @@ -409,7 +409,7 @@ flex: none; font-size: 13px; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; font-variant-numeric: tabular-nums lining-nums; color: var(--v2-text-text-muted); @@ -471,7 +471,7 @@ flex: none; font-size: 13px; font-weight: 530; - line-height: 100%; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); } @@ -510,7 +510,7 @@ margin-top: 4px; font-size: 13px; font-weight: 530; - line-height: 100%; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); } diff --git a/packages/ui/AGENTS.md b/packages/ui/AGENTS.md index a31b6bfe883..e97e33560a1 100644 --- a/packages/ui/AGENTS.md +++ b/packages/ui/AGENTS.md @@ -10,3 +10,10 @@ - Translate whole UI phrases in context rather than substituting glossary words. Audit recurring concepts for consistency and review every exact-English value; retain it only when it is an intentional product/provider/tool name, URL, code token, keyboard legend, acronym, asset name, or established borrowing. - Record the corpora used and flag uncertain or regional terminology in review notes. - Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. + +## Typography + +- Use the shared typography metrics instead of copying solid line heights from design exports: `--line-height-tight` is `12px`, `--line-height-compact` is `16px`, and `--line-height-base` is `20px`. +- Inter text at `13px` must use at least the compact `16px` line height. A `13px` solid line box can clip `g`, `j`, `p`, `q`, and `y` when the text or an ancestor truncates or hides overflow. +- Reserve `line-height: 1` and `leading-none` for non-text glyphs, icons, or deliberately reviewed display marks. Do not compensate text with transforms, negative margins, or clip-padding hacks. +- `TextShimmer` inherits font metrics. Put typography overrides on its parent when they must supersede the component defaults. diff --git a/packages/ui/src/data-display/line-comment/line-comment.css b/packages/ui/src/data-display/line-comment/line-comment.css index cd2406eb4c8..6a12d100f05 100644 --- a/packages/ui/src/data-display/line-comment/line-comment.css +++ b/packages/ui/src/data-display/line-comment/line-comment.css @@ -49,7 +49,7 @@ font-size: 13px; font-style: normal; font-weight: 440; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; @@ -59,7 +59,7 @@ font-size: 11px; font-style: normal; font-weight: 530; - line-height: 1; + line-height: var(--line-height-tight); letter-spacing: 0.05px; color: var(--v2-text-text-faint); font-variation-settings: "slnt" 0; @@ -138,7 +138,7 @@ font-size: 13px; font-style: normal; font-weight: 530; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); user-select: none; diff --git a/packages/ui/src/forms/field/field.css b/packages/ui/src/forms/field/field.css index ae64c89b31e..6aaa7a36ad5 100644 --- a/packages/ui/src/forms/field/field.css +++ b/packages/ui/src/forms/field/field.css @@ -20,7 +20,7 @@ font-style: normal; font-weight: 530; font-size: 13px; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; diff --git a/packages/ui/src/forms/text-input/text-input.css b/packages/ui/src/forms/text-input/text-input.css index d503830b449..595a37098b1 100644 --- a/packages/ui/src/forms/text-input/text-input.css +++ b/packages/ui/src/forms/text-input/text-input.css @@ -92,7 +92,7 @@ font-style: normal; font-weight: 440; font-size: 13px; - line-height: 1; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--v2-text-text-base); font-variation-settings: "slnt" 0; diff --git a/packages/ui/src/navigation/menu/menu.css b/packages/ui/src/navigation/menu/menu.css index 0d676a0d33d..6665f67eca9 100644 --- a/packages/ui/src/navigation/menu/menu.css +++ b/packages/ui/src/navigation/menu/menu.css @@ -76,7 +76,7 @@ font-size: 13px; font-weight: 440; - line-height: 100%; + line-height: var(--line-height-compact); letter-spacing: -0.04px; color: var(--menu-v2-fg); } diff --git a/packages/ui/src/navigation/segmented-control/segmented-control.css b/packages/ui/src/navigation/segmented-control/segmented-control.css index 61846cd3b30..b3dd5296a0e 100644 --- a/packages/ui/src/navigation/segmented-control/segmented-control.css +++ b/packages/ui/src/navigation/segmented-control/segmented-control.css @@ -37,7 +37,7 @@ font-style: normal; font-weight: 440; font-size: 13px; - line-height: 100%; + line-height: var(--line-height-compact); letter-spacing: -0.04px; font-variant-numeric: tabular-nums; font-variation-settings: "slnt" 0; diff --git a/packages/ui/src/navigation/tabs/tabs-current.css b/packages/ui/src/navigation/tabs/tabs-current.css index 53cc348effb..d4bfdaf047b 100644 --- a/packages/ui/src/navigation/tabs/tabs-current.css +++ b/packages/ui/src/navigation/tabs/tabs-current.css @@ -59,7 +59,7 @@ font-size: 13px; font-weight: 440; - line-height: 100%; + line-height: var(--line-height-compact); letter-spacing: -0.04px; } diff --git a/packages/ui/src/styles/tailwind/index.css b/packages/ui/src/styles/tailwind/index.css index 3493f3afda2..3492f4b0359 100644 --- a/packages/ui/src/styles/tailwind/index.css +++ b/packages/ui/src/styles/tailwind/index.css @@ -65,6 +65,9 @@ --leading-lg: var(--line-height-large); --leading-xl: var(--line-height-x-large); --leading-2xl: var(--line-height-2x-large); + --leading-text-tight: var(--line-height-tight); + --leading-text-compact: var(--line-height-compact); + --leading-text-base: var(--line-height-base); --tracking-normal: var(--letter-spacing-normal); --tracking-tight: var(--letter-spacing-tight); diff --git a/packages/ui/src/styles/tokens/theme.css b/packages/ui/src/styles/tokens/theme.css index ad621148452..6f17dcb8076 100644 --- a/packages/ui/src/styles/tokens/theme.css +++ b/packages/ui/src/styles/tokens/theme.css @@ -131,6 +131,9 @@ --font-family-text: "Inter", sans-serif; --v2-font-family-sans: "Inter", sans-serif; + --line-height-tight: 12px; + --line-height-compact: 16px; + --line-height-base: 20px; } /* OS preference fallback (no JS needed) */ 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,