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/infra/lake.ts b/infra/lake.ts index dd11ca38f9..d4e46168e3 100644 --- a/infra/lake.ts +++ b/infra/lake.ts @@ -67,6 +67,10 @@ const athenaWorkgroup = new aws.athena.Workgroup("LakeAthenaWorkgroup", { configuration: { enforceWorkgroupConfiguration: true, publishCloudwatchMetricsEnabled: true, + // Athena bills $5/TB scanned; kill any query that would scan more than 2 TB + // so a regression cannot silently burn money. Stats sync full passes scan + // ~250 GB as of 2026-07. + bytesScannedCutoffPerQuery: 2 * 1024 ** 4, resultConfiguration: { outputLocation: $interpolate`s3://${athenaResultsBucket.bucket}/`, }, 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/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/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/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 324a789825..bbb71e8722 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" @@ -1333,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) => { @@ -1704,22 +1707,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/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 cba1e4c885..3517252e5c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -3,12 +3,12 @@ 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" 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) { @@ -296,9 +297,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"), @@ -372,13 +374,20 @@ 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, { + 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 }) + else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) + submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id })) + }) } } if (!session) { 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()) }, 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/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 7a8e44c5ab..19c90b9a38 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -26,10 +26,13 @@ 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" 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 @@ -48,12 +51,15 @@ 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() 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 @@ -61,6 +67,7 @@ export default function NewSessionPage() { sessionKey: route.sessionKey, sessionID: () => route.params.id, queryOptions: serverSync().queryOptions, + model, }) const projectControls = createPromptProjectControls() const projectController = createPromptProjectController({ @@ -68,6 +75,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() diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2abe232346..9a0518fb91 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, @@ -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(), @@ -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 }), @@ -1267,7 +1278,7 @@ export default function Page() { const reviewPanelV2Rendered = createMemo((prev) => prev || !store.deferRender, false) const reviewPanelV2 = () => ( -
+
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/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index da21311f29..b85e7ab13d 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -249,8 +249,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(), @@ -269,8 +271,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 d6fac1dbb5..168bdf2f69 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", "patch", "apply_patch"].includes(part.tool) + if (part?.type === "tool") return part } + const asyncFile = () => ["edit", "write", "patch", "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", }} >
{ }) } -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/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} 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 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/public/robots.txt b/packages/console/app/public/robots.txt index bddac69dea..12850523e5 100644 --- a/packages/console/app/public/robots.txt +++ b/packages/console/app/public/robots.txt @@ -3,4 +3,7 @@ Allow: / # Disallow shared content pages Disallow: /s/ -Disallow: /share/ \ No newline at end of file +Disallow: /share/ + +Sitemap: https://opencode.ai/sitemap.xml +Sitemap: https://opencode.ai/data/sitemap.xml 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, + }) + }) }) diff --git a/packages/opencode/src/cli/cmd/tui.ts b/packages/opencode/src/cli/cmd/tui.ts index 79e21ed6bf..6f9dd25f51 100644 --- a/packages/opencode/src/cli/cmd/tui.ts +++ b/packages/opencode/src/cli/cmd/tui.ts @@ -112,7 +112,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 6d35900640..4c5d69d67f 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") 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/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx new file mode 100644 index 0000000000..68cfcfee68 --- /dev/null +++ b/packages/stats/app/src/component/model-compare-detail.tsx @@ -0,0 +1,1150 @@ +import "../routes/index.css" +import { Link, Meta, Title } from "@solidjs/meta" +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, useSearchParams } from "@solidjs/router" +import { createEffect, createMemo, createSignal, For, onMount, Show } from "solid-js" +import { getRequestEvent } from "solid-js/web" +import { + ComparisonCardsSection, + comparisonHref, + modelRefFromCatalog, + uniqueComparisonPairs, + type ComparisonModelRef, + type ComparisonPair, +} from "../routes/compare-cards" +import { ComparisonRadar } from "../routes/compare-radar" +import { + catalogSlug, + findModelCatalogEntry, + formatCatalogLabName, + getModelCatalog, + type ModelCatalog, + type ModelCatalogEntry, +} from "../routes/model-catalog" +import { + applyThemePreference, + Footer, + getGitHubStars, + Header, + isThemePreference, + themeStorageKey, + type HeaderLink, + type ThemePreference, +} from "../routes/stats-shell" +import { + canonicalFamilyComparisonPath, + canonicalModelComparisonPath, + latestFamilyComparisonPath, + type ResolvedComparisonFamily, +} from "../lib/comparison-pages" +import { baseUrl } from "../lib/language" + +const compareHeaderLinks: readonly HeaderLink[] = [ + { 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" }, + { href: `${import.meta.env.BASE_URL}compare`, label: "Model Compare" }, + { 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 + lab: string + labName: string + slug: string + catalog: ModelCatalogEntry | null + stats: StatsModelComparisonEntry | null +} +type ComparisonDirection = "higher" | "lower" +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 ComparisonModelRequest = { + lab: string + slug: string +} + +export type ModelCompareDetailPageProps = { + first?: { lab: string; slug: string } + second?: { lab: string; slug: string } + family?: { first: ResolvedComparisonFamily; second: ResolvedComparisonFamily } + catalog?: ModelCatalog +} + +const getComparisonData = query(async (models: StatsModelComparisonInput[]) => { + "use server" + return runtime.runPromise(getStatsModelsComparisonData(models)) +}, "getStatsModelComparisonDetailData") + +export default function ModelCompareDetailPage(props: ModelCompareDetailPageProps = {}) { + 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(() => props.first?.lab ?? params.firstLab ?? "") + const firstModelParam = createMemo(() => props.first?.slug ?? params.firstModel ?? "") + const secondLabParam = createMemo(() => props.second?.lab ?? params.secondLab ?? "") + const secondModelParam = createMemo(() => props.second?.slug ?? params.secondModel ?? "") + const catalogData = createAsync(() => getModelCatalog()) + const catalog = createMemo(() => props.catalog ?? catalogData()) + const firstCatalog = createMemo(() => resolvedCatalogEntry(catalog(), firstLabParam(), firstModelParam())) + const secondCatalog = createMemo(() => resolvedCatalogEntry(catalog(), secondLabParam(), secondModelParam())) + const modelRequests = createMemo(() => { + const selected: ComparisonModelRequest[] = [ + { lab: firstLabParam(), slug: firstModelParam() }, + { lab: secondLabParam(), slug: secondModelParam() }, + ...parseAdditionalModels(searchParams.add), + ] + return selected + .filter( + (model, index) => + index < 2 || + selected.findIndex( + (candidate) => comparisonModelRequestKey(candidate) === comparisonModelRequestKey(model), + ) === index, + ) + .slice(0, comparisonModelLimit) + }) + const modelSelections = createMemo(() => + modelRequests().map((model) => ({ + ...model, + catalog: resolvedCatalogEntry(catalog(), model.lab, model.slug), + })), + ) + const stats = createAsync(() => + getComparisonData(modelRequests().map((model) => ({ provider: model.lab, model: model.slug }))), + ) + const githubStars = createAsync(() => getGitHubStars()) + const [themePreference, setThemePreference] = createSignal("system") + const [highlightBest, setHighlightBest] = createSignal(true) + const [addingModel, setAddingModel] = createSignal(false) + let comparisonHeadingScroll: HTMLDivElement | undefined + let comparisonBodyScroll: HTMLDivElement | undefined + const models = createMemo(() => + modelSelections().map((model, index) => + buildComparisonModel(model.lab, model.slug, model.catalog ?? null, stats()?.models[index] ?? null), + ), + ) + const title = createMemo(() => { + if (props.family) + return `${props.family.first.name} vs ${props.family.second.name}: ${models()[0].name} vs ${models()[1].name}` + return `${models()[0].name} vs ${models()[1].name} - Model Comparison` + }) + const description = createMemo(() => { + if (props.family) + return `Compare the latest ${props.family.first.name} and ${props.family.second.name} models: ${models()[0].name} vs ${models()[1].name}. See benchmarks, usage, price, context length, and features.` + return `Compare ${models()[0].name} and ${models()[1].name} by usage, rank, context window, output limit, cache ratio, and cost across OpenCode data.` + }) + const canonicalPath = createMemo(() => { + if (props.family) return canonicalFamilyComparisonPath(props.family.first, props.family.second) + const first = firstCatalog() + const second = secondCatalog() + const source = catalog() + if (source && first && second) + return latestFamilyComparisonPath(source, first, second) ?? canonicalModelComparisonPath(first, second) + return canonicalModelComparisonPath(comparisonCatalogEntry(models()[0]), comparisonCatalogEntry(models()[1])) + }) + const canonicalUrl = createMemo(() => new URL(canonicalPath(), baseUrl).toString()) + const detailSections = createMemo(() => buildComparisonDetailSections(models())) + const relatedPairs = createMemo(() => buildRelatedPairs(catalog(), models()[0], models()[1])) + const selectorModels = createMemo(() => + 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 structuredData = createMemo(() => + JSON.stringify({ + "@context": "https://schema.org", + "@type": "WebPage", + name: title(), + description: description(), + url: canonicalUrl(), + about: models().map((model) => ({ + "@type": "SoftwareApplication", + name: model.name, + applicationCategory: "AI model", + provider: model.labName, + })), + }), + ) + const updateThemePreference = (preference: ThemePreference) => { + applyThemePreference(preference) + setThemePreference(preference) + if (typeof window === "undefined") return + window.localStorage.setItem(themeStorageKey, preference) + } + + onMount(() => { + if (typeof window === "undefined") return + const preference = window.localStorage.getItem(themeStorageKey) + const nextPreference = isThemePreference(preference) ? preference : "system" + applyThemePreference(nextPreference) + setThemePreference(nextPreference) + }) + + return ( +
+ + {title()} + + 2 ? "noindex,follow" : "index,follow"} /> + + + + + + + + + + + +
+
+
+ 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)} + > + +
+
+ +
+
+
+
+ ) +} + +function ComparisonHero(props: { + models: readonly ComparisonModel[] + canAddModel: boolean + highlightBest: boolean + onAddModel: () => void + onHighlightBestChange: () => void +}) { + return ( +
+ +
+

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

+
+ + +
+
+
+ ) +} + +function HeroModelStack() { + return ( + + ) +} + +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) + }} + /> + )} + +
+ ) +} + +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 ( + <> +