From 9c6bb4ff72a2e4ed69761147c29e9164a627659a Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:12:20 +1000 Subject: [PATCH 01/23] fix(app): show tree while opening files (#36018) --- packages/app/e2e/regression/review-open-file.spec.ts | 9 +++++++++ .../src/pages/session/v2/session-file-browser-tab.tsx | 7 ++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index f69591f608..397c53f248 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -74,6 +74,10 @@ test("opens and searches project files inline", async ({ page }) => { "opencode.global.dat:layout", JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: false, sidebarWidth: 240, expandMode: "collapse" }), + ) localStorage.setItem( "opencode.window.browser.dat:tabs", JSON.stringify([{ type: "session", server, sessionId: sessionID }]), @@ -86,13 +90,16 @@ test("opens and searches project files inline", async ({ page }) => { await expectSessionTitle(page, title) const panel = page.locator("#review-panel") + const sidebar = panel.locator('[data-slot="session-review-v2-sidebar"]') const contextButton = page.getByRole("button", { name: "View context usage" }) await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toBeVisible() await contextButton.click() await expect(panel.getByRole("tab", { name: "Context" })).toHaveAttribute("data-selected", "") + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() const filter = panel.getByRole("combobox", { name: "Filter files" }) await expect(filter).toBeFocused() @@ -102,9 +109,11 @@ test("opens and searches project files inline", async ({ page }) => { await panel.getByRole("button", { name: "README.md" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveAttribute("data-selected", "") await expect(panel.getByText("contents:README.md", { exact: true })).toBeVisible() + await expect(sidebar).toHaveCount(0) await panel.getByRole("button", { name: "Open file" }).click() await expect(panel.getByRole("tab", { name: "README.md" })).toHaveCount(0) + await expect(sidebar).toBeVisible() await filter.fill("nested") const result = panel.getByRole("option", { name: /nested\.ts/ }) await expect(result).toBeVisible() diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 597ebd3f7e..9cbdd40df1 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -46,6 +46,7 @@ export function SessionFileBrowserTab(props: { const resultsID = `session-file-browser-results-${createUniqueId()}` const [filter, setFilter] = createSignal("") const [explicitHighlight, setExplicitHighlight] = createSignal() + const sidebarOpened = () => props.placeholder || props.state.sidebarOpened() const query = createMemo(() => filter().trim()) const search = createQuery(() => { const value = query() @@ -98,15 +99,15 @@ export function SessionFileBrowserTab(props: { toolbar toolbarStart={ <> - - + + {title()} } sidebar={ {title()}} filter={filter()} onFilterChange={setFilter} From b94e55c7c0ed5fc48ef2c652bf466e1669af4624 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:12:54 +1000 Subject: [PATCH 02/23] fix(app): align context tokens with usage (#36015) --- .../src/components/session-context-usage.tsx | 5 ++--- .../session/session-context-metrics.test.ts | 19 +++++-------------- .../session/session-context-metrics.ts | 9 +++------ .../session/session-context-tab.tsx | 14 +++++++------- 4 files changed, 17 insertions(+), 30 deletions(-) diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index fd2d1dd1a3..c1e0ccde05 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -12,7 +12,7 @@ import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" -import { getSessionContext, getSessionTokenTotal } from "@/components/session/session-context-metrics" +import { getSessionContext } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionTabs } from "@/pages/session/helpers" import { useSettings } from "@/context/settings" @@ -74,7 +74,6 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) const context = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) - const tokens = createMemo(() => info()?.tokens) const cost = createMemo(() => { return usd().format(info()?.cost ?? 0) }) @@ -132,7 +131,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { ) diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 5b11bf1433..37cc0d5b5d 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { Message } from "@opencode-ai/sdk/v2/client" -import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" +import { getSessionContext } from "./session-context-metrics" const assistant = ( id: string, @@ -38,10 +38,10 @@ const user = (id: string) => { } describe("getSessionContext", () => { - test("computes usage from latest assistant with tokens", () => { + test("computes token totals and usage from latest assistant with tokens", () => { const messages = [ user("u1"), - assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5), + assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5), assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25), ] const providers = [ @@ -60,6 +60,8 @@ describe("getSessionContext", () => { const ctx = getSessionContext(messages, providers) expect(ctx?.message.id).toBe("a2") + expect(ctx?.total).toBe(500) + expect(ctx?.input).toBe(300) expect(ctx?.usage).toBe(50) expect(ctx?.providerLabel).toBe("OpenAI") expect(ctx?.modelLabel).toBe("GPT-4.1") @@ -94,15 +96,4 @@ describe("getSessionContext", () => { expect(ctx).toBeUndefined() }) - - test("computes stored session token totals", () => { - expect( - getSessionTokenTotal({ - input: 10, - output: 20, - reasoning: 30, - cache: { read: 40, write: 50 }, - }), - ).toBe(150) - }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 84cc984abf..30dc9e958b 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -1,4 +1,4 @@ -import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client" +import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client" type Provider = { id: string @@ -21,6 +21,7 @@ type Context = { modelLabel: string limit: number | undefined input: number + total: number usage: number | null } @@ -54,6 +55,7 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | modelLabel: model?.name ?? message.modelID, limit, input: message.tokens.input, + total, usage: limit ? Math.round((total / limit) * 100) : null, } } @@ -61,8 +63,3 @@ const build = (messages: Message[] = [], providers: Provider[] = []): Context | export function getSessionContext(messages: Message[] = [], providers: Provider[] = []) { return build(messages, providers) } - -export function getSessionTokenTotal(tokens: Session["tokens"] | undefined) { - if (!tokens) return undefined - return tokens.input + tokens.output + tokens.reasoning + tokens.cache.read + tokens.cache.write -} diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index 8757c69367..25ad5ab230 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -15,7 +15,7 @@ import { useLanguage } from "@/context/language" import { useProviders } from "@/hooks/use-providers" import { useSDK } from "@/context/sdk" import { useSessionLayout } from "@/pages/session/session-layout" -import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics" +import { getSessionContext } from "./session-context-metrics" import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown" import { createSessionContextFormatter } from "./session-context-format" @@ -135,7 +135,6 @@ export function SessionContextTab() { ) const ctx = createMemo(() => getSessionContext(messages(), [...providers.all().values()])) - const tokens = createMemo(() => info()?.tokens) const formatter = createMemo(() => createSessionContextFormatter(language.intl())) const cost = createMemo(() => { @@ -204,14 +203,15 @@ export function SessionContextTab() { { label: "context.stats.provider", value: providerLabel }, { label: "context.stats.model", value: modelLabel }, { label: "context.stats.limit", value: () => formatter().number(ctx()?.limit) }, - { label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) }, + { label: "context.stats.totalTokens", value: () => formatter().number(ctx()?.total) }, { label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) }, - { label: "context.stats.inputTokens", value: () => formatter().number(tokens()?.input) }, - { label: "context.stats.outputTokens", value: () => formatter().number(tokens()?.output) }, - { label: "context.stats.reasoningTokens", value: () => formatter().number(tokens()?.reasoning) }, + { label: "context.stats.inputTokens", value: () => formatter().number(ctx()?.input) }, + { label: "context.stats.outputTokens", value: () => formatter().number(ctx()?.message.tokens.output) }, + { label: "context.stats.reasoningTokens", value: () => formatter().number(ctx()?.message.tokens.reasoning) }, { label: "context.stats.cacheTokens", - value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`, + value: () => + `${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`, }, { label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) }, { label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) }, From 13b9e55c72620e9c7a528cf3881c929a37e3761c Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:13:34 +1000 Subject: [PATCH 03/23] fix(ui): sometimes outlines get clipped (#33846) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .../session-timeline-shell-outline.spec.ts | 228 ++++++++++++++++++ .../session/timeline/message-timeline.tsx | 9 +- 2 files changed, 234 insertions(+), 3 deletions(-) create mode 100644 packages/app/e2e/regression/session-timeline-shell-outline.spec.ts diff --git a/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts new file mode 100644 index 0000000000..54139cc371 --- /dev/null +++ b/packages/app/e2e/regression/session-timeline-shell-outline.spec.ts @@ -0,0 +1,228 @@ +import { expect, test, type Locator, type Page } from "@playwright/test" +import { + assistantMessage, + setupTimeline, + shell, + textPart, + toolPart, + userMessage, +} from "../performance/timeline-stability/fixture" + +for (const deviceScaleFactor of [1.25, 1.5]) { + test(`keeps the shell outline inside a fractionally short virtual row at ${deviceScaleFactor}x`, async ({ page }) => { + const shellID = "prt_shell_outline" + const timeline = await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(shellID, "completed", "shell output")])], + settings: { newLayoutDesigns: true, shellToolPartsExpanded: true }, + reducedMotion: true, + deviceScaleFactor, + }) + const part = page.locator(`[data-timeline-part-id="${shellID}"]`) + const output = part.locator('[data-component="bash-output"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(output).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]') + if (!output) throw new Error("Shell output is unavailable") + const rowRect = element.getBoundingClientRect() + const outputRect = output.getBoundingClientRect() + // Match a rounded-down measurement at a fractional device-pixel phase. + element.style.height = `${outputRect.bottom - rowRect.top - 0.49}px` + element.style.transform = "translateY(0.25px)" + output.style.setProperty("--v2-border-border-base", "rgb(255, 0, 255)") + output.style.setProperty("background", "rgb(0, 0, 0)", "important") + const style = getComputedStyle(output) + return { + outputWidth: outputRect.width, + outputHeight: outputRect.height, + borderColor: style.borderTopColor, + boxShadow: style.boxShadow, + clipMargin: getComputedStyle(element).overflowClipMargin, + } + }) + await timeline.settle() + + const clipped = await row.evaluate((element) => { + const output = element.querySelector('[data-component="bash-output"]')! + return output.getBoundingClientRect().bottom - element.getBoundingClientRect().bottom + }) + expect(clipped).toBeCloseTo(0.49, 1) + + expect(await page.evaluate(() => devicePixelRatio)).toBe(deviceScaleFactor) + const edges = await captureCardEdges(page, output) + + expect(edges.box.width).toBeCloseTo(geometry.outputWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.outputHeight, 2) + expect(geometry.borderColor).toBe("rgb(255, 0, 255)") + expect(geometry.boxShadow).toBe("none") + expect(geometry.clipMargin).toBe("0.5px") + expect(edges.magenta.top).toBeGreaterThan(0.75) + expect(edges.magenta.bottom).toBeGreaterThan(0.75) + expect(edges.magenta.vertical).toBeGreaterThanOrEqual(2) + }) +} + +test("keeps the patch card inside a fractionally short virtual row", async ({ page }) => { + const patchID = "prt_patch_outline" + const file = { + filePath: "src/outline.ts", + relativePath: "src/outline.ts", + type: "update", + additions: 1, + deletions: 1, + before: "const outline = false\n", + after: "const outline = true\n", + } + const timeline = await setupTimeline(page, { + messages: [ + userMessage(), + assistantMessage([ + toolPart(patchID, "apply_patch", "completed", { files: [file.filePath] }, { metadata: { files: [file] } }), + ]), + ], + settings: { editToolPartsExpanded: true, newLayoutDesigns: true }, + reducedMotion: true, + }) + const part = page.locator(`[data-timeline-part-id="${patchID}"]`) + const card = part.locator('[data-component="accordion"][data-scope="apply-patch"]') + const row = page.locator("[data-timeline-key]", { has: part }) + await expect(card).toBeVisible() + await timeline.settle() + + const geometry = await row.evaluate((element) => { + const card = element.querySelector('[data-component="accordion"][data-scope="apply-patch"]') + if (!card) throw new Error("Patch card is unavailable") + const rowRect = element.getBoundingClientRect() + const cardRect = card.getBoundingClientRect() + element.style.height = `${cardRect.bottom - rowRect.top - 0.49}px` + const clipMargin = getComputedStyle(element).overflowClipMargin + const bottom = element.getBoundingClientRect().bottom + return { + overflow: card.getBoundingClientRect().bottom - bottom, + paintOverflow: card.getBoundingClientRect().bottom - bottom - Number.parseFloat(clipMargin), + clipMargin, + cardWidth: cardRect.width, + cardHeight: cardRect.height, + } + }) + await timeline.settle() + + expect(geometry.overflow).toBeCloseTo(0.49, 1) + expect(geometry.paintOverflow).toBeLessThanOrEqual(0) + const edges = await captureCardEdges(page, card) + expect(edges.box.width).toBeCloseTo(geometry.cardWidth, 2) + expect(edges.box.height).toBeCloseTo(geometry.cardHeight, 2) + expect(edges.luminance.top).toBeLessThan(245) + expect(edges.luminance.bottom).toBeLessThan(245) + expect(Math.abs(edges.luminance.bottom - edges.luminance.top)).toBeLessThan(10) + expect(geometry.clipMargin).toBe("0.5px") +}) + +test("allows paint rounding for every framed row but not fixed turn gaps", async ({ page }) => { + const secondUserID = "msg_outline_second_user" + await setupTimeline(page, { + messages: [ + userMessage(undefined, { + summary: { + diffs: [ + { + file: "src/summary.ts", + additions: 1, + deletions: 1, + patch: "@@ -1 +1 @@\n-export const value = 1\n+export const value = 2", + }, + ], + }, + }), + assistantMessage([textPart("prt_outline_text", "Assistant text")]), + userMessage(undefined, { id: secondUserID, created: 1700000010000 }), + assistantMessage([], { + id: "msg_outline_second_assistant", + parentID: secondUserID, + created: 1700000011000, + }), + ], + }) + await expect(page.locator('[data-timeline-row="DiffSummary"]')).toBeVisible() + await expect(page.locator('[data-timeline-row="TurnGap"]')).toBeVisible() + + const rows = await page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + tag: element.querySelector("[data-timeline-row]")?.dataset.timelineRow, + clipMargin: getComputedStyle(element).overflowClipMargin, + })), + ) + expect(rows.filter((row) => row.tag !== "TurnGap").every((row) => row.clipMargin === "0.5px")).toBe(true) + expect(rows.filter((row) => row.tag === "TurnGap")).toEqual([{ tag: "TurnGap", clipMargin: "0px" }]) +}) + +async function captureCardEdges(page: Page, card: Locator) { + const box = await card.boundingBox() + if (!box) throw new Error("Tool card bounds are unavailable") + const viewport = page.viewportSize() + if (!viewport) throw new Error("Viewport bounds are unavailable") + const screenshot = await page.screenshot() + return page.evaluate( + async ({ source, box, viewport }) => { + const image = new Image() + image.src = source + await image.decode() + const canvas = document.createElement("canvas") + canvas.width = image.naturalWidth + canvas.height = image.naturalHeight + const context = canvas.getContext("2d") + if (!context) throw new Error("2D canvas is unavailable") + context.drawImage(image, 0, 0) + const scale = { + x: image.naturalWidth / viewport.width, + y: image.naturalHeight / viewport.height, + } + const rows = (candidates: number[]) => { + const left = Math.floor((box.x + 8) * scale.x) + const width = Math.floor((box.width - 16) * scale.x) + return candidates.map((row) => { + const pixels = context.getImageData(left, row, width, 1).data + const indexes = Array.from({ length: width }, (_, index) => index * 4) + return { + luminance: + indexes + .map((index) => (pixels[index]! + pixels[index + 1]! + pixels[index + 2]!) / 3) + .reduce((sum, value) => sum + value, 0) / width, + magenta: + indexes.filter((index) => pixels[index]! > 200 && pixels[index + 1]! < 180 && pixels[index + 2]! > 200) + .length / width, + } + }) + } + const pixels = context.getImageData(0, 0, image.naturalWidth, image.naturalHeight).data + const columns = new Uint32Array(image.naturalWidth) + for (let index = 0; index < pixels.length; index += 4) { + if (pixels[index]! <= 200 || pixels[index + 1]! >= 180 || pixels[index + 2]! <= 200) continue + columns[(index / 4) % image.naturalWidth] = columns[(index / 4) % image.naturalWidth]! + 1 + } + const top = box.y * scale.y + const bottom = (box.y + box.height) * scale.y + const topRows = rows([Math.floor(top) - 1, Math.floor(top), Math.ceil(top)]) + const bottomRows = rows([Math.floor(bottom) - 2, Math.floor(bottom) - 1, Math.ceil(bottom) - 1]) + return { + box, + luminance: { + top: Math.min(...topRows.map((row) => row.luminance)), + bottom: rows([Math.ceil(bottom) - 1])[0]!.luminance, + }, + magenta: { + top: Math.max(...topRows.map((row) => row.magenta)), + bottom: Math.max(...bottomRows.map((row) => row.magenta)), + vertical: Array.from(columns).filter((count) => count > box.height * scale.y * 0.75).length, + }, + } + }, + { + source: `data:image/png;base64,${screenshot.toString("base64")}`, + viewport, + box, + }, + ) +} diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index d6cc7be972..68fc4d3f4f 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1243,12 +1243,13 @@ export function MessageTimeline(props: { const initialRow = timelineRowByKey().get(props.rowKey)! const item = createMemo(() => virtualItemByKey().get(props.rowKey) ?? initialItem) const row = createMemo(() => timelineRowByKey().get(props.rowKey) ?? initialRow) - const asyncFile = () => { + const tool = () => { const value = row() - if (value._tag !== "AssistantPart" || value.group.type !== "part") return false + if (value._tag !== "AssistantPart" || value.group.type !== "part") return const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID) - return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool) + if (part?.type === "tool") return part } + const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "") const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile()) let contentMeasureFrame: number | undefined @@ -1278,6 +1279,8 @@ export function MessageTimeline(props: { width: "100%", height: `${item().size}px`, overflow: "clip", + // Rounded virtual measurements can otherwise clip a framed row's outer paint. + "overflow-clip-margin": row()._tag === "TurnGap" ? undefined : "0.5px", }} >
Date: Thu, 9 Jul 2026 18:30:03 -0500 Subject: [PATCH 04/23] fix(tui): forward environment to worker (#35925) Co-authored-by: Aiden Cline --- packages/opencode/src/cli/cmd/tui.ts | 6 +++++- packages/opencode/test/cli/tui/thread.test.ts | 6 ++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 6a9af10040..95ffac7ea5 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -207,7 +207,11 @@ export const TuiThreadCommand = cmd({ } const cwd = Filesystem.resolve(process.cwd()) - const worker = new Worker(file) + const worker = new Worker(file, { + env: Object.fromEntries( + Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined), + ), + }) const client = Rpc.client(worker) const reload = () => { client.call("reload", undefined).catch(() => {}) diff --git a/packages/opencode/test/cli/tui/thread.test.ts b/packages/opencode/test/cli/tui/thread.test.ts index 73f87d904b..2b29698086 100644 --- a/packages/opencode/test/cli/tui/thread.test.ts +++ b/packages/opencode/test/cli/tui/thread.test.ts @@ -16,6 +16,12 @@ describe("tui thread", () => { expect(source).not.toContain('import("./app")') }) + test("forwards the CLI environment to the TUI worker", async () => { + const source = await Bun.file(new URL("../../../src/cli/cmd/tui.ts", import.meta.url)).text() + + expect(source).toMatch(/new Worker\(file, \{\s*env: Object\.fromEntries\(\s*Object\.entries\(process\.env\)/) + }) + async function check(project?: string) { await using tmp = await tmpdir({ git: true }) const link = path.join(path.dirname(tmp.path), path.basename(tmp.path) + "-link") From 85ef35dd64c9a4e3afd640eaa6c843c3849aa943 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 9 Jul 2026 19:53:56 -0500 Subject: [PATCH 05/23] fix(data): oom crash in stats sync --- infra/stats.ts | 4 ++- packages/stats/core/src/athena.ts | 41 +++++++++++++++++-------------- 2 files changed, 26 insertions(+), 19 deletions(-) diff --git a/infra/stats.ts b/infra/stats.ts index b5b0e1c600..10d37119f0 100644 --- a/infra/stats.ts +++ b/infra/stats.ts @@ -185,7 +185,9 @@ export const statSync = new sst.aws.Service("StatsSyncService", { cluster: lakeCluster, architecture: "arm64", cpu: "0.25 vCPU", - memory: "0.5 GB", + // 0.5 GB caused an OOM crash loop: every restart immediately re-ran the 4 Athena + // stats queries (~$5/pass) every ~5 minutes instead of hourly. + memory: "2 GB", image: { context: ".", dockerfile: "packages/stats/server/Dockerfile", diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 50c356afa5..8879d52a66 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -116,28 +116,33 @@ const poll: ( const results: ( client: AwsAthenaClient, queryExecutionId: string, - nextToken?: string, ) => Effect.Effect = Effect.fn("Athena.results")(function* ( client: AwsAthenaClient, queryExecutionId: string, - nextToken?: string, ) { - const result = yield* Effect.tryPromise({ - try: () => - client.send( - new GetQueryResultsCommand({ - QueryExecutionId: queryExecutionId, - NextToken: nextToken, - MaxResults: ATHENA_PAGE_SIZE, - }), - ), - catch: (cause) => new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), - }) - const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] - const rows = (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1).map((row) => rowData(columns, row)) - - if (!result.NextToken) return rows - return [...rows, ...(yield* results(client, queryExecutionId, result.NextToken))] + // Accumulate pages iteratively; recursive spreads copied every previously + // fetched row per page and blew up memory on large result sets. + const rows: AthenaData[] = [] + let nextToken: string | undefined + while (true) { + const result = yield* Effect.tryPromise({ + try: () => + client.send( + new GetQueryResultsCommand({ + QueryExecutionId: queryExecutionId, + NextToken: nextToken, + MaxResults: ATHENA_PAGE_SIZE, + }), + ), + catch: (cause) => + new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), + }) + const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] + // The first page starts with the header row. + for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) + if (!result.NextToken) return rows + nextToken = result.NextToken + } }) function rowData(columns: string[], row: Row): AthenaData { From d09d5612f3f95dd08997ea095384b70b10c8dcb3 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 10 Jul 2026 00:55:59 +0000 Subject: [PATCH 06/23] chore: generate --- packages/stats/core/src/athena.ts | 57 ++++++++++++++----------------- 1 file changed, 26 insertions(+), 31 deletions(-) diff --git a/packages/stats/core/src/athena.ts b/packages/stats/core/src/athena.ts index 8879d52a66..96a48d3710 100644 --- a/packages/stats/core/src/athena.ts +++ b/packages/stats/core/src/athena.ts @@ -113,37 +113,32 @@ const poll: ( return yield* poll(client, queryExecutionId, attempt + 1) }) -const results: ( - client: AwsAthenaClient, - queryExecutionId: string, -) => Effect.Effect = Effect.fn("Athena.results")(function* ( - client: AwsAthenaClient, - queryExecutionId: string, -) { - // Accumulate pages iteratively; recursive spreads copied every previously - // fetched row per page and blew up memory on large result sets. - const rows: AthenaData[] = [] - let nextToken: string | undefined - while (true) { - const result = yield* Effect.tryPromise({ - try: () => - client.send( - new GetQueryResultsCommand({ - QueryExecutionId: queryExecutionId, - NextToken: nextToken, - MaxResults: ATHENA_PAGE_SIZE, - }), - ), - catch: (cause) => - new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), - }) - const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] - // The first page starts with the header row. - for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) - if (!result.NextToken) return rows - nextToken = result.NextToken - } -}) +const results: (client: AwsAthenaClient, queryExecutionId: string) => Effect.Effect = + Effect.fn("Athena.results")(function* (client: AwsAthenaClient, queryExecutionId: string) { + // Accumulate pages iteratively; recursive spreads copied every previously + // fetched row per page and blew up memory on large result sets. + const rows: AthenaData[] = [] + let nextToken: string | undefined + while (true) { + const result = yield* Effect.tryPromise({ + try: () => + client.send( + new GetQueryResultsCommand({ + QueryExecutionId: queryExecutionId, + NextToken: nextToken, + MaxResults: ATHENA_PAGE_SIZE, + }), + ), + catch: (cause) => + new AthenaQueryError({ message: "Failed to read Athena stats results", queryExecutionId, cause }), + }) + const columns = result.ResultSet?.ResultSetMetadata?.ColumnInfo?.map((item) => item.Name ?? "") ?? [] + // The first page starts with the header row. + for (const row of (result.ResultSet?.Rows ?? []).slice(nextToken ? 0 : 1)) rows.push(rowData(columns, row)) + if (!result.NextToken) return rows + nextToken = result.NextToken + } + }) function rowData(columns: string[], row: Row): AthenaData { return Object.fromEntries( From ea982c3832c75cb1330924ba8160da6497a2833a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:29:30 +0800 Subject: [PATCH 07/23] fix(app): wrap session creation state updates in startTransition (#36182) --- .../app/src/components/prompt-input/submit.ts | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 05d978a947..6807b5ab6d 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -3,7 +3,7 @@ import { showToast } from "@/utils/toast" import { base64Encode } from "@opencode-ai/core/util/encode" import { Binary } from "@opencode-ai/core/util/binary" import { useNavigate, useParams, useSearchParams } from "@solidjs/router" -import { batch, type Accessor } from "solid-js" +import { batch, startTransition, type Accessor } from "solid-js" import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" @@ -374,13 +374,16 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (created) { seed(sessionDirectory, created) session = created - if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id) - layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) - const draftID = search.draftId - if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) - else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) - submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + await startTransition(() => { + if (!session) return + if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) + local.session.promote(sessionDirectory, session.id) + layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) + const draftID = search.draftId + if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + }) } } if (!session) { From 01131c75d8eeeb62be4b0a32251cf1915b0a6963 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 10 Jul 2026 10:41:54 +0800 Subject: [PATCH 08/23] fix(app): focus input on new session (#36077) Co-authored-by: Victor Navarro --- packages/app/src/pages/new-session.tsx | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 7a8e44c5ab..926b0f2e97 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -26,6 +26,7 @@ import { useComposerCommands } from "@/pages/session/use-composer-commands" import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" import { PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" import { useTitlebarRightMount } from "@/components/titlebar" +import { useCommand } from "@/context/command" import { useProviders } from "@/hooks/use-providers" import { useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" @@ -48,6 +49,7 @@ export default function NewSessionPage() { const comments = useComments() const language = useLanguage() const settings = useSettings() + const command = useCommand() const providers = useProviders(() => sdk().directory) const openProviderSettings = useSettingsDialog("providers") const route = useSessionKey() @@ -68,6 +70,16 @@ export default function NewSessionPage() { onDone: () => inputRef?.focus(), }) + command.register("new-session", () => [ + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: () => inputRef?.focus(), + }, + ]) + const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() From c769380776b1023cb205fe0ddf73fe24ec3e4a2f Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 08:22:05 +0530 Subject: [PATCH 09/23] feat(app): visual improvements (#36073) --- packages/app/src/components/prompt-input.tsx | 15 +++++----- .../session-sortable-terminal-tab-v2.tsx | 12 +++++++- .../components/settings-v2/settings-v2.css | 6 ++++ packages/app/src/components/terminal.tsx | 21 ++++++++++++-- packages/app/src/pages/session.tsx | 2 +- .../src/pages/session/session-side-panel.tsx | 28 ++++++++++++++++--- .../src/pages/session/terminal-panel-v2.tsx | 4 +-- .../session/timeline/message-timeline.tsx | 2 +- .../src/v2/components/session-review-v2.css | 4 +-- packages/ui/src/components/tabs.css | 19 +++++++++++++ .../ui/src/v2/components/icon-button-v2.css | 12 +++++--- .../ui/src/v2/components/line-comment-v2.css | 1 + packages/ui/src/v2/components/select-v2.css | 1 + packages/ui/src/v2/components/tabs-v2.css | 1 + 14 files changed, 103 insertions(+), 25 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324a789825..2de29897e7 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -36,6 +36,8 @@ import { Icon } from "@opencode-ai/ui/icon" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" @@ -1704,22 +1706,19 @@ export const PromptInput: Component = (props) => { > } + variant="ghost-muted" + size="large" style={buttons()} disabled={store.mode !== "normal"} tabIndex={store.mode === "normal" ? undefined : -1} aria-label={language.t("prompt.menu.addImagesAndFiles")} /> - + {language.t("prompt.menu.imagesAndFiles")} diff --git a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx index f0cdf41ac1..301783a0df 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab-v2.tsx @@ -211,7 +211,17 @@ export function SortableTerminalTabV2(props: { { + // Switch on mousedown to shave the press-release delay off tab switches. + if (e.button !== 0) return + if (store.editing) return + focus() + }} + onClick={(e) => { + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (e.detail > 0) return + focus() + }} closeButton={ span { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 8998fc06c8..c5faf85bff 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,7 +1,8 @@ import { withAlpha } from "@opencode-ai/ui/theme/color" import { useTheme } from "@opencode-ai/ui/theme/context" import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import type { HexColor } from "@opencode-ai/ui/theme/types" +import { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve" +import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types" import { showToast } from "@/utils/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" @@ -68,6 +69,19 @@ const debugTerminal = (...values: unknown[]) => { console.debug("[terminal]", ...values) } +const resolveV2Token = (tokens: ResolvedV2Theme, key: string) => { + let current = tokens[key] + for (let i = 0; i < 8 && current; i++) { + const match = /^var\(--([^)]+)\)$/.exec(current.trim()) + if (!match) { + const hex = current.trim() + if (/^#[0-9a-fA-F]{8}$/.test(hex)) return hex.slice(0, 7) + return hex + } + current = tokens[match[1]] + } +} + const useTerminalUiBindings = (input: { container: HTMLDivElement term: Term @@ -238,7 +252,10 @@ export const Terminal = (props: TerminalProps) => { if (!variant?.seeds && !variant?.palette) return fallback const resolved = resolveThemeVariant(variant, mode === "dark") const text = resolved["text-stronger"] ?? fallback.foreground - const background = resolved["background-stronger"] ?? fallback.background + const background = settings.general.newLayoutDesigns() + ? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ?? + fallback.background) + : (resolved["background-stronger"] ?? fallback.background) const alpha = mode === "dark" ? 0.25 : 0.2 const base = text.startsWith("#") ? (text as HexColor) : (fallback.foreground as HexColor) const selectionBackground = withAlpha(base, alpha) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cbf47dc849..678f7b1687 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1304,7 +1304,7 @@ export default function Page() { const reviewPanelV2Rendered = createMemo((prev) => prev || !store.deferRender, false) const reviewPanelV2 = () => ( -
+
diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 5e5f89e3e2..e009177682 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -255,8 +255,10 @@ export function SessionSidePanel(props: { aria-label={language.t("session.panel.reviewAndFiles")} aria-hidden={!open()} inert={!open()} - class="relative min-w-0 flex overflow-hidden bg-background-base" + class="relative min-w-0 flex overflow-hidden" classList={{ + "bg-v2-background-bg-base": settings.general.newLayoutDesigns(), + "bg-background-base": !settings.general.newLayoutDesigns(), "h-full shrink-0": !props.stacked, "h-full min-h-0": props.stacked, "pointer-events-none": !open(), @@ -275,8 +277,20 @@ export function SessionSidePanel(props: { }} > -
-
+
+
-
+
-
+
{(title) => (
diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 68fc4d3f4f..a7390b6d61 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -1386,7 +1386,7 @@ export function MessageTimeline(props: { "w-full": true, "pb-4": true, "pr-3": true, - "pl-2": settings.general.newLayoutDesigns(), + "pl-2.5": settings.general.newLayoutDesigns(), "pl-2 md:pl-4": !settings.general.newLayoutDesigns(), "md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(), }} 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 c42fd20d3b..b116ebb43f 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -4,7 +4,7 @@ height: 100%; min-height: 0; overflow: hidden; - background: var(--background-stronger, var(--v2-background-bg-base)); + background: var(--v2-background-bg-base); } [data-component="session-review-v2"] [data-slot="session-review-v2-body"] { @@ -31,7 +31,7 @@ min-height: 0; overflow: hidden; border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); - background: var(--background-stronger, var(--v2-background-bg-base)); + background: var(--v2-background-bg-base); } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] { diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index fe450da85a..cd26b0cd50 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -679,3 +679,22 @@ [data-component="tabs-drag-preview"] > * { position: relative; } + +body[data-new-layout] #review-panel [data-component="tabs"], +body[data-new-layout] #terminal-panel [data-component="tabs"], +body[data-new-layout] #review-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"], +body[data-new-layout] #terminal-panel [data-component="tabs"][data-variant="normal"][data-orientation="horizontal"] { + background-color: var(--v2-background-bg-base); + + [data-slot="tabs-list"] { + background-color: var(--v2-background-bg-base); + + > .sticky { + background-color: var(--v2-background-bg-base); + + &::before { + background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); + } + } + } +} diff --git a/packages/ui/src/v2/components/icon-button-v2.css b/packages/ui/src/v2/components/icon-button-v2.css index d8b5339575..a90a101a08 100644 --- a/packages/ui/src/v2/components/icon-button-v2.css +++ b/packages/ui/src/v2/components/icon-button-v2.css @@ -112,8 +112,8 @@ color: var(--v2-text-text-base); } -[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"], [data-expanded]):not( - :disabled +[data-component="icon-button-v2"][data-variant="ghost"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] ) { background-color: var(--v2-overlay-simple-overlay-hover); } @@ -122,6 +122,10 @@ background-color: var(--v2-overlay-simple-overlay-pressed); } +[data-component="icon-button-v2"][data-variant="ghost"]:where([data-expanded]):not(:disabled) { + background-color: var(--v2-overlay-simple-overlay-pressed); +} + [data-component="icon-button-v2"][data-variant="ghost"]:is(:disabled, [data-state="disabled"]) { opacity: 0.5; cursor: not-allowed; @@ -133,8 +137,8 @@ color: var(--v2-icon-icon-muted); } -[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"], [data-expanded]):not( - :disabled +[data-component="icon-button-v2"][data-variant="ghost-muted"]:is(:hover, [data-state="hover"]):not(:disabled):not( + [data-expanded] ) { background-color: var(--v2-overlay-simple-overlay-hover); } diff --git a/packages/ui/src/v2/components/line-comment-v2.css b/packages/ui/src/v2/components/line-comment-v2.css index 0428ca19cb..31be02ae28 100644 --- a/packages/ui/src/v2/components/line-comment-v2.css +++ b/packages/ui/src/v2/components/line-comment-v2.css @@ -6,6 +6,7 @@ [data-component="line-comment-v2"] { box-sizing: border-box; + font-family: var(--v2-font-family-sans); font-variant-numeric: tabular-nums; min-width: 0; width: 100%; diff --git a/packages/ui/src/v2/components/select-v2.css b/packages/ui/src/v2/components/select-v2.css index 38562b2ea6..c22a9d901b 100644 --- a/packages/ui/src/v2/components/select-v2.css +++ b/packages/ui/src/v2/components/select-v2.css @@ -65,6 +65,7 @@ box-shadow: var(--v2-elevation-button-neutral); flex: none; align-self: stretch; + user-select: none; transition: background 85ms ease-out, outline-color 85ms ease-out, diff --git a/packages/ui/src/v2/components/tabs-v2.css b/packages/ui/src/v2/components/tabs-v2.css index 8582bbf678..70abf78606 100644 --- a/packages/ui/src/v2/components/tabs-v2.css +++ b/packages/ui/src/v2/components/tabs-v2.css @@ -192,6 +192,7 @@ color: var(--v2-text-text-muted); font-size: 12px; font-weight: 500; + user-select: none; } [data-component="tabs-v2"][data-variant="settings"][data-orientation="vertical"] [data-slot="tabs-v2-trigger-wrapper"] { From 772e976d801d96f7319af2ee5efc8d5879cb35fc Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Fri, 10 Jul 2026 09:29:03 +0530 Subject: [PATCH 10/23] fix(app): timeline width jumps around when review panel is open/closed (#36202) --- packages/app/src/pages/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 678f7b1687..0217ed668c 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -483,7 +483,7 @@ export default function Page() { if (desktopSessionResizeOpen()) return `${sessionPanelResizedWidth()}px` return `calc(100% - ${layout.fileTree.width()}px)` }) - const centered = createMemo(() => isDesktop() && !desktopReviewOpen()) + const centered = createMemo(() => isDesktop() && (newSessionDesign() || !desktopReviewOpen())) const desktopV2PanelLayout = createMemo(() => sessionPanelLayout({ review: desktopV2ReviewOpen(), From d0ba5389248e05546849b9f69b7bc417aa5fd5d7 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 10 Jul 2026 01:48:51 -0400 Subject: [PATCH 11/23] zen: track openai cache_write_tokens --- artifacts/glm52-rise-video/sst-env.d.ts | 10 ++++++++++ packages/codemode/sst-env.d.ts | 10 ++++++++++ .../app/src/routes/zen/util/provider/openai.ts | 4 +++- packages/console/app/test/providerUsage.test.ts | 16 ++++++++++++++++ 4 files changed, 39 insertions(+), 1 deletion(-) create mode 100644 artifacts/glm52-rise-video/sst-env.d.ts create mode 100644 packages/codemode/sst-env.d.ts diff --git a/artifacts/glm52-rise-video/sst-env.d.ts b/artifacts/glm52-rise-video/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/artifacts/glm52-rise-video/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/codemode/sst-env.d.ts b/packages/codemode/sst-env.d.ts new file mode 100644 index 0000000000..64441936d7 --- /dev/null +++ b/packages/codemode/sst-env.d.ts @@ -0,0 +1,10 @@ +/* This file is auto-generated by SST. Do not edit. */ +/* tslint:disable */ +/* eslint-disable */ +/* deno-fmt-ignore-file */ +/* biome-ignore-all lint: auto-generated */ + +/// + +import "sst" +export {} \ No newline at end of file diff --git a/packages/console/app/src/routes/zen/util/provider/openai.ts b/packages/console/app/src/routes/zen/util/provider/openai.ts index 0e9992a289..cebc78a127 100644 --- a/packages/console/app/src/routes/zen/util/provider/openai.ts +++ b/packages/console/app/src/routes/zen/util/provider/openai.ts @@ -4,6 +4,7 @@ type Usage = { input_tokens?: number input_tokens_details?: { cached_tokens?: number + cache_write_tokens?: number } output_tokens?: number output_tokens_details?: { @@ -48,12 +49,13 @@ export const openaiHelper: ProviderHelper = ({ workspaceID }) => ({ const outputTokens = usage.output_tokens ?? 0 const reasoningTokens = usage.output_tokens_details?.reasoning_tokens ?? undefined const cacheReadTokens = usage.input_tokens_details?.cached_tokens ?? undefined + const cacheWriteTokens = usage.input_tokens_details?.cache_write_tokens ?? undefined return { inputTokens: inputTokens - (cacheReadTokens ?? 0), outputTokens, reasoningTokens, cacheReadTokens, - cacheWrite5mTokens: undefined, + cacheWrite5mTokens: cacheWriteTokens, cacheWrite1hTokens: undefined, } }, diff --git a/packages/console/app/test/providerUsage.test.ts b/packages/console/app/test/providerUsage.test.ts index d39c9fa861..7be3aacffd 100644 --- a/packages/console/app/test/providerUsage.test.ts +++ b/packages/console/app/test/providerUsage.test.ts @@ -65,4 +65,20 @@ describe("provider usage extraction", () => { output_tokens: 7, }) }) + + test("parses OpenAI stream cache write usage", () => { + const usageParser = providers.openai.createUsageParser() + usageParser.parse( + 'event: response.completed\ndata: {"response":{"usage":{"input_tokens":10,"input_tokens_details":{"cached_tokens":4,"cache_write_tokens":3},"output_tokens":2}}}', + ) + + expect(providers.openai.normalizeUsage(usageParser.retrieve())).toEqual({ + inputTokens: 6, + outputTokens: 2, + reasoningTokens: undefined, + cacheReadTokens: 4, + cacheWrite5mTokens: 3, + cacheWrite1hTokens: undefined, + }) + }) }) From 8a03fc265b6d73c2e15881fcc702c9cb3027dd0e Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 10 Jul 2026 15:49:05 +0800 Subject: [PATCH 12/23] feat(app): extract prompt state and add composer model selection (#36227) --- packages/app/src/components/prompt-input.tsx | 1 + .../components/prompt-input/submit.test.ts | 38 +++ .../app/src/components/prompt-input/submit.ts | 14 +- packages/app/src/components/titlebar.tsx | 12 +- packages/app/src/context/local.tsx | 9 +- packages/app/src/context/prompt-state.test.ts | 29 ++ packages/app/src/context/prompt-state.ts | 265 +++++++++++++++ packages/app/src/context/prompt.tsx | 320 +++--------------- packages/app/src/context/tab-memory.ts | 3 + packages/app/src/context/tabs.test.ts | 2 + packages/app/src/context/tabs.tsx | 12 +- packages/app/src/pages/new-session.tsx | 7 +- packages/app/src/pages/session.tsx | 13 +- .../composer/prompt-model-selection.ts | 133 ++++++++ .../composer/session-composer-controls.ts | 5 +- .../session/session-model-helpers.test.ts | 101 +++++- .../pages/session/session-model-helpers.ts | 47 +++ .../pages/session/use-composer-commands.tsx | 9 +- packages/app/src/utils/persist.ts | 2 +- 19 files changed, 729 insertions(+), 293 deletions(-) create mode 100644 packages/app/src/context/prompt-state.test.ts create mode 100644 packages/app/src/context/prompt-state.ts create mode 100644 packages/app/src/pages/session/composer/prompt-model-selection.ts diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 2de29897e7..bbb71e8722 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1335,6 +1335,7 @@ export const PromptInput: Component = (props) => { onQueue: props.onQueue, onAbort: props.onAbort, onSubmit: props.onSubmit, + model: props.controls.model.selection, }) const handleKeyDown = (event: KeyboardEvent) => { diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 9a65fa101d..ad9fd31db8 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -1,5 +1,6 @@ import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" import type { Prompt } from "@/context/prompt" +import type { ModelSelection } from "@/context/local" let createPromptSubmit: typeof import("./submit").createPromptSubmit @@ -33,6 +34,10 @@ const prompt = { current: () => promptValue, cursor: () => 0, dirty: () => true, + model: { + current: () => undefined, + set: () => undefined, + }, reset: () => undefined, set: () => undefined, context: { @@ -378,6 +383,39 @@ describe("prompt submit worktree selection", () => { }) }) + test("uses an injected model selection", async () => { + params = { id: "session-1" } + const model = { + current: () => ({ id: "draft-model", provider: { id: "draft-provider" } }), + variant: { current: () => "draft-variant" }, + } as unknown as ModelSelection + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + model, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(optimistic[0]).toMatchObject({ + message: { + model: { providerID: "draft-provider", modelID: "draft-model", variant: "draft-variant" }, + }, + }) + }) + test("seeds new sessions before optimistic prompts are added", async () => { const submit = createPromptSubmit({ prompt, diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 6807b5ab6d..c9f560d269 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -8,7 +8,7 @@ import { useTabs } from "@/context/tabs" import { useServerSync, type ServerSync } from "@/context/server-sync" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import { usePermission } from "@/context/permission" import { type ContextItem, type ImageAttachmentPart, type Prompt, type usePrompt } from "@/context/prompt" import { useSDK, type DirectorySDK } from "@/context/sdk" @@ -191,6 +191,7 @@ type PromptSubmitInput = { onQueue?: (draft: FollowupDraft) => void onAbort?: () => void onSubmit?: () => void + model?: ModelSelection } export function createPromptSubmit(input: PromptSubmitInput) { @@ -298,9 +299,10 @@ export function createPromptSubmit(input: PromptSubmitInput) { return } - const currentModel = local.model.current() + const modelSelection = input.model ?? local.model + const currentModel = modelSelection.current() const currentAgent = local.agent.current() - const variant = local.model.variant.current() + const variant = modelSelection.variant.current() if (!currentModel || !currentAgent) { showToast({ title: language.t("prompt.toast.modelAgentRequired.title"), @@ -377,7 +379,11 @@ export function createPromptSubmit(input: PromptSubmitInput) { await startTransition(() => { if (!session) return if (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory) - local.session.promote(sessionDirectory, session.id) + local.session.promote(sessionDirectory, session.id, { + agent: currentAgent.name, + model: { providerID: currentModel.provider.id, modelID: currentModel.id }, + variant: variant ?? null, + }) layout.handoff.setTabs(base64Encode(sessionDirectory), session.id) const draftID = search.draftId if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id }) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 2a818d565d..994d69a60f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -25,6 +25,7 @@ import { readSessionTabsRemovedDetail, SESSION_TABS_REMOVED_EVENT } from "@/comp import { useGlobal } from "@/context/global" import { ServerConnection, useServer } from "@/context/server" import { tabKey, useTabs } from "@/context/tabs" +import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" @@ -324,13 +325,20 @@ export function Titlebar(props: { update?: TitlebarUpdate }) { const route = layout.route() const activeSession = session() if (route.type === "session" && activeSession) { - tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "") + const sessionTab = { + type: "session" as const, + server: route.server ?? server.key, + sessionId: activeSession.id, + } + const model = tabs.stateValue(sessionTab, "prompt")?.model.current() + tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model) return } const activeTab = currentTab() if (activeTab?.type === "draft") { - tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "") + const model = tabs.stateValue(activeTab, "prompt")?.model.current() + tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model) return } diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 415da747e0..a12fd832fd 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -67,7 +67,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const list = createMemo(() => sync().data.agent.filter((item) => item.mode !== "subagent" && !item.hidden)) const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) - const [saved, setSaved] = persisted( + const [saved, setSaved, , savedReady] = persisted( { ...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]), migrate, @@ -375,11 +375,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model, agent, session: { + ready: savedReady, reset() { setStore({ draft: undefined, promoting: undefined }) }, - promote(dir: string, session: string) { - const next = clone(snapshot()) + promote(dir: string, session: string, state?: State) { + const next = clone(state ?? snapshot()) if (!next) return const key = handoffKey(serverSDK().scope, dir, session) handoff.set(key, next) @@ -409,3 +410,5 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ return result }, }) + +export type ModelSelection = ReturnType["model"] diff --git a/packages/app/src/context/prompt-state.test.ts b/packages/app/src/context/prompt-state.test.ts new file mode 100644 index 0000000000..24afe5424e --- /dev/null +++ b/packages/app/src/context/prompt-state.test.ts @@ -0,0 +1,29 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createPromptState, DEFAULT_PROMPT } from "./prompt-state" + +describe("prompt state initialization", () => { + test("initializes prompt text, cursor, and model together", () => { + createRoot((dispose) => { + const model = { providerID: "anthropic", modelID: "claude", variant: "high" } + const prompt = createPromptState({ prompt: "hello", model }) + + expect(prompt.current()).toEqual([{ type: "text", content: "hello", start: 0, end: 5 }]) + expect(prompt.cursor()).toBe(5) + expect(prompt.model.current()).toEqual(model) + expect(prompt.model.current()).not.toBe(model) + dispose() + }) + }) + + test("uses the default prompt without initial values", () => { + createRoot((dispose) => { + const prompt = createPromptState() + + expect(prompt.current()).toEqual(DEFAULT_PROMPT) + expect(prompt.cursor()).toBeUndefined() + expect(prompt.model.current()).toBeUndefined() + dispose() + }) + }) +}) diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts new file mode 100644 index 0000000000..b6a68be4e7 --- /dev/null +++ b/packages/app/src/context/prompt-state.ts @@ -0,0 +1,265 @@ +import { checksum } from "@opencode-ai/core/util/encode" +import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { batch, createMemo, type Accessor } from "solid-js" +import { createStore, type SetStoreFunction } from "solid-js/store" +import type { FileSelection } from "@/context/file" +import { Persist, persisted } from "@/utils/persist" +import type { ServerScope } from "@/utils/server-scope" + +interface PartBase { + content: string + start: number + end: number +} + +export interface TextPart extends PartBase { + type: "text" +} + +export interface FileAttachmentPart extends PartBase { + type: "file" + path: string + selection?: FileSelection + mime?: string + filename?: string + url?: string + source?: FilePartSource +} + +export interface AgentPart extends PartBase { + type: "agent" + name: string +} + +export interface ImageAttachmentPart { + type: "image" + id: string + filename: string + sourcePath?: string + mime: string + dataUrl: string +} + +export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart +export type Prompt = ContentPart[] + +export type PromptModel = { + providerID: string + modelID: string + variant?: string | null +} + +export type FileContextItem = { + type: "file" + path: string + selection?: FileSelection + comment?: string + commentID?: string + commentOrigin?: "review" | "file" + preview?: string +} + +export type ContextItem = FileContextItem +export type PromptScope = { draftID: string } | { dir: string; id?: string } + +export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] + +type PromptStore = { + prompt: Prompt + cursor?: number + model?: PromptModel + context: { + items: (ContextItem & { key: string })[] + } +} + +type InitialPrompt = { + prompt?: string + model?: PromptModel +} + +function isSelectionEqual(a?: FileSelection, b?: FileSelection) { + if (!a && !b) return true + if (!a || !b) return false + return ( + a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar + ) +} + +function isPartEqual(partA: ContentPart, partB: ContentPart) { + switch (partA.type) { + case "text": + return partB.type === "text" && partA.content === partB.content + case "file": + return ( + partB.type === "file" && + partA.path === partB.path && + partA.mime === partB.mime && + partA.filename === partB.filename && + isSelectionEqual(partA.selection, partB.selection) + ) + case "agent": + return partB.type === "agent" && partA.name === partB.name + case "image": + return partB.type === "image" && partA.id === partB.id + } +} + +export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { + if (promptA.length !== promptB.length) return false + for (let i = 0; i < promptA.length; i++) { + if (!isPartEqual(promptA[i], promptB[i])) return false + } + return true +} + +function cloneSelection(selection?: FileSelection) { + if (!selection) return undefined + return { ...selection } +} + +function clonePart(part: ContentPart): ContentPart { + if (part.type === "text") return { ...part } + if (part.type === "image") return { ...part } + if (part.type === "agent") return { ...part } + return { + ...part, + selection: cloneSelection(part.selection), + } +} + +function clonePrompt(prompt: Prompt): Prompt { + return prompt.map(clonePart) +} + +function contextItemKey(item: ContextItem) { + if (item.type !== "file") return item.type + const start = item.selection?.startLine + const end = item.selection?.endLine + const key = `${item.type}:${item.path}:${start}:${end}` + + if (item.commentID) return `${key}:c=${item.commentID}` + const comment = item.comment?.trim() + if (!comment) return key + const digest = checksum(comment) ?? comment + return `${key}:c=${digest.slice(0, 8)}` +} + +function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { + return item.type === "file" && !!item.comment?.trim() +} + +function createPromptActions(setStore: SetStoreFunction) { + return { + set(prompt: Prompt, cursorPosition?: number) { + const next = clonePrompt(prompt) + batch(() => { + setStore("prompt", next) + if (cursorPosition !== undefined) setStore("cursor", cursorPosition) + }) + }, + reset() { + batch(() => { + setStore("prompt", clonePrompt(DEFAULT_PROMPT)) + setStore("cursor", 0) + }) + }, + } +} + +function promptTarget(serverScope: ServerScope, scope: PromptScope) { + if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") + const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` + return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) +} + +function promptStore(initial?: InitialPrompt): PromptStore { + const text = initial?.prompt + return { + prompt: + text === undefined ? clonePrompt(DEFAULT_PROMPT) : [{ type: "text", content: text, start: 0, end: text.length }], + cursor: text === undefined ? undefined : text.length, + model: initial?.model ? { ...initial.model } : undefined, + context: { + items: [], + }, + } +} + +function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { + const actions = createPromptActions(setStore) + const value = { + current: () => store.prompt, + cursor: createMemo(() => store.cursor), + dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), + model: { + current: () => store.model, + set: (model: PromptModel | undefined) => setStore("model", model), + }, + context: { + items: createMemo(() => store.context.items), + add(item: ContextItem) { + const key = contextItemKey(item) + if (store.context.items.find((x) => x.key === key)) return + setStore("context", "items", (items) => [...items, { key, ...item }]) + }, + remove(key: string) { + setStore("context", "items", (items) => items.filter((x) => x.key !== key)) + }, + removeComment(path: string, commentID: string) { + setStore("context", "items", (items) => + items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), + ) + }, + updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { + setStore("context", "items", (items) => + items.map((item) => { + if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item + const value = { ...item, ...next } + return { ...value, key: contextItemKey(value) } + }), + ) + }, + replaceComments(items: FileContextItem[]) { + setStore("context", "items", (current) => [ + ...current.filter((item) => !isCommentItem(item)), + ...items.map((item) => ({ ...item, key: contextItemKey(item) })), + ]) + }, + }, + set: actions.set, + reset: actions.reset, + capture: () => value, + } + return value +} + +function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt) { + const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) + return { ready, ...createPromptStateValue(store, setStore) } +} + +export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { + return createPersistedPrompt(promptTarget(serverScope, scope), initial) +} + +export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { + return createPersistedPrompt(Persist.draft(draftID, "prompt"), initial) +} + +export type PromptSession = ReturnType + +export function createPromptReady(session: Accessor) { + return Object.defineProperty(() => session().ready(), "promise", { + get: () => session().ready.promise, + }) as (() => boolean) & { readonly promise: Promise | undefined } +} + +export function createPromptState(initial?: InitialPrompt) { + const [store, setStore] = createStore(promptStore(initial)) + const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) + return { + ready, + ...createPromptStateValue(store, setStore), + } +} diff --git a/packages/app/src/context/prompt.tsx b/packages/app/src/context/prompt.tsx index c361493516..0ad8e70f3c 100644 --- a/packages/app/src/context/prompt.tsx +++ b/packages/app/src/context/prompt.tsx @@ -1,186 +1,49 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" import { createSimpleContext } from "@opencode-ai/ui/context" -import { base64Encode, checksum } from "@opencode-ai/core/util/encode" import { useParams, useSearchParams } from "@solidjs/router" -import { batch, createMemo, createRoot, getOwner, onCleanup, type Accessor } from "solid-js" -import { createStore, type SetStoreFunction } from "solid-js/store" -import type { FileSelection } from "@/context/file" -import { Persist, persisted } from "@/utils/persist" +import { createMemo, createRoot, getOwner, onCleanup } from "solid-js" +import { requireServerKey } from "@/utils/session-route" +import { ServerConnection } from "./server" import { useServerSDK } from "./server-sdk" -import type { ServerScope } from "@/utils/server-scope" +import { useSettings } from "./settings" import { useSDK } from "./sdk" import { useTabs, type Tab } from "./tabs" -import { ServerConnection } from "./server" -import { requireServerKey } from "@/utils/session-route" -import { useSettings } from "./settings" -import type { FilePartSource } from "@opencode-ai/sdk/v2/client" +import { + createPromptReady, + createPromptSession, + type ContextItem, + type FileContextItem, + type Prompt, + type PromptModel, + type PromptScope, + type PromptSession, +} from "./prompt-state" -interface PartBase { - content: string - start: number - end: number -} - -export interface TextPart extends PartBase { - type: "text" -} - -export interface FileAttachmentPart extends PartBase { - type: "file" - path: string - selection?: FileSelection - mime?: string - filename?: string - url?: string - source?: FilePartSource -} - -export interface AgentPart extends PartBase { - type: "agent" - name: string -} - -export interface ImageAttachmentPart { - type: "image" - id: string - filename: string - sourcePath?: string - mime: string - dataUrl: string -} - -export type ContentPart = TextPart | FileAttachmentPart | AgentPart | ImageAttachmentPart -export type Prompt = ContentPart[] - -export type FileContextItem = { - type: "file" - path: string - selection?: FileSelection - comment?: string - commentID?: string - commentOrigin?: "review" | "file" - preview?: string -} - -export type ContextItem = FileContextItem - -export const DEFAULT_PROMPT: Prompt = [{ type: "text", content: "", start: 0, end: 0 }] - -function isSelectionEqual(a?: FileSelection, b?: FileSelection) { - if (!a && !b) return true - if (!a || !b) return false - return ( - a.startLine === b.startLine && a.startChar === b.startChar && a.endLine === b.endLine && a.endChar === b.endChar - ) -} - -function isPartEqual(partA: ContentPart, partB: ContentPart) { - switch (partA.type) { - case "text": - return partB.type === "text" && partA.content === partB.content - case "file": - return ( - partB.type === "file" && - partA.path === partB.path && - partA.mime === partB.mime && - partA.filename === partB.filename && - isSelectionEqual(partA.selection, partB.selection) - ) - case "agent": - return partB.type === "agent" && partA.name === partB.name - case "image": - return partB.type === "image" && partA.id === partB.id - } -} - -export function isPromptEqual(promptA: Prompt, promptB: Prompt): boolean { - if (promptA.length !== promptB.length) return false - for (let i = 0; i < promptA.length; i++) { - if (!isPartEqual(promptA[i], promptB[i])) return false - } - return true -} - -function cloneSelection(selection?: FileSelection) { - if (!selection) return undefined - return { ...selection } -} - -function clonePart(part: ContentPart): ContentPart { - if (part.type === "text") return { ...part } - if (part.type === "image") return { ...part } - if (part.type === "agent") return { ...part } - return { - ...part, - selection: cloneSelection(part.selection), - } -} - -function clonePrompt(prompt: Prompt): Prompt { - return prompt.map(clonePart) -} - -function contextItemKey(item: ContextItem) { - if (item.type !== "file") return item.type - const start = item.selection?.startLine - const end = item.selection?.endLine - const key = `${item.type}:${item.path}:${start}:${end}` - - if (item.commentID) { - return `${key}:c=${item.commentID}` - } - - const comment = item.comment?.trim() - if (!comment) return key - const digest = checksum(comment) ?? comment - return `${key}:c=${digest.slice(0, 8)}` -} - -function isCommentItem(item: ContextItem | (ContextItem & { key: string })) { - return item.type === "file" && !!item.comment?.trim() -} - -function createPromptActions( - setStore: SetStoreFunction<{ - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } - }>, -) { - return { - set(prompt: Prompt, cursorPosition?: number) { - const next = clonePrompt(prompt) - batch(() => { - setStore("prompt", next) - if (cursorPosition !== undefined) setStore("cursor", cursorPosition) - }) - }, - reset() { - batch(() => { - setStore("prompt", clonePrompt(DEFAULT_PROMPT)) - setStore("cursor", 0) - }) - }, - } -} +export { + createPromptReady, + createPromptSession, + createPromptState, + DEFAULT_PROMPT, + isPromptEqual, +} from "./prompt-state" +export type { + AgentPart, + ContentPart, + ContextItem, + FileAttachmentPart, + FileContextItem, + ImageAttachmentPart, + Prompt, + PromptModel, + PromptScope, + PromptSession, + TextPart, +} from "./prompt-state" const WORKSPACE_KEY = "__workspace__" const MAX_PROMPT_SESSIONS = 20 -type PromptSession = ReturnType - -type PromptStore = { - prompt: Prompt - cursor?: number - context: { - items: (ContextItem & { key: string })[] - } -} - -type Scope = { draftID: string } | { dir: string; id?: string } - -export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnection.Key) { +export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) { if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID) if (!scope.id) return return ( @@ -189,7 +52,7 @@ export function selectPromptTab(tabs: Tab[], scope: Scope, server: ServerConnect ) } -function scopeKey(scope: Scope) { +function scopeKey(scope: PromptScope) { if ("draftID" in scope) return `draft:${scope.draftID}` return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}` } @@ -199,91 +62,6 @@ type PromptCacheEntry = { dispose: VoidFunction } -function promptTarget(serverScope: ServerScope, scope: Scope) { - if ("draftID" in scope) return Persist.draft(scope.draftID, "prompt") - const legacy = `${scope.dir}/prompt${scope.id ? "/" + scope.id : ""}.v2` - return Persist.serverScoped(serverScope, scope.dir, scope.id, "prompt", [legacy]) -} - -export function createPromptSession(serverScope: ServerScope, scope: Scope) { - const [store, setStore, _, ready] = persisted( - promptTarget(serverScope, scope), - createStore(promptStore()), - ) - - return { ready, ...createPromptStateValue(store, setStore) } -} - -export function createPromptReady(session: Accessor) { - return Object.defineProperty(() => session().ready(), "promise", { - get: () => session().ready.promise, - }) as (() => boolean) & { readonly promise: Promise | undefined } -} - -function promptStore(): PromptStore { - return { - prompt: clonePrompt(DEFAULT_PROMPT), - cursor: undefined, - context: { - items: [], - }, - } -} - -function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction) { - const actions = createPromptActions(setStore) - - const value = { - current: () => store.prompt, - cursor: createMemo(() => store.cursor), - dirty: () => !isPromptEqual(store.prompt, DEFAULT_PROMPT), - context: { - items: createMemo(() => store.context.items), - add(item: ContextItem) { - const key = contextItemKey(item) - if (store.context.items.find((x) => x.key === key)) return - setStore("context", "items", (items) => [...items, { key, ...item }]) - }, - remove(key: string) { - setStore("context", "items", (items) => items.filter((x) => x.key !== key)) - }, - removeComment(path: string, commentID: string) { - setStore("context", "items", (items) => - items.filter((item) => !(item.type === "file" && item.path === path && item.commentID === commentID)), - ) - }, - updateComment(path: string, commentID: string, next: Partial & { comment?: string }) { - setStore("context", "items", (items) => - items.map((item) => { - if (item.type !== "file" || item.path !== path || item.commentID !== commentID) return item - const value = { ...item, ...next } - return { ...value, key: contextItemKey(value) } - }), - ) - }, - replaceComments(items: FileContextItem[]) { - setStore("context", "items", (current) => [ - ...current.filter((item) => !isCommentItem(item)), - ...items.map((item) => ({ ...item, key: contextItemKey(item) })), - ]) - }, - }, - set: actions.set, - reset: actions.reset, - capture: () => value, - } - return value -} - -export function createPromptState() { - const [store, setStore] = createStore(promptStore()) - const ready = Object.assign(() => true, { promise: Promise.resolve(true) }) - return { - ready, - ...createPromptStateValue(store, setStore), - } -} - export const createTabPromptState = ( tabs: ReturnType, tab: Tab, @@ -303,9 +81,7 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const cache = new Map() const disposeAll = () => { - for (const entry of cache.values()) { - entry.dispose() - } + for (const entry of cache.values()) entry.dispose() cache.clear() } @@ -324,13 +100,11 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( const owner = getOwner() const serverKey = () => params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server) - const scope = () => + const scope = (): PromptScope => search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id } - const load = (scope: Scope) => { + const load = (scope: PromptScope) => { const current = settings.general.newLayoutDesigns() ? selectPromptTab(tabs.store, scope, serverKey()) : undefined - if (current) { - return createTabPromptState(tabs, current, serverSDK().scope, scope) - } + if (current) return createTabPromptState(tabs, current, serverSDK().scope, scope) const key = scopeKey(scope) const existing = cache.get(key) @@ -354,15 +128,19 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( } const session = createMemo(() => load(scope())) - const pick = (scope?: Scope) => (scope ? load(scope) : session()) + const pick = (scope?: PromptScope) => (scope ? load(scope) : session()) const ready = createPromptReady(session) return { ready, - capture: (scope?: Scope) => pick(scope).capture(), + capture: (scope?: PromptScope) => pick(scope).capture(), current: () => session().current(), cursor: () => session().cursor(), dirty: () => session().dirty(), + model: { + current: () => session().model.current(), + set: (model: PromptModel | undefined) => session().model.set(model), + }, context: { items: () => session().context.items(), add: (item: ContextItem) => session().context.add(item), @@ -372,8 +150,8 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext( session().context.updateComment(path, commentID, next), replaceComments: (items: FileContextItem[]) => session().context.replaceComments(items), }, - set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition), - reset: (scope?: Scope) => pick(scope).reset(), + set: (prompt: Prompt, cursorPosition?: number, scope?: PromptScope) => pick(scope).set(prompt, cursorPosition), + reset: (scope?: PromptScope) => pick(scope).reset(), } }, }) diff --git a/packages/app/src/context/tab-memory.ts b/packages/app/src/context/tab-memory.ts index de8050f379..70bb987c7b 100644 --- a/packages/app/src/context/tab-memory.ts +++ b/packages/app/src/context/tab-memory.ts @@ -16,6 +16,9 @@ export function createTabMemory(owner: Owner | null) { } return { + get(key: string, name: string) { + return entries.get(key)?.get(name)?.value as T | undefined + }, ensure(key: string, name: string, init: () => T) { const state = entries.get(key) ?? new Map() if (!entries.has(key)) entries.set(key, state) diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index b4bff4caff..b56ea2dfcf 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -22,6 +22,8 @@ describe("tab memory", () => { }) expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first) + expect(memory.get("tab", "prompt")).toBe(first) + expect(memory.get("missing", "prompt")).toBeUndefined() expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first) memory.remove("tab") diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 47ed77e781..cac6500350 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -11,6 +11,7 @@ import { SessionTabsRemovedDetail } from "@/components/titlebar-session-events" import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" +import { createDraftPromptSession, type PromptModel } from "./prompt-state" export type SessionTab = { type: "session" @@ -207,15 +208,17 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`) return tab }, - newDraft(draft: Omit, prompt?: string) { + newDraft(draft: Omit, prompt?: string, model?: PromptModel) { const draftID = uuid() + const tab = { type: "draft" as const, draftID, ...draft } + memory.ensure(tabKey(tab), "prompt", () => createDraftPromptSession(draftID, { prompt, model })) void startTransition(() => { setStore( produce((tabs) => { - tabs.push({ type: "draft", draftID, ...draft }) + tabs.push(tab) }), ) - navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID)) + navigate(draftHref(draftID)) }) }, updateDraft(draftID: string, draft: Partial>) { @@ -373,6 +376,9 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ state(tab: Tab, name: string, init: () => T) { return memory.ensure(tabKey(tab), name, init) }, + stateValue(tab: Tab, name: string) { + return memory.get(tabKey(tab), name) + }, } return { ...actions, store, info, ready, recentReady } diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 926b0f2e97..19c90b9a38 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -31,6 +31,8 @@ import { useProviders } from "@/hooks/use-providers" import { useSettingsDialog } from "@/components/settings-dialog" import { Persist, persisted } from "@/utils/persist" import createPresence from "solid-presence" +import { useLocal } from "@/context/local" +import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 @@ -54,8 +56,10 @@ export default function NewSessionPage() { const openProviderSettings = useSettingsDialog("providers") const route = useSessionKey() const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const local = useLocal() + const model = createPromptModelSelection({ agent: local.agent.current }) - useComposerCommands() + useComposerCommands({ model }) let inputRef: HTMLDivElement | undefined @@ -63,6 +67,7 @@ export default function NewSessionPage() { sessionKey: route.sessionKey, sessionID: () => route.params.id, queryOptions: serverSync().queryOptions, + model, }) const projectControls = createPromptProjectControls() const projectController = createPromptProjectController({ diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 0217ed668c..66afcfa9bf 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -69,7 +69,7 @@ import { MessageTimeline } from "@/pages/session/timeline/message-timeline" import { createTimelineModel } from "@/pages/session/timeline/model" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" -import { syncSessionModel } from "@/pages/session/session-model-helpers" +import { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers" import { clampSessionPanelWidth, SESSION_PANEL_WIDTH_MIN, @@ -557,6 +557,17 @@ export default function Page() { ), ) + let restoredModelSession: string | undefined + createEffect(() => { + const id = params.id + if (!id || !prompt.ready() || !local.session.ready()) return + if (restoredModelSession !== id) { + restoredModelSession = id + if (restorePromptModel(local, prompt)) return + } + syncPromptModel(local, prompt) + }) + createEffect( on( () => ({ dir: sdk().directory, id: params.id }), diff --git a/packages/app/src/pages/session/composer/prompt-model-selection.ts b/packages/app/src/pages/session/composer/prompt-model-selection.ts new file mode 100644 index 0000000000..3f5bb0f477 --- /dev/null +++ b/packages/app/src/pages/session/composer/prompt-model-selection.ts @@ -0,0 +1,133 @@ +import { batch, createMemo, startTransition } from "solid-js" +import { useModels } from "@/context/models" +import type { ModelKey, ModelSelection } from "@/context/local" +import { cycleModelVariant, getConfiguredAgentVariant, resolveModelVariant } from "@/context/model-variant" +import { usePrompt } from "@/context/prompt" +import { useSDK } from "@/context/sdk" +import { useSync } from "@/context/sync" +import { useProviders } from "@/hooks/use-providers" + +export function createPromptModelSelection(input: { agent: () => { model?: ModelKey; variant?: string } | undefined }) { + const sdk = useSDK() + const sync = useSync() + const models = useModels() + const prompt = usePrompt() + const providers = useProviders(() => sdk().directory) + const connected = createMemo(() => new Set(providers.connected().map((item) => item.id))) + + const valid = (model: ModelKey) => { + const provider = providers.all().get(model.providerID) + return !!provider?.models[model.modelID] && connected().has(model.providerID) + } + + const configured = () => { + const value = sync().data.config.model + if (!value) return + const [providerID, modelID] = value.split("/") + const model = { providerID, modelID } + if (valid(model)) return model + } + + const recent = () => models.recent.list().find(valid) + const fallback = () => { + const defaults = providers.default() + return providers.connected().flatMap((provider) => { + const modelID = defaults[provider.id] ?? Object.values(provider.models)[0]?.id + return modelID ? [{ providerID: provider.id, modelID }] : [] + })[0] + } + + const current = () => { + const key = [prompt.model.current(), input.agent()?.model, configured(), recent(), fallback()].find( + (item): item is ModelKey => !!item && valid(item), + ) + if (!key) return + return models.find(key) + } + const recentModels = createMemo(() => + models.recent + .list() + .map(models.find) + .filter((item): item is NonNullable => !!item), + ) + + const selection = { + ready: models.ready, + current, + recent: recentModels, + list: models.list, + cycle(direction: 1 | -1) { + const items = recentModels() + const item = current() + if (!item) return + const index = items.findIndex((entry) => entry.provider.id === item.provider.id && entry.id === item.id) + if (index === -1) return + const next = items[(index + direction + items.length) % items.length] + if (next) selection.set({ providerID: next.provider.id, modelID: next.id }) + }, + set(item: ModelKey | undefined, options?: { recent?: boolean }) { + startTransition(() => + batch(() => { + prompt.model.set(item ? { ...item, variant: prompt.model.current()?.variant } : undefined) + if (!item) return + models.setVisibility(item, true) + if (options?.recent) models.recent.push(item) + }), + ) + }, + visible: models.visible, + setVisibility: models.setVisibility, + variant: { + configured() { + const item = input.agent() + const model = current() + if (!item || !model) return + return getConfiguredAgentVariant({ + agent: { model: item.model, variant: item.variant }, + model: { providerID: model.provider.id, modelID: model.id, variants: model.variants }, + }) + }, + selected() { + return prompt.model.current()?.variant + }, + current() { + const resolved = resolveModelVariant({ + variants: this.list(), + selected: this.selected(), + configured: this.configured(), + }) + if (resolved) return resolved + const model = current() + if (!model) return + const saved = models.variant.get({ providerID: model.provider.id, modelID: model.id }) + if (saved && this.list().includes(saved)) return saved + }, + list() { + return Object.keys(current()?.variants ?? {}) + }, + set(value: string | undefined) { + startTransition(() => + batch(() => { + const model = current() + if (!model) return + prompt.model.set({ providerID: model.provider.id, modelID: model.id, variant: value ?? null }) + models.variant.set({ providerID: model.provider.id, modelID: model.id }, value) + }), + ) + }, + cycle() { + const variants = this.list() + if (variants.length === 0) return + this.set( + cycleModelVariant({ + variants, + selected: this.selected(), + configured: this.configured(), + }), + ) + }, + }, + } satisfies ModelSelection + + return selection +} diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index c7f2304531..8d5d96719c 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -7,7 +7,7 @@ import type { PromptProjectControls } from "@/components/prompt-project-selector import { useDirectoryPicker } from "@/components/directory-picker" import { useGlobal } from "@/context/global" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import type { QueryOptionsApi } from "@/context/server-sync" import { useServerSDK } from "@/context/server-sdk" import { serverName, ServerConnection, useServer } from "@/context/server" @@ -22,6 +22,7 @@ export function createPromptInputController(input: { sessionKey: Accessor sessionID: Accessor queryOptions: Pick + model?: ModelSelection }) { const layout = useLayout() const local = useLocal() @@ -44,7 +45,7 @@ export function createPromptInputController(input: { select: local.agent.set, }, model: { - selection: local.model, + selection: input.model ?? local.model, paid: providers.paid().length > 0, loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, }, diff --git a/packages/app/src/pages/session/session-model-helpers.test.ts b/packages/app/src/pages/session/session-model-helpers.test.ts index 2ab293b8fb..201627b369 100644 --- a/packages/app/src/pages/session/session-model-helpers.test.ts +++ b/packages/app/src/pages/session/session-model-helpers.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import type { UserMessage } from "@opencode-ai/sdk/v2" -import { resetSessionModel, syncSessionModel } from "./session-model-helpers" +import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers" const message = (input?: { agent?: string; model?: UserMessage["model"] }) => ({ @@ -50,3 +50,102 @@ describe("resetSessionModel", () => { expect(calls).toEqual(["reset"]) }) }) + +describe("syncPromptModel", () => { + test("stores the effective session model in prompt state", () => { + const calls: unknown[] = [] + + syncPromptModel( + { + model: { + current: () => ({ id: "claude-sonnet-4", provider: { id: "anthropic" } }), + set() {}, + variant: { current: () => "high", set() {} }, + }, + }, + { + model: { + current: () => undefined, + set: (model) => calls.push(model), + }, + }, + ) + + expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" }]) + }) + + test("does not rewrite an unchanged prompt model", () => { + const calls: unknown[] = [] + const model = { providerID: "anthropic", modelID: "claude-sonnet-4", variant: "high" } + + syncPromptModel( + { + model: { + current: () => ({ id: model.modelID, provider: { id: model.providerID } }), + set() {}, + variant: { current: () => model.variant, set() {} }, + }, + }, + { + model: { + current: () => model, + set: (value) => calls.push(value), + }, + }, + ) + + expect(calls).toEqual([]) + }) +}) + +describe("restorePromptModel", () => { + test("restores the persisted prompt model into session selection", () => { + const calls: unknown[] = [] + const restored = restorePromptModel( + { + model: { + current: () => ({ id: "gpt", provider: { id: "openai" } }), + set: (model) => calls.push(model), + variant: { + current: () => undefined, + set: (variant) => calls.push(variant), + }, + }, + }, + { + model: { + current: () => ({ providerID: "anthropic", modelID: "claude", variant: "high" }), + set() {}, + }, + }, + ) + + expect(restored).toBe(true) + expect(calls).toEqual([{ providerID: "anthropic", modelID: "claude" }, "high"]) + }) + + test("does nothing without a persisted prompt model", () => { + const calls: unknown[] = [] + const restored = restorePromptModel( + { + model: { + current: () => ({ id: "gpt", provider: { id: "openai" } }), + set: (model) => calls.push(model), + variant: { + current: () => undefined, + set: (variant) => calls.push(variant), + }, + }, + }, + { + model: { + current: () => undefined, + set() {}, + }, + }, + ) + + expect(restored).toBe(false) + expect(calls).toEqual([]) + }) +}) diff --git a/packages/app/src/pages/session/session-model-helpers.ts b/packages/app/src/pages/session/session-model-helpers.ts index c9e2e1dbd2..f2aadff27d 100644 --- a/packages/app/src/pages/session/session-model-helpers.ts +++ b/packages/app/src/pages/session/session-model-helpers.ts @@ -7,6 +7,24 @@ type Local = { } } +type ModelSelection = { + model: { + current(): { id: string; provider: { id: string } } | undefined + set(model: { providerID: string; modelID: string }): void + variant: { + current(): string | undefined + set(variant: string | undefined): void + } + } +} + +type PromptState = { + model: { + current(): { providerID: string; modelID: string; variant?: string | null } | undefined + set(model: { providerID: string; modelID: string; variant?: string | null }): void + } +} + export const resetSessionModel = (local: Local) => { local.session.reset() } @@ -14,3 +32,32 @@ export const resetSessionModel = (local: Local) => { export const syncSessionModel = (local: Local, msg: UserMessage) => { local.session.restore(msg) } + +export const syncPromptModel = (local: ModelSelection, prompt: PromptState) => { + const model = local.model.current() + if (!model) return + const next = { + providerID: model.provider.id, + modelID: model.id, + variant: local.model.variant.current(), + } + const current = prompt.model.current() + if (current?.providerID === next.providerID && current.modelID === next.modelID && current.variant === next.variant) + return + prompt.model.set(next) +} + +export const restorePromptModel = (local: ModelSelection, prompt: PromptState) => { + const model = prompt.model.current() + if (!model) return false + const current = local.model.current() + if ( + current?.provider.id === model.providerID && + current.id === model.modelID && + local.model.variant.current() === (model.variant ?? undefined) + ) + return true + local.model.set({ providerID: model.providerID, modelID: model.modelID }) + local.model.variant.set(model.variant ?? undefined) + return true +} diff --git a/packages/app/src/pages/session/use-composer-commands.tsx b/packages/app/src/pages/session/use-composer-commands.tsx index 98239010f5..9b75c7bf4c 100644 --- a/packages/app/src/pages/session/use-composer-commands.tsx +++ b/packages/app/src/pages/session/use-composer-commands.tsx @@ -1,6 +1,6 @@ import { useCommand, type CommandOption } from "@/context/command" import { useLanguage } from "@/context/language" -import { useLocal } from "@/context/local" +import { useLocal, type ModelSelection } from "@/context/local" import { useSettings } from "@/context/settings" import { useDialog } from "@opencode-ai/ui/context/dialog" import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom" @@ -14,7 +14,7 @@ const withCategory = (category: string) => { }) } -export const useComposerCommands = () => { +export const useComposerCommands = (input: { model?: ModelSelection } = {}) => { const command = useCommand() const dialog = useDialog() const language = useLanguage() @@ -22,6 +22,7 @@ export const useComposerCommands = () => { const settings = useSettings() const { sessionKey } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) + const model = input.model ?? local.model const modelCommand = withCategory(language.t("command.category.model")) const agentCommand = withCategory(language.t("command.category.agent")) @@ -43,7 +44,7 @@ export const useComposerCommands = () => { } const { DialogSelectModel } = await import("@/components/dialog-select-model") owner.run(() => { - void dialog.show(() => , restoreComposer) + void dialog.show(() => , restoreComposer) }) } @@ -61,7 +62,7 @@ export const useComposerCommands = () => { title: language.t("command.model.variant.cycle"), description: language.t("command.model.variant.cycle.description"), keybind: "shift+mod+d", - onSelect: () => local.model.variant.cycle(), + onSelect: () => model.variant.cycle(), }), agentCommand({ id: "agent.cycle", diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 671b798f61..a2daae4866 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage { } } -const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"] +const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"] export function draftPersistedKeys() { return DRAFT_PERSISTED_KEYS From 60c88336b9430b8a516c6a44884e3fbf45dc2f62 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 10 Jul 2026 05:52:25 -0500 Subject: [PATCH 13/23] feat(stats): update model comparison page --- .../[secondLab]/[secondModel].tsx | 1282 +++++++++++++---- packages/stats/app/src/routes/index.css | 584 ++++++++ packages/stats/core/src/domain/home.ts | 38 +- 3 files changed, 1585 insertions(+), 319 deletions(-) diff --git a/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx b/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx index 88df35bf36..6ac3fd1d71 100644 --- a/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx +++ b/packages/stats/app/src/routes/compare/[firstLab]/[firstModel]/[secondLab]/[secondModel].tsx @@ -1,18 +1,23 @@ import "../../../../index.css" import { Link, Meta, Title } from "@solidjs/meta" -import { getStatsModelComparisonData, type StatsModelComparisonEntry } from "@opencode-ai/stats-core/domain/home" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { + getStatsModelsComparisonData, + type ModelUsagePoint, + type StatsModelComparisonInput, + type StatsModelComparisonEntry, +} from "@opencode-ai/stats-core/domain/home" import { runtime } from "@opencode-ai/stats-core/runtime" -import { createAsync, query, useParams } from "@solidjs/router" -import { createMemo, createSignal, For, onMount, Show } from "solid-js" +import { createAsync, query, useParams, useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js" import { getRequestEvent } from "solid-js/web" import { - ComparisonCardsSection, + comparisonHref, modelRefFromCatalog, uniqueComparisonPairs, type ComparisonModelRef, type ComparisonPair, } from "../../../../compare-cards" -import { ComparisonSelector } from "../../../../compare-selector" import { catalogSlug, findModelCatalogEntry, @@ -34,10 +39,11 @@ import { const compareFallbackUrl = "https://stats.opencode.ai" const compareHeaderLinks: readonly HeaderLink[] = [ - { href: "#overview", label: "Overview" }, - { href: "#comparison", label: "Comparison" }, - { href: "#compare-tool", label: "Compare" }, - { href: "#model-comparison", label: "Related" }, + { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, + { href: `${import.meta.env.BASE_URL}#leaderboard`, label: "Leaderboard" }, + { href: `${import.meta.env.BASE_URL}#market-share`, label: "Market Share" }, + { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, + { href: `${import.meta.env.BASE_URL}#session-cost`, label: "Session Cost" }, ] const compareFooterLinks: readonly HeaderLink[] = [ { href: import.meta.env.BASE_URL, label: "Data Home" }, @@ -45,6 +51,13 @@ const compareFooterLinks: readonly HeaderLink[] = [ { href: `${import.meta.env.BASE_URL}#top-models`, label: "Top Models" }, { href: `${import.meta.env.BASE_URL}#token-cost`, label: "Token Cost" }, ] +const heroLabs = [ + { lab: "deepseek", label: "DeepSeek" }, + { lab: "openai", label: "OpenAI" }, + { lab: "anthropic", label: "Anthropic" }, +] as const +const usageBarLimit = 60 +const comparisonModelLimit = 6 type ComparisonModel = { name: string @@ -55,26 +68,42 @@ type ComparisonModel = { stats: StatsModelComparisonEntry | null } type ComparisonDirection = "higher" | "lower" -type ComparisonCell = { value: string; detail?: string; score?: number } -type ComparisonRow = { - label: string - description: string - direction: ComparisonDirection - cells: [ComparisonCell, ComparisonCell] +type ComparisonDetailCell = { + value: string + unit?: string + href?: string + kind?: "boolean" + score?: number + trend?: number } +type ComparisonDetailRow = { + label: string + direction?: ComparisonDirection + cells: ComparisonDetailCell[] +} +type ComparisonDetailSection = { + title: string + badge?: string + rows: ComparisonDetailRow[] + usage?: ModelUsagePoint[][] +} +type ComparisonModelSelection = { + lab: string + slug: string + catalog: ModelCatalogEntry | null | undefined +} +type ComparisonModels = [ComparisonModel, ComparisonModel, ...ComparisonModel[]] -const getComparisonData = query( - async (firstLab: string, firstModel: string, secondLab: string, secondModel: string) => { - "use server" - return runtime.runPromise(getStatsModelComparisonData(firstLab, firstModel, secondLab, secondModel)) - }, - "getStatsModelComparisonData", -) +const getComparisonData = query(async (models: StatsModelComparisonInput[]) => { + "use server" + return runtime.runPromise(getStatsModelsComparisonData(models)) +}, "getStatsModelComparisonDetailData") export default function ModelComparePair() { const event = getRequestEvent() event?.response.headers.set("Cache-Control", "public, max-age=60, s-maxage=300, stale-while-revalidate=86400") const params = useParams() + const [searchParams] = useSearchParams<{ add?: string }>() const firstLabParam = createMemo(() => params.firstLab ?? "") const firstModelParam = createMemo(() => params.firstModel ?? "") const secondLabParam = createMemo(() => params.secondLab ?? "") @@ -82,24 +111,59 @@ export default function ModelComparePair() { const catalog = createAsync(() => getModelCatalog()) const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam())) const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam())) + const modelSelections = createMemo(() => { + const selected: ComparisonModelSelection[] = [ + { lab: firstLabParam(), slug: firstModelParam(), catalog: firstCatalog() }, + { lab: secondLabParam(), slug: secondModelParam(), catalog: secondCatalog() }, + ...parseAdditionalModels(searchParams.add).map((model) => ({ + ...model, + catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug), + })), + ] + return selected + .filter( + (model, index) => + index < 2 || + selected.findIndex( + (candidate) => comparisonModelSelectionKey(candidate) === comparisonModelSelectionKey(model), + ) === index, + ) + .slice(0, comparisonModelLimit) as [ + ComparisonModelSelection, + ComparisonModelSelection, + ...ComparisonModelSelection[], + ] + }) const stats = createAsync(() => { - if (catalog() === undefined || firstCatalog() === undefined || secondCatalog() === undefined) + const selected = modelSelections() + if (catalog() === undefined || selected.some((model) => model.catalog === undefined)) return Promise.resolve(undefined) return getComparisonData( - firstCatalog()?.lab ?? firstLabParam(), - firstCatalog()?.slug ?? firstModelParam(), - secondCatalog()?.lab ?? secondLabParam(), - secondCatalog()?.slug ?? secondModelParam(), + selected.map((model) => ({ + provider: model.catalog?.lab ?? model.lab, + model: model.catalog?.slug ?? model.slug, + })), ) }) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") + const [highlightBest, setHighlightBest] = createSignal(true) + const [addingModel, setAddingModel] = createSignal(false) + let comparisonPage: HTMLElement | undefined + let comparisonHeadingScroll: HTMLDivElement | undefined + let comparisonBodyScroll: HTMLDivElement | undefined + // Trackpads emit noisy diagonal deltas, so keep an axis until a clear handoff or idle. + const comparisonWheel = { + axis: undefined as "horizontal" | "vertical" | undefined, + deltaX: 0, + deltaY: 0, + reset: undefined as number | undefined, + } const models = createMemo( () => - [ - buildComparisonModel(firstLabParam(), firstModelParam(), firstCatalog() ?? null, stats()?.models[0] ?? null), - buildComparisonModel(secondLabParam(), secondModelParam(), secondCatalog() ?? null, stats()?.models[1] ?? null), - ] as const, + modelSelections().map((model, index) => + buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), + ) as ComparisonModels, ) const title = createMemo(() => `${models()[0].name} vs ${models()[1].name} - Model Comparison`) const description = createMemo( @@ -118,15 +182,89 @@ export default function ModelComparePair() { event?.request.url ?? (typeof window === "undefined" ? compareFallbackUrl : window.location.href), ).toString(), ) - const rows = createMemo(() => buildComparisonRows(models()[0], models()[1])) + const detailSections = createMemo(() => buildComparisonDetailSections(models())) const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1])) const selectorModels = createMemo(() => - uniqueCatalogModels([ - comparisonCatalogEntry(models()[0]), - comparisonCatalogEntry(models()[1]), - ...(catalog()?.models ?? []), - ]), + uniqueCatalogModels([...models().map(comparisonCatalogEntry), ...(catalog()?.models ?? [])]), ) + const selectedCatalogModels = createMemo(() => models().map(comparisonCatalogEntry)) + const canAddModel = createMemo( + () => + models().length < comparisonModelLimit && + selectorModels().some((model) => !selectedCatalogModels().some((selected) => selected.id === model.id)), + ) + const navigateToModels = (next: ModelCatalogEntry[]) => { + if (typeof window === "undefined" || next.length < 2) return + window.location.href = comparisonModelsHref(next) + } + const syncComparisonScroll = (source: HTMLDivElement, target: HTMLDivElement | undefined) => { + if (target && target.scrollLeft !== source.scrollLeft) target.scrollLeft = source.scrollLeft + } + const resetComparisonWheel = () => { + comparisonWheel.axis = undefined + comparisonWheel.deltaX = 0 + comparisonWheel.deltaY = 0 + comparisonWheel.reset = undefined + } + const handleComparisonWheel = (event: WheelEvent) => { + if (event.ctrlKey) return + if (event.target instanceof Element && event.target.closest('[data-component="compare-model-modal"]')) return + if (!(event.currentTarget instanceof HTMLDivElement)) return + const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1 + const deltaX = (event.shiftKey && event.deltaX === 0 ? event.deltaY : event.deltaX) * scale + const deltaY = (event.shiftKey ? 0 : event.deltaY) * scale + if (event.currentTarget.scrollWidth <= event.currentTarget.clientWidth + 1) { + resetComparisonWheel() + if (deltaY === 0) return + event.preventDefault() + window.scrollBy({ top: deltaY }) + return + } + + if ( + (comparisonWheel.axis === "horizontal" && Math.abs(deltaY) >= 8 && Math.abs(deltaY) > Math.abs(deltaX) * 1.5) || + (comparisonWheel.axis === "vertical" && Math.abs(deltaX) >= 8 && Math.abs(deltaX) > Math.abs(deltaY) * 1.5) + ) + resetComparisonWheel() + + event.preventDefault() + comparisonWheel.deltaX += deltaX + comparisonWheel.deltaY += deltaY + if (comparisonWheel.reset !== undefined) window.clearTimeout(comparisonWheel.reset) + comparisonWheel.reset = window.setTimeout(resetComparisonWheel, 80) + + if ( + comparisonWheel.axis === undefined && + Math.max(Math.abs(comparisonWheel.deltaX), Math.abs(comparisonWheel.deltaY)) < 6 + ) + return + comparisonWheel.axis ??= + Math.abs(comparisonWheel.deltaX) > Math.abs(comparisonWheel.deltaY) * 1.5 ? "horizontal" : "vertical" + + if (comparisonWheel.axis === "vertical") window.scrollBy({ top: comparisonWheel.deltaY }) + if (comparisonWheel.axis === "horizontal") { + event.currentTarget.scrollLeft += comparisonWheel.deltaX + syncComparisonScroll( + event.currentTarget, + event.currentTarget === comparisonHeadingScroll ? comparisonBodyScroll : comparisonHeadingScroll, + ) + } + comparisonWheel.deltaX = 0 + comparisonWheel.deltaY = 0 + } + const handleComparisonPageWheel = (event: WheelEvent) => { + if (event.ctrlKey || event.shiftKey || event.deltaY === 0) return + if ( + event.target instanceof Element && + event.target.closest( + '[data-component="compare-detail-heading-scroll"], [data-component="compare-detail-body-scroll"], [data-component="compare-model-modal"]', + ) + ) + return + event.preventDefault() + const scale = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1 + window.scrollBy({ top: event.deltaY * scale }) + } const structuredData = createMemo(() => JSON.stringify({ "@context": "https://schema.org", @@ -155,12 +293,27 @@ export default function ModelComparePair() { const nextPreference = isThemePreference(preference) ? preference : "system" applyThemePreference(nextPreference) setThemePreference(nextPreference) + comparisonPage?.addEventListener("wheel", handleComparisonPageWheel, { passive: false }) + comparisonHeadingScroll?.addEventListener("wheel", handleComparisonWheel, { passive: false }) + comparisonBodyScroll?.addEventListener("wheel", handleComparisonWheel, { passive: false }) + onCleanup(() => { + comparisonPage?.removeEventListener("wheel", handleComparisonPageWheel) + comparisonHeadingScroll?.removeEventListener("wheel", handleComparisonWheel) + comparisonBodyScroll?.removeEventListener("wheel", handleComparisonWheel) + if (comparisonWheel.reset !== undefined) window.clearTimeout(comparisonWheel.reset) + }) }) return ( -
+
(comparisonPage = element)} + data-page="stats" + data-layout="compare-detail" + data-theme={themePreference()} + > {title()} + 2 ? "noindex,follow" : "index,follow"} /> @@ -174,123 +327,635 @@ export default function ModelComparePair() {
- -
-

- Comparison Table. Compare usage, cost, limits, and features. -

- - Loading comparison -

Loading stats for both models.

-
- } - > - - - -
-

- Compare Another Pair. Choose two models to compare. -

- 1} - fallback={ -
- No models found -

The model list could not be loaded.

-
- } - > - -
-
- setAddingModel(true)} + onHighlightBestChange={() => setHighlightBest(!highlightBest())} /> + + model.id)} + label="Add model" + models={selectorModels()} + onClose={() => setAddingModel(false)} + onSelect={(model) => { + setAddingModel(false) + navigateToModels([...selectedCatalogModels(), model]) + }} + /> + +
+
(comparisonHeadingScroll = element)} + onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonBodyScroll)} + > + +
+
(comparisonBodyScroll = element)} + onScroll={(event) => syncComparisonScroll(event.currentTarget, comparisonHeadingScroll)} + > + +
+ Loading comparison +

Loading model stats.

+
+ + } + > + +
+
+
+
) } -function ComparisonHero(props: { models: readonly [ComparisonModel, ComparisonModel] }) { +function ComparisonHero(props: { + models: readonly ComparisonModel[] + canAddModel: boolean + highlightBest: boolean + onAddModel: () => void + onHighlightBestChange: () => void +}) { return ( -
- - Compare - -
-

- {props.models[0].name} vs {props.models[1].name} +
+ +
+

model.name).join(", ")}`}> + Compare + + AI models

-

Compare usage, cost, limits, and features for these two models.

+
+ + +
-
) } -function ComparisonTable(props: { models: readonly [ComparisonModel, ComparisonModel]; rows: ComparisonRow[] }) { +function HeroModelStack() { return ( -
- - - - - - {(model) => } - - - - - {(row) => { - const best = () => bestCellIndex(row) - return ( - - - - {(cell, index) => ( - - )} - - - ) + + ) +} + +function ComparisonPairSelector(props: { catalogModels: ModelCatalogEntry[]; models: readonly ComparisonModel[] }) { + const [activeIndex, setActiveIndex] = createSignal() + const selectedModels = createMemo(() => props.models.map(comparisonCatalogEntry)) + const activeSelected = createMemo(() => { + const index = activeIndex() + if (index === undefined) return undefined + return selectedModels()[index] + }) + const blockedIds = createMemo(() => + selectedModels() + .filter((_, index) => index !== activeIndex()) + .map((model) => model.id), + ) + const navigateToSelection = (index: number, model: ModelCatalogEntry) => { + if (typeof window === "undefined") return + window.location.href = comparisonModelsHref( + selectedModels().map((selected, selectedIndex) => (selectedIndex === index ? model : selected)), + ) + } + + return ( +
+
+ + + {(selected) => ( + setActiveIndex(undefined)} + onSelect={(model) => { + const index = activeIndex() + if (index === undefined) return + setActiveIndex(undefined) + navigateToSelection(index, model) }} - -
-
- {props.models[0].name} compared with {props.models[1].name} -
Metric{model.name}
- {row.label} - {row.description} - - {cell.value} - {(detail) => {detail()}} -
+ /> + )} + +

+ ) +} + +function CompareDetailSelectButton(props: { + model: ComparisonModel + label: string + column: number + last: boolean + expanded: boolean + onOpen: () => void +}) { + return ( + + ) +} + +function CompareModelSelectModal(props: { + models: ModelCatalogEntry[] + selected?: ModelCatalogEntry + blockedIds: string[] + label: string + onClose: () => void + onSelect: (model: ModelCatalogEntry) => void +}) { + let searchInput: HTMLInputElement | undefined + const [search, setSearch] = createSignal("") + const [previewId, setPreviewId] = createSignal(props.selected?.id ?? "") + const availableModels = createMemo(() => + uniqueCatalogModels([...(props.selected ? [props.selected] : []), ...props.models]).filter( + (model) => !props.blockedIds.includes(model.id), + ), + ) + const filteredModels = createMemo(() => { + const terms = search().trim().toLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return availableModels() + return availableModels().filter((model) => terms.every((term) => modelSearchText(model).includes(term))) + }) + const preview = createMemo( + () => filteredModels().find((model) => model.id === previewId()) ?? filteredModels()[0] ?? availableModels()[0], + ) + + createEffect(() => { + const models = filteredModels() + if (models.some((model) => model.id === previewId())) return + const selected = + props.selected && models.some((model) => model.id === props.selected?.id) ? props.selected.id : undefined + setPreviewId(selected ?? models[0]?.id ?? "") + }) + + createEffect(() => { + if (typeof window === "undefined") return + window.requestAnimationFrame(() => searchInput?.focus()) + }) + + return ( +
{ + if (event.key !== "Escape") return + props.onClose() + }} + > +
event.stopPropagation()} + > +
+ +
+ 0} + fallback={ +
+ No models found + Try another search. +
+ } + > + + {(model) => ( + + )} + +
+
+
+
) } +function CompareModelDetail(props: { model: ModelCatalogEntry }) { + return ( + + ) +} + +function CompareModelFact(props: { label: string; value: string; href?: string }) { + return ( +
+
{props.label}
+
+ + {(href) => {props.value}} + +
+
+ ) +} + +function ComparisonDetailMatrix(props: { + sections: ComparisonDetailSection[] + highlightBest: boolean + modelCount: number +}) { + return ( +
+
+ + {(section) => ( +
+ +
+ {section.title} + {(badge) => {badge()}} +
+ + {(_, index) => ( +
+ )} +
+
+
+ ) +} + +function ComparisonDetailSpacer(props: { modelCount: number }) { + return ( + <> +