Compare commits

..

1 Commits

Author SHA1 Message Date
Brendan Allan 67225ae656 fix(app): preserve model selection across draft remounts 2026-07-09 07:05:28 +00:00
152 changed files with 1685 additions and 5844 deletions
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+28 -44
View File
@@ -29,9 +29,8 @@
},
"packages/app": {
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
@@ -95,7 +94,7 @@
},
"packages/cli": {
"name": "@opencode-ai/cli",
"version": "1.17.18",
"version": "1.17.16",
"bin": {
"lildax": "./bin/lildax.cjs",
},
@@ -143,7 +142,7 @@
},
"packages/codemode": {
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"acorn": "8.15.0",
"effect": "catalog:",
@@ -157,7 +156,7 @@
},
"packages/console/app": {
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@ibm/plex": "6.4.1",
@@ -193,7 +192,7 @@
},
"packages/console/core": {
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@aws-sdk/client-sts": "3.782.0",
"@jsx-email/render": "1.1.1",
@@ -220,7 +219,7 @@
},
"packages/console/function": {
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@ai-sdk/anthropic": "3.0.82",
"@ai-sdk/openai": "3.0.48",
@@ -242,7 +241,7 @@
},
"packages/console/mail": {
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
@@ -266,7 +265,7 @@
},
"packages/console/support": {
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@cloudflare/vite-plugin": "1.15.2",
"@opencode-ai/console-core": "workspace:*",
@@ -286,7 +285,7 @@
},
"packages/core": {
"name": "@opencode-ai/core",
"version": "1.17.18",
"version": "1.17.16",
"bin": {
"opencode": "./bin/opencode",
},
@@ -380,7 +379,7 @@
},
"packages/desktop": {
"name": "@opencode-ai/desktop",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@zip.js/zip.js": "2.7.62",
"effect": "catalog:",
@@ -434,7 +433,7 @@
},
"packages/effect-drizzle-sqlite": {
"name": "@opencode-ai/effect-drizzle-sqlite",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"drizzle-orm": "catalog:",
"effect": "catalog:",
@@ -448,7 +447,7 @@
},
"packages/effect-sqlite-node": {
"name": "@opencode-ai/effect-sqlite-node",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"effect": "catalog:",
},
@@ -460,7 +459,7 @@
},
"packages/enterprise": {
"name": "@opencode-ai/enterprise",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@hono/standard-validator": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -492,7 +491,7 @@
},
"packages/function": {
"name": "@opencode-ai/function",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@octokit/auth-app": "8.0.1",
"@octokit/rest": "catalog:",
@@ -508,7 +507,7 @@
},
"packages/http-recorder": {
"name": "@opencode-ai/http-recorder",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@effect/platform-node": "4.0.0-beta.83",
"@effect/platform-node-shared": "4.0.0-beta.83",
@@ -539,7 +538,7 @@
},
"packages/llm": {
"name": "@opencode-ai/llm",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@opencode-ai/schema": "workspace:*",
"@smithy/eventstream-codec": "4.2.14",
@@ -558,7 +557,7 @@
},
"packages/opencode": {
"name": "opencode",
"version": "1.17.18",
"version": "1.17.16",
"bin": {
"opencode": "./bin/opencode",
},
@@ -689,7 +688,7 @@
},
"packages/plugin": {
"name": "@opencode-ai/plugin",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@ai-sdk/provider": "3.0.8",
"@opencode-ai/sdk": "workspace:*",
@@ -765,7 +764,7 @@
},
"packages/sdk/js": {
"name": "@opencode-ai/sdk",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"cross-spawn": "catalog:",
},
@@ -780,7 +779,7 @@
},
"packages/server": {
"name": "@opencode-ai/server",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/protocol": "workspace:*",
@@ -795,7 +794,7 @@
},
"packages/session-ui": {
"name": "@opencode-ai/session-ui",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@opencode-ai/core": "workspace:*",
@@ -839,7 +838,7 @@
},
"packages/slack": {
"name": "@opencode-ai/slack",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@opencode-ai/sdk": "workspace:*",
"@slack/bolt": "^3.17.1",
@@ -852,7 +851,7 @@
},
"packages/stats/app": {
"name": "@opencode-ai/stats-app",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@ibm/plex": "6.4.1",
"@opencode-ai/stats-core": "workspace:*",
@@ -885,7 +884,7 @@
},
"packages/stats/core": {
"name": "@opencode-ai/stats-core",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@aws-sdk/client-athena": "3.933.0",
"@planetscale/database": "1.19.0",
@@ -904,7 +903,7 @@
},
"packages/stats/server": {
"name": "@opencode-ai/stats-server",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@aws-sdk/client-firehose": "3.933.0",
"@effect/platform-node": "catalog:",
@@ -945,7 +944,7 @@
},
"packages/tui": {
"name": "@opencode-ai/tui",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@opencode-ai/core": "workspace:*",
"@opencode-ai/plugin": "workspace:*",
@@ -972,7 +971,7 @@
},
"packages/ui": {
"name": "@opencode-ai/ui",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@kobalte/core": "catalog:",
"@pierre/diffs": "catalog:",
@@ -1023,7 +1022,7 @@
},
"packages/web": {
"name": "@opencode-ai/web",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@astrojs/cloudflare": "12.6.3",
"@astrojs/markdown-remark": "6.3.1",
@@ -1088,7 +1087,6 @@
},
"catalog": {
"@cloudflare/workers-types": "4.20251008.0",
"@corvu/drawer": "0.2.4",
"@effect/opentelemetry": "4.0.0-beta.83",
"@effect/platform-node": "4.0.0-beta.83",
"@effect/sql-sqlite-bun": "4.0.0-beta.83",
@@ -1470,10 +1468,6 @@
"@cloudflare/workers-types": ["@cloudflare/workers-types@4.20251008.0", "", {}, "sha512-dZLkO4PbCL0qcCSKzuW7KE4GYe49lI12LCfQ5y9XeSwgYBoAUbwH4gmJ6A0qUIURiTJTkGkRkhVPqpq2XNgYRA=="],
"@corvu/dialog": ["@corvu/dialog@0.2.4", "", { "dependencies": { "@corvu/utils": "~0.4.2", "solid-dismissible": "~0.1.1", "solid-focus-trap": "~0.1.8", "solid-presence": "~0.2.0", "solid-prevent-scroll": "~0.1.10" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-n54vJq+fOy8GVrnYBdJpD6JXNuyx7LOeMrRxwzAvZnYGpW8+AA12tnb/P/2emJj/HjOO5otheGKb0breshdFlA=="],
"@corvu/drawer": ["@corvu/drawer@0.2.4", "", { "dependencies": { "@corvu/dialog": "~0.2.4", "@corvu/utils": "~0.4.2", "@solid-primitives/memo": "^1.4.1", "solid-transition-size": "~0.1.4" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-7jQoGZ8ROB9CmXam2nMY2wEskU3IoFwZQywkF/7vrBc/edGsPv7mOVQ1GN6G+4nd7nrMZ3UtqHAhmRh9V0azlw=="],
"@corvu/utils": ["@corvu/utils@0.4.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.11" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-Ox2kYyxy7NoXdKWdHeDEjZxClwzO4SKM8plAaVwmAJPxHMqA0rLOoAsa+hBDwRLpctf+ZRnAd/ykguuJidnaTA=="],
"@cspotcode/source-map-support": ["@cspotcode/source-map-support@0.8.1", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.9" } }, "sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw=="],
@@ -2674,8 +2668,6 @@
"@solid-primitives/media": ["@solid-primitives/media@2.3.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-hQ4hLOGvfbugQi5Eu1BFWAIJGIAzztq9x0h02xgBGl2l0Jaa3h7tg6bz5tV1NSuNYVGio4rPoa7zVQQLkkx9dA=="],
"@solid-primitives/memo": ["@solid-primitives/memo@1.5.0", "", { "dependencies": { "@solid-primitives/scheduled": "^1.5.3", "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-nSMHpFdnOMP88t7lqtktUXJlhQdJk0BQs2v3jUqcn+OtFwUm27Oa/coKl1BHDxL/65jR5drjqxxvuhaIpiUi0w=="],
"@solid-primitives/props": ["@solid-primitives/props@3.2.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-XzG6en9gSFwmvbKcATm2BxL63HegZ+BAG5fmHi8jyBppQHcaths7ffz+6vYvwYy3nlgLa20ufJLj7tst+PcHFA=="],
"@solid-primitives/refs": ["@solid-primitives/refs@1.1.3", "", { "dependencies": { "@solid-primitives/utils": "^6.4.0" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-aam02fjNKpBteewF/UliPSQCVJsIIGOLEWQOh+ll6R/QePzBOOBMcC4G+5jTaO75JuUS1d/14Q1YXT3X0Ow6iA=="],
@@ -5108,10 +5100,6 @@
"socks-proxy-agent": ["socks-proxy-agent@8.0.5", "", { "dependencies": { "agent-base": "^7.1.2", "debug": "^4.3.4", "socks": "^2.8.3" } }, "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw=="],
"solid-dismissible": ["solid-dismissible@0.1.1", "", { "dependencies": { "@corvu/utils": "~0.4.1" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-9kcKBJIMdS+586cA1g63HYWxKh3h89leeNHbPZ1csYjuni+NvPBtNr11l0iEX2AKKEt6FHk6qNhc/gjoYAW1pA=="],
"solid-focus-trap": ["solid-focus-trap@0.1.9", "", { "dependencies": { "@corvu/utils": "~0.4.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-LTyNki6GUJPRLXV5uMWPkYClB07SUMubbr2EkAddiR0CJCF/I283txilMU9RURSr/P8EewMfXWu2o3aWrK7A5A=="],
"solid-js": ["solid-js@1.9.10", "", { "dependencies": { "csstype": "^3.1.0", "seroval": "~1.3.0", "seroval-plugins": "~1.3.0" } }, "sha512-Coz956cos/EPDlhs6+jsdTxKuJDPT7B5SVIWgABwROyxjY7Xbr8wkzD68Et+NxnV7DLJ3nJdAC2r9InuV/4Jew=="],
"solid-list": ["solid-list@0.3.0", "", { "dependencies": { "@corvu/utils": "~0.4.0" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-t4hx/F/l8Vmq+ib9HtZYl7Z9F1eKxq3eKJTXlvcm7P7yI4Z8O7QSOOEVHb/K6DD7M0RxzVRobK/BS5aSfLRwKg=="],
@@ -5124,8 +5112,6 @@
"solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="],
"solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="],
"solid-use": ["solid-use@0.9.1", "", { "peerDependencies": { "solid-js": "^1.7" } }, "sha512-UwvXDVPlrrbj/9ewG9ys5uL2IO4jSiwys2KPzK4zsnAcmEl7iDafZWW1Mo4BSEWOmQCGK6IvpmGHo1aou8iOFw=="],
"sonic-boom": ["sonic-boom@4.2.1", "", { "dependencies": { "atomic-sleep": "^1.0.0" } }, "sha512-w6AxtubXa2wTXAUsZMMWERrsIRAdrK0Sc+FUytWvYAhBJLyuI4llrMIC1DtlNSdI99EI86KZum2MMq3EAZlF9Q=="],
@@ -6464,8 +6450,6 @@
"slice-ansi/is-fullwidth-code-point": ["is-fullwidth-code-point@4.0.0", "", {}, "sha512-O4L094N2/dZ7xqVdrXhh9r1KODPJpFms8B5sGdJLPy664AgvXsreZUyCQQNItZRDlYug4xStLjNp/sz3HvBowQ=="],
"solid-transition-size/@corvu/utils": ["@corvu/utils@0.3.2", "", { "dependencies": { "@floating-ui/dom": "^1.6.7" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ZWlyWEE8qV9+CB9OAyo2bTrZGXQN9ZeM+JfYv89zoR+lRACKTDuoOZEdiyL8Uc7U5dUSH1uTqKhTTnaHWb+wZA=="],
"sort-keys/is-plain-obj": ["is-plain-obj@1.1.0", "", {}, "sha512-yvkRyxmFKEOQ4pNXCmJG5AEQNlXJS5LaONXo5/cLdTZdWvsZ1ioJEonLGAosKlMWE8lwUy/bJzMjcw8az73+Fg=="],
"sst/aws4fetch": ["aws4fetch@1.0.18", "", {}, "sha512-3Cf+YaUl07p24MoQ46rFwulAmiyCwH2+1zw1ZyPAX5OtJ34Hh185DwB8y/qRLb6cYYYtSFJ9pthyLc0MD4e8sQ=="],
-4
View File
@@ -67,10 +67,6 @@ 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}/`,
},
+1 -3
View File
@@ -185,9 +185,7 @@ export const statSync = new sst.aws.Service("StatsSyncService", {
cluster: lakeCluster,
architecture: "arm64",
cpu: "0.25 vCPU",
// 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",
memory: "0.5 GB",
image: {
context: ".",
dockerfile: "packages/stats/server/Dockerfile",
+4 -4
View File
@@ -1,8 +1,8 @@
{
"nodeModules": {
"x86_64-linux": "sha256-JTtn+wXTXg+yklvIMDLcGFaYhTU6ZrCgKT9JTNEQ3gA=",
"aarch64-linux": "sha256-gXU6zyhvAZrZirkL/PlHdkHtEof/7PVSPCaE34Jnd4U=",
"aarch64-darwin": "sha256-Q0oTG3uzOlD/X2kJingLle529lKFoTpyCW2rHXOZ6iE=",
"x86_64-darwin": "sha256-LINvKHxPibTlJeNzfACQx0x+Yj5oROT6Du3I5AtqqXk="
"x86_64-linux": "sha256-DDHxztBr46Jxrb0uYvLYSezrqMgY+UrozRHvKdEyeXY=",
"aarch64-linux": "sha256-Dp2ohpv1jXcFX2ri4fVmsnC9WEx60bviUSlcOtIJBmE=",
"aarch64-darwin": "sha256-CXFJmkcRAS+liHf9TG5qoUG08VvKe5RusEGZTlnNer4=",
"x86_64-darwin": "sha256-7iuCfAzggMDyJ7tX8UuS7T7DCbr9h+lskE+HVFUaD0Q="
}
}
-1
View File
@@ -47,7 +47,6 @@
"@shikijs/stream": "4.2.0",
"ulid": "3.0.1",
"@kobalte/core": "0.13.11",
"@corvu/drawer": "0.2.4",
"@types/luxon": "3.7.1",
"@types/node": "24.12.2",
"@types/semver": "7.7.1",
@@ -74,10 +74,6 @@ 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 }]),
@@ -90,16 +86,13 @@ 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()
@@ -109,11 +102,9 @@ 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()
@@ -1,228 +0,0 @@
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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>("[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,
},
)
}
+1 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/app",
"version": "1.17.18",
"version": "1.17.16",
"description": "",
"type": "module",
"exports": {
@@ -47,7 +47,6 @@
"vite-plugin-solid": "catalog:"
},
"dependencies": {
"@corvu/drawer": "catalog:",
"@dnd-kit/abstract": "0.5.0",
"@dnd-kit/dom": "0.5.0",
"@dnd-kit/helpers": "0.5.0",
Binary file not shown.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 23 KiB

@@ -1,173 +0,0 @@
import { DialogBody, DialogHeader, DialogTitle, DialogV2 } from "@opencode-ai/ui/v2/dialog-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { ScrollView } from "@opencode-ai/ui/scroll-view"
import { Tag } from "@opencode-ai/ui/v2/badge-v2"
import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { createMemo, onCleanup, onMount, type Component, For, Show } from "solid-js"
import { useLocal } from "@/context/local"
import { popularProviders, useProviders } from "@/hooks/use-providers"
import { decode64 } from "@/utils/base64"
import { useLanguage } from "@/context/language"
import { ModelTooltip } from "./model-tooltip"
type ModelState = ReturnType<typeof useLocal>["model"]
export const DialogSelectModelUnpaidV2: Component<{ model?: ModelState }> = (props) => {
const local = useLocal()
const model = props.model ?? local.model
const dialog = useDialog()
const directory = () => decode64(local.slug())
const providers = useProviders(directory)
const language = useLanguage()
const modelKey = (item: ReturnType<ModelState["list"]>[number]) => `${item.provider.id}:${item.id}`
const currentKey = createMemo(() => {
const c = model.current()
return c ? `${c.provider.id}:${c.id}` : undefined
})
const isFree = (item: ReturnType<ModelState["list"]>[number]) =>
item.provider.id === "opencode" && (!item.cost || item.cost.input === 0)
const openProviders = (provider?: string) => {
void import("./dialog-connect-provider").then((x) => {
const controller = x.useProviderConnectController()
controller.select(provider)
void dialog.show(() => <x.DialogConnectProvider controller={controller} directory={directory} />)
})
}
const selectModel = (item: ReturnType<ModelState["list"]>[number]) => {
model.set({ modelID: item.id, providerID: item.provider.id }, { recent: true })
dialog.close()
}
// Focus starts on the dialog's close button, outside the list, so listen at the
// document level while the dialog is mounted instead of on the list container.
let listEl: HTMLDivElement | undefined
onMount(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key !== "ArrowDown" && e.key !== "ArrowUp") return
if (!listEl) return
const buttons = Array.from(listEl.querySelectorAll<HTMLButtonElement>("button"))
if (buttons.length === 0) return
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
const next =
index < 0 ? (e.key === "ArrowDown" ? 0 : buttons.length - 1) : index + (e.key === "ArrowDown" ? 1 : -1)
buttons[(next + buttons.length) % buttons.length]?.focus()
e.preventDefault()
}
document.addEventListener("keydown", handleKeyDown)
onCleanup(() => document.removeEventListener("keydown", handleKeyDown))
})
return (
<DialogV2 containerClass="!h-[min(calc(100vh_-_16px),480px)] !w-[min(calc(100vw_-_16px),560px)]">
<DialogHeader closeLabel={language.t("common.close")}>
<DialogTitle>{language.t("dialog.model.select.title")}</DialogTitle>
</DialogHeader>
<div class="h-px w-full shrink-0 bg-v2-border-border-muted" />
<DialogBody class="min-h-0 flex-1 gap-0">
<ScrollView class="min-h-0 flex-1 w-full">
<div ref={listEl} class="flex min-h-full flex-col">
<div class="flex h-fit w-full flex-col items-start gap-0.5 px-3.5 pb-3.5 pt-3">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 pb-2 pt-1">
<div class="flex h-5 flex-none flex-row items-center p-0 font-[440] text-[13px] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.freeModels.title")}
</div>
</div>
<For each={model.list()}>
{(item) => (
<TooltipV2
class="w-full"
placement="right-start"
gutter={6}
openDelay={0}
value={<ModelTooltip model={item} latest={item.latest} free={isFree(item)} v2 />}
>
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-md px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => selectModel(item)}
>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item)}>
<Tag class="shrink-0">{language.t("model.tag.free")}</Tag>
</Show>
<Show when={item.latest}>
<Tag class="shrink-0">{language.t("model.tag.latest")}</Tag>
</Show>
<Show when={currentKey() === modelKey(item)}>
<Icon name="check" class="ml-auto size-4 shrink-0 text-v2-icon-icon-base" />
</Show>
</button>
</TooltipV2>
)}
</For>
</div>
<div class="flex w-full flex-col p-2.5 pt-0">
<div class="flex h-fit w-full flex-none grow-0 flex-col items-start gap-0.5 self-stretch rounded-lg bg-v2-background-bg-layer-02 p-1 shadow-[var(--v2-elevation-switch-off)]">
<div class="flex h-8 w-full flex-none select-none flex-row items-center gap-2 self-stretch px-2.5 py-1.5">
<div class="flex h-5 flex-none flex-row items-center p-0 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint [font-family:Inter,var(--font-family-sans)] [font-variant-numeric:tabular-nums] [font-variation-settings:'slnt'_0]">
{language.t("dialog.model.unpaid.addMore.title")}
</div>
</div>
<div class="flex w-full flex-col">
<For
each={[...providers.popular()].sort((a, b) => {
if (popularProviders.includes(a.id) && popularProviders.includes(b.id)) {
return popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)
}
return a.name.localeCompare(b.name)
})}
>
{(provider) => (
<button
type="button"
class="flex w-full scroll-my-3.5 flex-row items-center gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders(provider.id)}
>
<ProviderIcon id={provider.id} class="size-4 shrink-0 text-v2-icon-icon-muted" />
<span class="min-w-0 truncate">{provider.name}</span>
<Show when={provider.id === "opencode"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencode.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "opencode-go"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.opencodeGo.tagline")}
</span>
<Tag class="shrink-0">{language.t("dialog.provider.tag.recommended")}</Tag>
</Show>
<Show when={provider.id === "anthropic"}>
<span class="min-w-0 truncate text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-muted [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.anthropic.note")}
</span>
</Show>
</button>
)}
</For>
<button
type="button"
class="flex h-9 w-full scroll-my-3.5 flex-row items-center justify-start gap-2 rounded-[6px] px-2.5 py-2 text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0] hover:bg-v2-overlay-simple-overlay-hover focus:bg-v2-overlay-simple-overlay-hover focus:outline-none"
onClick={() => openProviders()}
>
<span class="flex size-4 shrink-0 items-center justify-center text-v2-icon-icon-muted">
<Icon name="dot-grid" size="small" />
</span>
<span class="min-w-0 truncate text-left text-[13px] font-[530] leading-5 tracking-[-0.04px] text-v2-text-text-base [font-family:Inter,var(--font-family-sans)] [font-variation-settings:'slnt'_0]">
{language.t("dialog.provider.viewAll")}
</span>
</button>
</div>
</div>
</div>
</div>
</ScrollView>
</DialogBody>
</DialogV2>
)
}
@@ -474,7 +474,7 @@ export function ModelSelectorPopoverV2(props: {
}}
onSelect={() => selectModel(item)}
>
<span class="min-w-0 truncate leading-5">{item.name}</span>
<span class="min-w-0 truncate">{item.name}</span>
<Show when={isFree(item.provider.id, item.cost)}>
<TagV2 class="shrink-0">{language.t("model.tag.free")}</TagV2>
</Show>
+41 -131
View File
@@ -1,144 +1,54 @@
import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon"
import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2"
import { Icon } from "@opencode-ai/ui/v2/icon"
import { Popover } from "@opencode-ai/ui/popover"
import { createSignal, Show } from "solid-js"
import { createStore } from "solid-js/store"
import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer"
import { usePlatform } from "@/context/platform"
import introducingTabsVideo from "@/assets/help/introducing-tabs.mp4"
import { Persist, persisted } from "@/utils/persist"
const helpIcon = (
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
data-slot="icon-svg"
>
<path
d="M6.94235 10.5714V10.4854C6.94617 9.76302 7.01879 9.18777 7.16022 8.75968C7.30546 8.33158 7.50804 7.98567 7.76796 7.72193C8.02787 7.45819 8.34321 7.21548 8.71397 6.99379C8.93948 6.85619 9.14206 6.69374 9.32171 6.50645C9.50518 6.31916 9.64851 6.10511 9.75171 5.86431C9.85874 5.62351 9.91225 5.35404 9.91225 5.0559C9.91225 4.69661 9.82625 4.38509 9.65424 4.12136C9.48607 3.85762 9.26055 3.65504 8.9777 3.51362C8.69486 3.36837 8.38143 3.29575 8.03743 3.29575C7.73165 3.29575 7.43733 3.35882 7.15448 3.48495C6.87546 3.61108 6.6423 3.80984 6.45501 4.08122C6.26772 4.3526 6.15878 4.70425 6.12821 5.13617H4.56299C4.59357 4.47109 4.76557 3.9054 5.07899 3.43908C5.39242 2.96894 5.80522 2.61156 6.31741 2.36694C6.83341 2.12231 7.40675 2 8.03743 2C8.72161 2 9.31789 2.13378 9.82625 2.40134C10.3384 2.66507 10.734 3.0301 11.0131 3.49642C11.2959 3.96273 11.4373 4.49976 11.4373 5.1075C11.4373 5.53177 11.3724 5.914 11.2424 6.25418C11.1124 6.59436 10.9251 6.89823 10.6805 7.16579C10.4397 7.43335 10.1492 7.67033 9.80905 7.87673C9.48033 8.08313 9.21468 8.301 9.0121 8.53034C8.80952 8.75585 8.66237 9.02341 8.57063 9.33302C8.4789 9.64262 8.42921 10.0268 8.42156 10.4854V10.5714H6.94235ZM7.72782 14C7.43351 14 7.17933 13.8949 6.96528 13.6847C6.75506 13.4744 6.64994 13.2203 6.64994 12.9221C6.64994 12.6278 6.75506 12.3755 6.96528 12.1653C7.17933 11.9551 7.43351 11.85 7.72782 11.85C8.02214 11.85 8.27441 11.9551 8.48463 12.1653C8.69868 12.3755 8.8057 12.6278 8.8057 12.9221C8.8057 13.1209 8.75601 13.3024 8.65663 13.4668C8.55726 13.6273 8.4273 13.7573 8.26676 13.8567C8.10623 13.9522 7.92658 14 7.72782 14Z"
fill="var(--v2-icon-icon-base)"
/>
</svg>
)
const triggerClass =
"size-7 !rounded-full shrink-0 bg-v2-background-bg-base shadow-[var(--v2-elevation-button-neutral)]"
// TODO: wire to changelog / seen-state when available
const showPopover = () => true
export function HelpButton() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const platform = usePlatform()
const [state, setState] = /* persisted(Persist.global("help-button"), */ createStore({ dismissed: false }) /* ) */
const [shown, setShown] = createSignal(false)
return (
<a
href="https://opencode.ai"
aria-label="Open the OpenCode website"
data-component="icon-button-v2"
data-size="large"
class={`${triggerClass} fixed bottom-5 right-5 z-50 flex items-center justify-center`}
onClick={(event) => {
event.preventDefault()
platform.openLink(event.currentTarget.href)
}}
>
{helpIcon}
</a>
)
}
// can remove this after the tabs rollout has been out for a while
export function TabsInfoPopup() {
if (import.meta.env.VITE_OPENCODE_CHANNEL !== "dev") return null
const [state, setState] = persisted(Persist.global("tabsInfoPopup"), createStore({ dismissed: false }))
// setState({ dismissed: false }) // for testing
const [drawerOpen, setDrawerOpen] = createSignal(false)
return (
<Drawer open={drawerOpen()} onOpenChange={setDrawerOpen} side="right">
<Show when={!state.dismissed}>
<div
class="fixed bottom-14 right-5 z-50 h-[240px] w-[192px] rounded-[8px] bg-v2-background-bg-base p-1 shadow-[var(--v2-elevation-floating)]"
aria-label="Introducing Tabs. A faster, more intuitive way to work."
<Show when={!state.dismissed}>
<div class="fixed bottom-4 right-4 z-50 hidden md:block">
<Popover
open={shown()}
onOpenChange={setShown}
triggerAs="button"
triggerProps={{
type: "button",
"aria-label": "Help",
class:
"size-7 rounded-full bg-background-base shadow-[var(--shadow-lg-border-base)] flex items-center justify-center text-text-base hover:text-text-strong transition-colors",
}}
trigger={<span aria-hidden="true">?</span>}
class="[&_[data-slot=popover-body]]:p-0 w-[320px] max-w-[calc(100vw-40px)] bg-transparent border-0 shadow-none rounded-xl"
gutter={8}
placement="top-end"
>
<button
type="button"
aria-label="Dismiss Tabs information"
class="absolute top-3 right-3 z-10 size-5 flex items-center justify-center rounded-[4px] bg-[rgba(0,0,0,0.4)]"
onClick={() => setState("dismissed", true)}
>
<svg
width="16"
height="16"
viewBox="0 0 16 16"
fill="none"
xmlns="http://www.w3.org/2000/svg"
aria-hidden="true"
>
<path d="M4.25 11.75L11.75 4.25M11.75 11.75L4.25 4.25" stroke="white" />
</svg>
</button>
<button
type="button"
class="relative block h-[232px] w-[184px] cursor-pointer overflow-hidden rounded-[4px] text-left"
onClick={() => {
setState("dismissed", true)
setDrawerOpen(true)
}}
>
<video
src={introducingTabsVideo}
class="absolute inset-0 h-full w-full object-cover"
loop
muted
autoplay
playsinline
aria-hidden="true"
onContextMenu={(event) => event.preventDefault()}
/>
<div class="absolute inset-x-0 bottom-0 flex w-full flex-col items-start gap-1.5 bg-[linear-gradient(180deg,rgba(0,0,0,0)_0%,#000000_100%)] px-3 py-5">
<p class="w-full select-none text-[13px] font-[530] leading-none tracking-[-0.04px] text-[#FFFFFF]">
Introducing Tabs
</p>
<p class="w-full select-none text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-[#808080]">
A faster, more intuitive way to work.
<Show when={shown()}>
<div class="relative flex flex-col gap-1 w-[320px] p-4 rounded-xl bg-background-strong shadow-[var(--shadow-lg-border-base)]">
<button
type="button"
aria-label="Close"
class="absolute top-3.5 right-3.5 size-6 rounded-md flex items-center justify-center text-text-base hover:text-text-strong hover:bg-surface-raised-base-hover transition-colors"
onClick={() => {
setShown(false)
setState("dismissed", true)
}}
>
<Icon name="xmark-small" />
</button>
<span class="text-14-regular text-text-strong">Lorem ipsum dolor sit amet</span>
<p class="text-12-regular text-text-weak">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et
dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation.
</p>
</div>
</button>
</div>
</Show>
<DrawerContent>
<div class="flex h-[52px] w-full shrink-0 items-center gap-4 self-stretch border-b border-v2-border-border-muted p-4">
<p class="min-h-0 min-w-0 flex-1 text-[13px] font-[530] leading-5 tracking-[-0.04px] tabular-nums text-v2-text-text-muted">
June 16
</p>
<DrawerClose
as={IconButtonV2}
type="button"
size="small"
variant="ghost-muted"
aria-label="Close"
icon={<IconV2 name="xmark-small" />}
/>
</div>
<div class="relative flex w-full flex-col items-start gap-6 p-8">
<p class="w-full shrink-0 self-stretch text-[21px] font-[610] leading-6 tracking-[-0.37px] tabular-nums text-v2-text-text-base">
Introducing Tabs Navigation.
</p>
<p class="w-full flex-1 text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-base">
We've introduced tabs as the primary navigation in OpenCode. Your most important session are now pinned at
the top of your screen at all times. No more hunting through menus or losing your place mid-session. Switch
contexts instantly, pick up exactly where you left off, and keep your focus where it belongs: on the
sessions.
</p>
</div>
</DrawerContent>
</Drawer>
</Show>
</Popover>
</div>
</Show>
)
}
+40 -59
View File
@@ -36,8 +36,6 @@ 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"
@@ -45,8 +43,6 @@ import { IconButton } from "@opencode-ai/ui/icon-button"
import { Select } from "@opencode-ai/ui/select"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { ModelSelectorPopover, ModelSelectorPopoverV2 } from "@/components/dialog-select-model"
import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid"
import { DialogSelectModelUnpaidV2 } from "@/components/dialog-select-model-unpaid-v2"
import { useCommand } from "@/context/command"
import { Persist, persisted } from "@/utils/persist"
import { usePermission } from "@/context/permission"
@@ -1335,7 +1331,6 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
onQueue: props.onQueue,
onAbort: props.onAbort,
onSubmit: props.onSubmit,
model: props.controls.model.selection,
})
const handleKeyDown = (event: KeyboardEvent) => {
@@ -1554,11 +1549,9 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
style: control(),
onClose: restoreFocus,
onUnpaidClick: () => {
if (props.controls.newLayoutDesigns) {
dialog.show(() => <DialogSelectModelUnpaidV2 model={props.controls.model.selection} />)
return
}
dialog.show(() => <DialogSelectModelUnpaid model={props.controls.model.selection} />)
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => <x.DialogSelectModelUnpaid model={props.controls.model.selection} />)
})
},
}))
@@ -1707,19 +1700,22 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
>
<MenuV2 gutter={6} modal={false} placement="top-start">
<MenuV2.Trigger
as={IconButtonV2}
as={IconButton}
data-action="prompt-attach"
type="button"
icon={<IconV2 name="plus" />}
variant="ghost-muted"
size="large"
icon="plus"
variant="ghost"
class="size-7 rounded-md p-[6px] text-v2-icon-icon-muted"
style={buttons()}
disabled={store.mode !== "normal"}
tabIndex={store.mode === "normal" ? undefined : -1}
aria-label={language.t("prompt.menu.addImagesAndFiles")}
/>
<MenuV2.Portal>
<MenuV2.Content style={{ "min-width": "180px" }}>
<MenuV2.Content
class="[&_[data-slot=menu-v2-item-shortcut]]:w-5 [&_[data-slot=menu-v2-item-shortcut]]:justify-center"
style={{ "min-width": "180px" }}
>
<MenuV2.Item onSelect={pick} shortcut={command.keybind("file.attach")}>
{language.t("prompt.menu.imagesAndFiles")}
</MenuV2.Item>
@@ -1776,7 +1772,7 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="max-w-[160px] justify-start capitalize"
style={control()}
>
<span class="truncate leading-5">
<span class="truncate">
{props.controls.model.selection.variant.current() ?? language.t("common.default")}
</span>
<span class="-ml-0.5 -mr-1 flex shrink-0">
@@ -2063,9 +2059,11 @@ export const PromptInput: Component<PromptInputProps> = (props) => {
class="min-w-0 max-w-[320px] text-13-regular text-text-base group"
style={control()}
onClick={() => {
dialog.show(() => (
<DialogSelectModelUnpaid model={props.controls.model.selection} />
))
void import("@/components/dialog-select-model-unpaid").then((x) => {
dialog.show(() => (
<x.DialogSelectModelUnpaid model={props.controls.model.selection} />
))
})
}}
>
<Show when={props.controls.model.selection.current()?.provider?.id}>
@@ -2232,47 +2230,30 @@ function ComposerModelControl(props: { state: ComposerModelControlState }) {
</>
}
>
<Show
when={props.state.newLayoutDesigns}
fallback={
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
}
<Button
data-action="prompt-model"
as="div"
variant="ghost"
size="normal"
class="min-w-0 max-w-[220px] justify-start text-[13px] font-[440] leading-5 text-v2-text-text-faint group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<ButtonV2
data-action="prompt-model"
variant="ghost-muted"
size="normal"
class="min-w-0 max-w-[220px] justify-start ![font-weight:440] group"
classList={{ "animate-in fade-in": props.state.shouldAnimate }}
style={props.state.style}
onClick={props.state.onUnpaidClick}
>
<ModelControlContent state={props.state} v2 />
</ButtonV2>
</Show>
<Show when={props.state.providerID}>
{(providerID) => (
<ProviderIcon
id={providerID()}
class="size-4 shrink-0 opacity-40 group-hover:opacity-100 transition-opacity duration-150"
style={{ "will-change": "opacity", transform: "translateZ(0)" }}
/>
)}
</Show>
<span class="truncate">{props.state.modelName}</span>
<span class="-ml-1 shrink-0 flex size-fit">
<Icon name="chevron-down" size="small" class="text-v2-icon-icon-muted" />
</span>
</Button>
</TooltipV2>
}
>
@@ -1,6 +1,5 @@
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
@@ -34,10 +33,6 @@ const prompt = {
current: () => promptValue,
cursor: () => 0,
dirty: () => true,
model: {
current: () => undefined,
set: () => undefined,
},
reset: () => undefined,
set: () => undefined,
context: {
@@ -383,39 +378,6 @@ 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,
@@ -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, startTransition, type Accessor } from "solid-js"
import { batch, 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, type ModelSelection } from "@/context/local"
import { useLocal } 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,7 +191,6 @@ type PromptSubmitInput = {
onQueue?: (draft: FollowupDraft) => void
onAbort?: () => void
onSubmit?: () => void
model?: ModelSelection
}
export function createPromptSubmit(input: PromptSubmitInput) {
@@ -299,10 +298,9 @@ export function createPromptSubmit(input: PromptSubmitInput) {
return
}
const modelSelection = input.model ?? local.model
const currentModel = modelSelection.current()
const currentModel = local.model.current()
const currentAgent = local.agent.current()
const variant = modelSelection.variant.current()
const variant = local.model.variant.current()
if (!currentModel || !currentAgent) {
showToast({
title: language.t("prompt.toast.modelAgentRequired.title"),
@@ -376,20 +374,13 @@ export function createPromptSubmit(input: PromptSubmitInput) {
if (created) {
seed(sessionDirectory, created)
session = created
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 (shouldAutoAccept) permission.enableAutoAccept(session.id, sessionDirectory)
local.session.promote(sessionDirectory, session.id)
layout.handoff.setTabs(base64Encode(sessionDirectory), session.id)
const draftID = search.draftId
if (draftID) tabs.promoteDraft(draftID, { server: tabs.draft(draftID).server, sessionId: session.id })
else navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`)
submission.retarget(prompt.capture({ dir: base64Encode(sessionDirectory), id: session.id }))
}
}
if (!session) {
@@ -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 } from "@/components/session/session-context-metrics"
import { getSessionContext, getSessionTokenTotal } 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,6 +74,7 @@ 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)
})
@@ -131,7 +132,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) {
<ContextTooltipRow name={language.t("context.usage.usage")} value={`${context()?.usage ?? 0}%`} />
<ContextTooltipRow
name={language.t("context.usage.tokens")}
value={context()?.total.toLocaleString(language.intl()) ?? "0"}
value={getSessionTokenTotal(tokens())?.toLocaleString(language.intl()) ?? "0"}
/>
</div>
)
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { Message } from "@opencode-ai/sdk/v2/client"
import { getSessionContext } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
const assistant = (
id: string,
@@ -38,10 +38,10 @@ const user = (id: string) => {
}
describe("getSessionContext", () => {
test("computes token totals and usage from latest assistant with tokens", () => {
test("computes usage from latest assistant with tokens", () => {
const messages = [
user("u1"),
assistant("a1", { input: 600, output: 200, reasoning: 100, read: 50, write: 50 }, 0.5),
assistant("a1", { input: 0, output: 0, reasoning: 0, read: 0, write: 0 }, 0.5),
assistant("a2", { input: 300, output: 100, reasoning: 50, read: 25, write: 25 }, 1.25),
]
const providers = [
@@ -60,8 +60,6 @@ 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")
@@ -96,4 +94,15 @@ 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)
})
})
@@ -1,4 +1,4 @@
import type { AssistantMessage, Message } from "@opencode-ai/sdk/v2/client"
import type { AssistantMessage, Message, Session } from "@opencode-ai/sdk/v2/client"
type Provider = {
id: string
@@ -21,7 +21,6 @@ type Context = {
modelLabel: string
limit: number | undefined
input: number
total: number
usage: number | null
}
@@ -55,7 +54,6 @@ 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,
}
}
@@ -63,3 +61,8 @@ 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
}
@@ -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 } from "./session-context-metrics"
import { getSessionContext, getSessionTokenTotal } from "./session-context-metrics"
import { estimateSessionContextBreakdown, type SessionContextBreakdownKey } from "./session-context-breakdown"
import { createSessionContextFormatter } from "./session-context-format"
@@ -135,6 +135,7 @@ 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(() => {
@@ -203,15 +204,14 @@ 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(ctx()?.total) },
{ label: "context.stats.totalTokens", value: () => formatter().number(getSessionTokenTotal(tokens())) },
{ label: "context.stats.usage", value: () => formatter().percent(ctx()?.usage) },
{ 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.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.cacheTokens",
value: () =>
`${formatter().number(ctx()?.message.tokens.cache.read)} / ${formatter().number(ctx()?.message.tokens.cache.write)}`,
value: () => `${formatter().number(tokens()?.cache.read)} / ${formatter().number(tokens()?.cache.write)}`,
},
{ label: "context.stats.userMessages", value: () => counts().user.toLocaleString(language.intl()) },
{ label: "context.stats.assistantMessages", value: () => counts().assistant.toLocaleString(language.intl()) },
@@ -211,17 +211,7 @@ export function SortableTerminalTabV2(props: {
<MenuV2.Context.Trigger class="relative" as="div">
<Tabs.Trigger
value={props.terminal.id}
onMouseDown={(e) => {
// 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()
}}
onClick={focus}
closeButton={
<IconButton
icon="close-small"
@@ -20,11 +20,6 @@
height: 100%;
overflow-y: auto;
scrollbar-width: none;
user-select: none;
}
.settings-v2-panel :is(input, textarea, [contenteditable="true"]) {
user-select: text;
}
.settings-v2-panel::-webkit-scrollbar {
@@ -186,7 +181,6 @@
flex-direction: column;
gap: 8px;
padding: 4px 0 4px 4px;
user-select: none;
}
.settings-v2-nav-footer > span {
+2 -19
View File
@@ -1,8 +1,7 @@
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 { resolveThemeVariantV2 } from "@opencode-ai/ui/theme/v2/resolve"
import type { HexColor, ResolvedV2Theme } from "@opencode-ai/ui/theme/types"
import type { HexColor } 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"
@@ -69,19 +68,6 @@ 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
@@ -252,10 +238,7 @@ 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 = settings.general.newLayoutDesigns()
? (resolveV2Token(resolveThemeVariantV2(variant, mode === "dark"), "v2-background-bg-base") ??
fallback.background)
: (resolved["background-stronger"] ?? fallback.background)
const 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)
+2 -10
View File
@@ -25,7 +25,6 @@ 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"
@@ -325,20 +324,13 @@ export function Titlebar(props: { update?: TitlebarUpdate }) {
const route = layout.route()
const activeSession = session()
if (route.type === "session" && activeSession) {
const sessionTab = {
type: "session" as const,
server: route.server ?? server.key,
sessionId: activeSession.id,
}
const model = tabs.stateValue<PromptSession>(sessionTab, "prompt")?.model.current()
tabs.newDraft({ server: sessionTab.server, directory: activeSession.directory }, "", model)
tabs.newDraft({ server: route.server ?? server.key, directory: activeSession.directory }, "")
return
}
const activeTab = currentTab()
if (activeTab?.type === "draft") {
const model = tabs.stateValue<PromptSession>(activeTab, "prompt")?.model.current()
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "", model)
tabs.newDraft({ server: activeTab.server, directory: activeTab.directory }, "")
return
}
-119
View File
@@ -1,119 +0,0 @@
/**
* Taken from https://www.solid-ui.com/docs/components/drawer
* Only used in one place hence not a v2 component yet... can be promoted to ui/v2 later
*/
import type { Component, ComponentProps, JSX, ValidComponent } from "solid-js"
import { splitProps } from "solid-js"
import type { ContentProps, DescriptionProps, DynamicProps, LabelProps, OverlayProps } from "@corvu/drawer"
import DrawerPrimitive from "@corvu/drawer"
const Drawer = DrawerPrimitive
const DrawerTrigger = DrawerPrimitive.Trigger
const DrawerPortal = DrawerPrimitive.Portal
const DrawerClose = DrawerPrimitive.Close
type DrawerOverlayProps<T extends ValidComponent = "div"> = OverlayProps<T> & { class?: string }
const DrawerOverlay = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerOverlayProps<T>>) => {
const [, rest] = splitProps(props as DrawerOverlayProps, ["class"])
const drawerContext = DrawerPrimitive.useContext()
const overlayStyle = () => {
const state = drawerContext.transitionState()
if (state === "opening" || state === "closing") return undefined
const open = drawerContext.openPercentage()
return {
opacity: open,
"backdrop-filter": `blur(${4 * open}px)`,
}
}
return (
<DrawerPrimitive.Overlay
class={props.class}
classList={{
"fixed inset-0 z-[100] bg-v2-overlay-simple-overlay-scrim opacity-0 backdrop-blur-none transition-[opacity,backdrop-filter] duration-300 data-[opening]:opacity-100 data-[opening]:backdrop-blur-[4px] data-[closing]:opacity-0 data-[closing]:backdrop-blur-none": true,
}}
style={overlayStyle()}
{...rest}
/>
)
}
type DrawerContentProps<T extends ValidComponent = "div"> = ContentProps<T> & {
class?: string
children?: JSX.Element
}
const DrawerContent = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerContentProps<T>>) => {
const [, rest] = splitProps(props as DrawerContentProps, ["class", "children"])
return (
<DrawerPortal>
<DrawerOverlay />
<DrawerPrimitive.Content
class={props.class}
classList={{
"group/drawer-content fixed inset-y-[6px] right-[6px] left-auto z-[100] flex h-auto max-h-[calc(100vh-12px)] w-[560px] max-w-[calc(100vw-12px)] flex-col items-start rounded-[8px] bg-v2-background-bg-base p-0 shadow-[var(--v2-elevation-overlay)] data-[transitioning]:transition-transform data-[transitioning]:duration-300 md:select-none": true,
}}
{...rest}
>
{props.children}
</DrawerPrimitive.Content>
</DrawerPortal>
)
}
const DrawerHeader: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "grid gap-1.5 p-4 text-center sm:text-left": true }} {...rest} />
}
const DrawerFooter: Component<ComponentProps<"div">> = (props) => {
const [, rest] = splitProps(props, ["class"])
return <div class={props.class} classList={{ "mt-auto flex flex-col gap-2 p-4": true }} {...rest} />
}
type DrawerTitleProps<T extends ValidComponent = "div"> = LabelProps<T> & { class?: string }
const DrawerTitle = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerTitleProps<T>>) => {
const [, rest] = splitProps(props as DrawerTitleProps, ["class"])
return (
<DrawerPrimitive.Label
class={props.class}
classList={{ "text-base font-[530] leading-none tracking-[-0.04px] text-v2-text-text-base": true }}
{...rest}
/>
)
}
type DrawerDescriptionProps<T extends ValidComponent = "div"> = DescriptionProps<T> & {
class?: string
}
const DrawerDescription = <T extends ValidComponent = "div">(props: DynamicProps<T, DrawerDescriptionProps<T>>) => {
const [, rest] = splitProps(props as DrawerDescriptionProps, ["class"])
return (
<DrawerPrimitive.Description
class={props.class}
classList={{
"text-[13px] font-[440] leading-[140%] tracking-[-0.04px] text-v2-text-text-muted": true,
}}
{...rest}
/>
)
}
export {
Drawer,
DrawerPortal,
DrawerOverlay,
DrawerTrigger,
DrawerClose,
DrawerContent,
DrawerHeader,
DrawerFooter,
DrawerTitle,
DrawerDescription,
}
+55 -39
View File
@@ -1,6 +1,6 @@
import { createSimpleContext } from "@opencode-ai/ui/context"
import { base64Encode } from "@opencode-ai/core/util/encode"
import { useParams } from "@solidjs/router"
import { useParams, useSearchParams } from "@solidjs/router"
import { batch, createEffect, createMemo, startTransition } from "solid-js"
import { createStore } from "solid-js/store"
import { useModels } from "@/context/models"
@@ -11,21 +11,22 @@ import { useSDK } from "./sdk"
import { useSync } from "./sync"
import { useServerSDK } from "./server-sdk"
import { ScopedKey, type ServerScope } from "@/utils/server-scope"
import {
cloneModelSelectionState,
createModelSelectionState,
createTabModelSelectionState,
type ModelSelectionState,
} from "./model-selection-state"
import { useTabs } from "./tabs"
export type ModelKey = { providerID: string; modelID: string; variant?: string }
type State = {
agent?: string
model?: ModelKey
variant?: string | null
}
type Saved = {
session: Record<string, State | undefined>
session: Record<string, ModelSelectionState | undefined>
}
const WORKSPACE_KEY = "__workspace__"
const handoff = new Map<string, State>()
const handoff = new Map<string, ModelSelectionState>()
const handoffKey = (scope: ServerScope, dir: string, id: string) => ScopedKey.from(scope, dir, id)
@@ -33,8 +34,8 @@ const migrate = (value: unknown) => {
if (!value || typeof value !== "object") return { session: {} }
const item = value as {
session?: Record<string, State | undefined>
pick?: Record<string, State | undefined>
session?: Record<string, ModelSelectionState | undefined>
pick?: Record<string, ModelSelectionState | undefined>
}
if (item.session && typeof item.session === "object") return { session: item.session }
@@ -45,14 +46,6 @@ const migrate = (value: unknown) => {
}
}
const clone = (value: State | undefined) => {
if (!value) return
return {
...value,
model: value.model ? { ...value.model } : undefined,
} satisfies State
}
export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
name: "Local",
init: () => {
@@ -62,12 +55,14 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const serverSDK = useServerSDK()
const providers = useProviders(() => sdk().directory)
const models = useModels()
const tabs = useTabs()
const [search] = useSearchParams<{ draftId?: string }>()
const id = createMemo(() => params.id || undefined)
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, , savedReady] = persisted(
const [saved, setSaved] = persisted(
{
...Persist.serverWorkspace(serverSDK().scope, sdk().directory, "model-selection", ["model-selection.v1"]),
migrate,
@@ -79,8 +74,8 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
const [store, setStore] = createStore<{
current?: string
draft?: State
promoting?: State
draft?: ModelSelectionState
promoting?: ModelSelectionState
last?: {
type: "agent" | "model" | "variant"
agent?: string
@@ -122,10 +117,33 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
setStore("current", items[0]?.name)
})
const scope = createMemo<State | undefined>(() => {
const draft = createMemo(() => {
const draftID = search.draftId
if (!draftID) return
const tab = tabs.store.find((item) => item.type === "draft" && item.draftID === draftID)
if (!tab || tab.type !== "draft") return
return createTabModelSelectionState(tabs, tab, () => {
const [state, setState] = persisted(
Persist.draft(tab.draftID, "model-selection"),
createStore<{ value?: ModelSelectionState }>({}),
)
return createModelSelectionState([state, setState])
})
})
const setDraft = (value: ModelSelectionState | undefined) => {
const state = draft()
if (state) {
state.set(value)
return
}
setStore("draft", cloneModelSelectionState(value))
}
const scope = createMemo<ModelSelectionState | undefined>(() => {
const session = id()
if (!session) return store.draft ?? store.promoting
return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
if (session) return saved.session[session] ?? handoff.get(handoffKey(serverSDK().scope, sdk().directory, session))
return draft()?.current() ?? store.draft ?? store.promoting
})
createEffect(() => {
@@ -141,7 +159,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return
}
setSaved("session", session, clone(next))
setSaved("session", session, cloneModelSelectionState(next))
handoff.delete(key)
setStore("promoting", undefined)
})
@@ -203,13 +221,13 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: item.name,
model: item.model ?? prev?.model,
variant: item.variant ?? prev?.variant,
} satisfies State
} satisfies ModelSelectionState
const session = id()
if (session) {
setSaved("session", session, next)
return
}
setStore("draft", next)
setDraft(next)
})
},
move(direction: 1 | -1) {
@@ -256,21 +274,21 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
agent: agent.current()?.name,
model: model ? { providerID: model.provider.id, modelID: model.id } : undefined,
variant: selected(),
} satisfies State
} satisfies ModelSelectionState
}
const write = (next: Partial<State>) => {
const write = (next: Partial<ModelSelectionState>) => {
const state = {
...(scope() ?? { agent: agent.current()?.name }),
...next,
} satisfies State
} satisfies ModelSelectionState
const session = id()
if (session) {
setSaved("session", session, state)
return
}
setStore("draft", state)
setDraft(state)
}
const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean))
@@ -375,12 +393,12 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
model,
agent,
session: {
ready: savedReady,
reset() {
setStore({ draft: undefined, promoting: undefined })
setDraft(undefined)
setStore("promoting", undefined)
},
promote(dir: string, session: string, state?: State) {
const next = clone(state ?? snapshot())
promote(dir: string, session: string) {
const next = cloneModelSelectionState(snapshot())
if (!next) return
const key = handoffKey(serverSDK().scope, dir, session)
handoff.set(key, next)
@@ -390,7 +408,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
}
setStore("promoting", next)
setStore("draft", undefined)
setDraft(undefined)
},
restore(msg: { sessionID: string; agent: string; model: ModelKey }) {
const session = id()
@@ -410,5 +428,3 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({
return result
},
})
export type ModelSelection = ReturnType<typeof useLocal>["model"]
@@ -0,0 +1,39 @@
import { describe, expect, test } from "bun:test"
import { createRoot, getOwner } from "solid-js"
import { createTabMemory } from "./tab-memory"
import { createTabModelSelectionState } from "./model-selection-state"
import type { DraftTab } from "./tabs"
describe("model selection state", () => {
test("keeps model selection scoped to its draft tab across consumers", () => {
createRoot((dispose) => {
const memory = createTabMemory(getOwner())
const tabs = {
state<T>(tab: DraftTab, name: string, init: () => T) {
return memory.ensure(`draft:${tab.draftID}`, name, init)
},
}
const first = { type: "draft", draftID: "first", server: "server", directory: "/repo" } as DraftTab
const second = { type: "draft", draftID: "second", server: "server", directory: "/repo" } as DraftTab
createTabModelSelectionState(tabs, first).set({
agent: "build",
model: { providerID: "anthropic", modelID: "claude" },
})
createTabModelSelectionState(tabs, second).set({
agent: "build",
model: { providerID: "openai", modelID: "gpt" },
})
expect(createTabModelSelectionState(tabs, first).current()?.model).toEqual({
providerID: "anthropic",
modelID: "claude",
})
expect(createTabModelSelectionState(tabs, second).current()?.model).toEqual({
providerID: "openai",
modelID: "gpt",
})
dispose()
})
})
})
@@ -0,0 +1,38 @@
import { createStore, type SetStoreFunction, type Store } from "solid-js/store"
import type { DraftTab, useTabs } from "./tabs"
export type ModelSelectionState = {
agent?: string
model?: { providerID: string; modelID: string; variant?: string }
variant?: string | null
}
export function cloneModelSelectionState(value: ModelSelectionState | undefined) {
if (!value) return
return {
...value,
model: value.model ? { ...value.model } : undefined,
} satisfies ModelSelectionState
}
type ModelSelectionStore = { value?: ModelSelectionState }
export function createModelSelectionState(
state: [Store<ModelSelectionStore>, SetStoreFunction<ModelSelectionStore>] = createStore<ModelSelectionStore>({}),
) {
const [store, setStore] = state
return {
current: () => store.value,
set(value: ModelSelectionState | undefined) {
setStore("value", cloneModelSelectionState(value))
},
}
}
export function createTabModelSelectionState(
tabs: Pick<ReturnType<typeof useTabs>, "state">,
tab: DraftTab,
init: () => ReturnType<typeof createModelSelectionState> = createModelSelectionState,
) {
return tabs.state(tab, "model-selection", init)
}
@@ -1,29 +0,0 @@
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()
})
})
})
-265
View File
@@ -1,265 +0,0 @@
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<PromptStore>) {
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<PromptStore>) {
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<FileContextItem> & { 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<typeof promptTarget>, initial?: InitialPrompt) {
const [store, setStore, _, ready] = persisted(target, createStore<PromptStore>(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<typeof createPromptSession>
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
export function createPromptState(initial?: InitialPrompt) {
const [store, setStore] = createStore<PromptStore>(promptStore(initial))
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
+271 -49
View File
@@ -1,49 +1,186 @@
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 { createMemo, createRoot, getOwner, onCleanup } from "solid-js"
import { requireServerKey } from "@/utils/session-route"
import { ServerConnection } from "./server"
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 { useServerSDK } from "./server-sdk"
import { useSettings } from "./settings"
import type { ServerScope } from "@/utils/server-scope"
import { useSDK } from "./sdk"
import { useTabs, type Tab } from "./tabs"
import {
createPromptReady,
createPromptSession,
type ContextItem,
type FileContextItem,
type Prompt,
type PromptModel,
type PromptScope,
type PromptSession,
} from "./prompt-state"
import { ServerConnection } from "./server"
import { requireServerKey } from "@/utils/session-route"
import { useSettings } from "./settings"
import type { FilePartSource } from "@opencode-ai/sdk/v2/client"
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"
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)
})
},
}
}
const WORKSPACE_KEY = "__workspace__"
const MAX_PROMPT_SESSIONS = 20
export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerConnection.Key) {
type PromptSession = ReturnType<typeof createPromptSession>
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) {
if ("draftID" in scope) return tabs.find((tab) => tab.type === "draft" && tab.draftID === scope.draftID)
if (!scope.id) return
return (
@@ -52,7 +189,7 @@ export function selectPromptTab(tabs: Tab[], scope: PromptScope, server: ServerC
)
}
function scopeKey(scope: PromptScope) {
function scopeKey(scope: Scope) {
if ("draftID" in scope) return `draft:${scope.draftID}`
return `${scope.dir}:${scope.id ?? WORKSPACE_KEY}`
}
@@ -62,6 +199,91 @@ 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>(promptStore()),
)
return { ready, ...createPromptStateValue(store, setStore) }
}
export function createPromptReady(session: Accessor<PromptSession>) {
return Object.defineProperty(() => session().ready(), "promise", {
get: () => session().ready.promise,
}) as (() => boolean) & { readonly promise: Promise<unknown> | undefined }
}
function promptStore(): PromptStore {
return {
prompt: clonePrompt(DEFAULT_PROMPT),
cursor: undefined,
context: {
items: [],
},
}
}
function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction<PromptStore>) {
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<FileContextItem> & { 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>(promptStore())
const ready = Object.assign(() => true, { promise: Promise.resolve(true) })
return {
ready,
...createPromptStateValue(store, setStore),
}
}
export const createTabPromptState = (
tabs: ReturnType<typeof useTabs>,
tab: Tab,
@@ -81,7 +303,9 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const cache = new Map<string, PromptCacheEntry>()
const disposeAll = () => {
for (const entry of cache.values()) entry.dispose()
for (const entry of cache.values()) {
entry.dispose()
}
cache.clear()
}
@@ -100,11 +324,13 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
const owner = getOwner()
const serverKey = () =>
params.serverKey ? requireServerKey(params.serverKey) : ServerConnection.key(serverSDK().server)
const scope = (): PromptScope =>
const scope = () =>
search.draftId ? { draftID: search.draftId } : { dir: base64Encode(sdk().directory), id: params.id }
const load = (scope: PromptScope) => {
const load = (scope: Scope) => {
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)
@@ -128,19 +354,15 @@ export const { use: usePrompt, provider: PromptProvider } = createSimpleContext(
}
const session = createMemo(() => load(scope()))
const pick = (scope?: PromptScope) => (scope ? load(scope) : session())
const pick = (scope?: Scope) => (scope ? load(scope) : session())
const ready = createPromptReady(session)
return {
ready,
capture: (scope?: PromptScope) => pick(scope).capture(),
capture: (scope?: Scope) => 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),
@@ -150,8 +372,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?: PromptScope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: PromptScope) => pick(scope).reset(),
set: (prompt: Prompt, cursorPosition?: number, scope?: Scope) => pick(scope).set(prompt, cursorPosition),
reset: (scope?: Scope) => pick(scope).reset(),
}
},
})
-3
View File
@@ -16,9 +16,6 @@ export function createTabMemory(owner: Owner | null) {
}
return {
get<T>(key: string, name: string) {
return entries.get(key)?.get(name)?.value as T | undefined
},
ensure<T>(key: string, name: string, init: () => T) {
const state = entries.get(key) ?? new Map<string, Entry>()
if (!entries.has(key)) entries.set(key, state)
-2
View File
@@ -22,8 +22,6 @@ describe("tab memory", () => {
})
expect(memory.ensure("tab", "prompt", () => ({ value: "other" }))).toBe(first)
expect(memory.get<typeof first>("tab", "prompt")).toBe(first)
expect(memory.get("missing", "prompt")).toBeUndefined()
expect(memory.ensure("other", "prompt", () => ({ value: "other" }))).not.toBe(first)
memory.remove("tab")
+3 -9
View File
@@ -11,7 +11,6 @@ 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"
@@ -208,17 +207,15 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
if (!tab || tab.type !== "draft") throw new Error(`Draft not found: ${draftID}`)
return tab
},
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string, model?: PromptModel) {
newDraft(draft: Omit<DraftTab, "type" | "draftID">, prompt?: string) {
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(tab)
tabs.push({ type: "draft", draftID, ...draft })
}),
)
navigate(draftHref(draftID))
navigate(prompt ? `${draftHref(draftID)}&prompt=${encodeURIComponent(prompt)}` : draftHref(draftID))
})
},
updateDraft(draftID: string, draft: Partial<Omit<DraftTab, "type" | "draftID">>) {
@@ -376,9 +373,6 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({
state<T>(tab: Tab, name: string, init: () => T) {
return memory.ensure(tabKey(tab), name, init)
},
stateValue<T>(tab: Tab, name: string) {
return memory.get<T>(tabKey(tab), name)
},
}
return { ...actions, store, info, ready, recentReady }
-10
View File
@@ -12,16 +12,6 @@ interface ImportMeta {
readonly env: ImportMetaEnv
}
declare module "*.png" {
const src: string
export default src
}
declare module "*.mp4" {
const src: string
export default src
}
export declare module "solid-js" {
namespace JSX {
interface Directives {
+1 -1
View File
@@ -42,7 +42,7 @@ const domains = [
},
] as const
describe.skipIf(!!process.env.CI)("i18n parity", () => {
describe("i18n parity", () => {
test("non-English locales have every English key", async () => {
for (const domain of domains) {
const source = await dictionary(domain.source)
+1 -2
View File
@@ -1,7 +1,7 @@
import { createEffect, Suspense, type ParentProps } from "solid-js"
import { useNavigate } from "@solidjs/router"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { usePlatform } from "@/context/platform"
import { setNavigate } from "@/utils/notification-click"
@@ -39,7 +39,6 @@ export default function NewLayout(props: ParentProps) {
<Suspense>{props.children}</Suspense>
</main>
{import.meta.env.DEV && <DebugBar inline />}
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2 />
</div>
+1 -2
View File
@@ -54,7 +54,7 @@ import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context"
import { useCommand, type CommandOption } from "@/context/command"
import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd"
import { DebugBar } from "@/components/debug-bar"
import { HelpButton, TabsInfoPopup } from "@/components/help-button"
import { HelpButton } from "@/components/help-button"
import { Titlebar, type TitlebarUpdate } from "@/components/titlebar"
import { useDirectoryPicker } from "@/components/directory-picker"
import { ServerConnection, useServer } from "@/context/server"
@@ -2395,7 +2395,6 @@ export default function LegacyLayout(props: ParentProps) {
</div>
{import.meta.env.DEV && import.meta.env.VITE_DISABLE_DEBUG_BAR !== "1" && <DebugBar />}
</div>
<TabsInfoPopup />
<HelpButton />
<ToastRegion v2={false} />
</div>
+1 -18
View File
@@ -26,13 +26,10 @@ 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
@@ -51,15 +48,12 @@ 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({ model })
useComposerCommands()
let inputRef: HTMLDivElement | undefined
@@ -67,7 +61,6 @@ export default function NewSessionPage() {
sessionKey: route.sessionKey,
sessionID: () => route.params.id,
queryOptions: serverSync().queryOptions,
model,
})
const projectControls = createPromptProjectControls()
const projectController = createPromptProjectController({
@@ -75,16 +68,6 @@ 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()
+3 -14
View File
@@ -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 { restorePromptModel, syncPromptModel, syncSessionModel } from "@/pages/session/session-model-helpers"
import { 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() && (newSessionDesign() || !desktopReviewOpen()))
const centered = createMemo(() => isDesktop() && !desktopReviewOpen())
const desktopV2PanelLayout = createMemo(() =>
sessionPanelLayout({
review: desktopV2ReviewOpen(),
@@ -557,17 +557,6 @@ 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 }),
@@ -1315,7 +1304,7 @@ export default function Page() {
const reviewPanelV2Rendered = createMemo<boolean>((prev) => prev || !store.deferRender, false)
const reviewPanelV2 = () => (
<div class="flex flex-col h-full overflow-hidden bg-v2-background-bg-base contain-strict">
<div class="flex flex-col h-full overflow-hidden bg-background-stronger contain-strict">
<Show when={reviewPanelV2Rendered()}>
<ReviewPanelV2 {...reviewPanelV2Props()} />
</Show>
@@ -1,133 +0,0 @@
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<typeof item> => !!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
}
@@ -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, type ModelSelection } from "@/context/local"
import { useLocal } from "@/context/local"
import type { QueryOptionsApi } from "@/context/server-sync"
import { useServerSDK } from "@/context/server-sdk"
import { serverName, ServerConnection, useServer } from "@/context/server"
@@ -22,7 +22,6 @@ export function createPromptInputController(input: {
sessionKey: Accessor<string>
sessionID: Accessor<string | undefined>
queryOptions: Pick<QueryOptionsApi, "agents" | "providers">
model?: ModelSelection
}) {
const layout = useLayout()
const local = useLocal()
@@ -45,7 +44,7 @@ export function createPromptInputController(input: {
select: local.agent.set,
},
model: {
selection: input.model ?? local.model,
selection: local.model,
paid: providers.paid().length > 0,
loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading,
},
@@ -1,118 +0,0 @@
import { For } from "solid-js"
import { createStore } from "solid-js/store"
import { SessionRevertDock } from "@/pages/session/composer/session-revert-dock"
import { SettingsProvider, useSettings } from "@/context/settings"
export default {
title: "Composer/Revert Dock",
id: "composer-revert-dock",
tags: ["autodocs"],
parameters: {
docs: {
description: {
component: `### Overview
Real \`SessionRevertDock\` from app code, rendered above a mock composer card.
### Source path
- \`packages/app/src/pages/session/composer/session-revert-dock.tsx\`
### Why the mock composer
The live composer overlaps the dock's bottom by 18px (\`session-composer-region-controller.ts\` \`lift()\`).
The card below reproduces that overlap so the collapsed/expanded cutoff behavior can be verified in isolation.
### Layout split
Use the **Layout** button to toggle \`newLayoutDesigns\` and preview both the v2 dock and the legacy (v1) \`DockTray\` fallback.
### Notes
- \`onRestore\` only mutates local story state, so nothing in the real session is affected.
- Click the header to expand/collapse. Click "Restore message" to remove a row.`,
},
},
},
}
const messages = [
"update current branch with latest changes from dev and fix conflicts if any",
"investigate why the chat input loses focus after sending a message",
"Debug why streaming responses sometimes duplicate the last token",
"suggest a better title for this PR based on the diff",
"add a storybook story for the revert dock",
"reduce re-renders in the timeline component",
]
const btn = (accent?: boolean) =>
({
padding: "6px 12px",
"border-radius": "6px",
border: "1px solid var(--v2-border-border-base, #0000001a)",
background: accent ? "var(--v2-background-bg-contrast, #242424)" : "var(--v2-background-bg-base, #fff)",
color: accent ? "var(--v2-text-text-contrast, #fafafa)" : "var(--v2-text-text-base, #161616)",
cursor: "pointer",
"font-size": "13px",
}) as const
function Stage(props: { count: number }) {
const settings = useSettings()
const seed = () => messages.slice(0, props.count).map((text, index) => ({ id: `rolled-${index}`, text }))
const [store, setStore] = createStore({ items: seed() })
const v2 = () => settings.general.newLayoutDesigns()
const reset = () => setStore("items", seed())
const restore = (id: string) =>
setStore(
"items",
store.items.filter((item) => item.id !== id),
)
return (
<div style={{ display: "grid", gap: "16px", "max-width": "720px" }}>
<div style={{ display: "flex", gap: "8px" }}>
<button style={btn()} onClick={reset}>
Reset ({props.count})
</button>
<button style={btn(v2())} onClick={() => settings.general.setNewLayoutDesigns(!v2())}>
Layout: {v2() ? "v2" : "v1"}
</button>
</div>
{/* Reproduce the real composer stack: dock + card overlapping the dock's bottom by lift() = 18px */}
<div style={{ display: "flex", "flex-direction": "column" }}>
<SessionRevertDock items={store.items} onRestore={restore} />
<div
style={{ position: "relative", "z-index": 70, "margin-top": "-18px" }}
class="min-h-24 w-full rounded-[12px] border border-v2-border-border-base bg-v2-background-bg-base px-4 py-3 text-[13px] text-v2-text-text-faint"
>
Ask anything...
</div>
</div>
<div class="text-[12px] text-v2-text-text-faint">
Restored so far:{" "}
<For each={seed()}>
{(item) => <span>{store.items.some((current) => current.id === item.id) ? "" : `“${item.text}” `}</span>}
</For>
</div>
</div>
)
}
const story = (count: number) => () => (
<SettingsProvider>
<Stage count={count} />
</SettingsProvider>
)
export const OneMessage = {
name: "1 rolled back",
render: story(1),
}
export const ThreeMessages = {
name: "3 rolled back",
render: story(3),
}
export const ManyMessages = {
name: "6 rolled back (scrolls)",
render: story(6),
}
@@ -3,11 +3,7 @@ import { createStore } from "solid-js/store"
import { Button } from "@opencode-ai/ui/button"
import { DockTray } from "@opencode-ai/ui/dock-surface"
import { IconButton } from "@opencode-ai/ui/icon-button"
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 { useLanguage } from "@/context/language"
import { useSettings } from "@/context/settings"
export function SessionRevertDock(props: {
items: { id: string; text: string }[]
@@ -16,7 +12,6 @@ export function SessionRevertDock(props: {
onRestore: (id: string) => void
}) {
const language = useLanguage()
const settings = useSettings()
const [store, setStore] = createStore({
collapsed: true,
})
@@ -36,153 +31,69 @@ export function SessionRevertDock(props: {
)
const preview = createMemo(() => props.items[0]?.text ?? "")
const onHeaderKeyDown = (event: KeyboardEvent) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}
return (
<Show
when={settings.general.newLayoutDesigns()}
fallback={
<DockTray data-component="session-revert-dock">
<div
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
</div>
<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
}
>
<DockTray data-component="session-revert-dock">
<div
data-component="session-revert-dock"
class="w-full overflow-hidden rounded-xl border-[0.5px] border-v2-border-border-base bg-v2-background-bg-layer-01"
class="pl-3 pr-2 py-2 flex items-center gap-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={(event) => {
if (event.key !== "Enter" && event.key !== " ") return
event.preventDefault()
toggle()
}}
>
<div
class="flex h-[42px] items-center gap-2 pl-4 pr-2"
role="button"
tabIndex={0}
onClick={toggle}
onKeyDown={onHeaderKeyDown}
>
<IconV2 name="outline-reset" size="normal" class="text-v2-icon-icon-muted" />
<span
classList={{
"font-[440] shrink-0 cursor-default text-[13px] leading-5 tracking-[-0.04px]": true,
"text-v2-text-text-base": !store.collapsed,
"text-v2-text-text-muted": store.collapsed,
<span class="shrink-0 text-14-regular text-text-strong cursor-default">{label()}</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate text-14-regular text-text-base cursor-default">{preview()}</span>
</Show>
<div class="ml-auto shrink-0">
<IconButton
data-collapsed={store.collapsed ? "true" : "false"}
icon="chevron-down"
size="normal"
variant="ghost"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
>
{label()}
</span>
<Show when={store.collapsed && preview()}>
<span class="min-w-0 flex-1 truncate cursor-default text-[13px] font-[440] leading-5 tracking-[-0.04px] text-v2-text-text-faint">
{preview()}
</span>
</Show>
<div class="ml-auto shrink-0">
<IconButtonV2
icon={<IconV2 name="outline-chevron-down" size="small" />}
size="large"
variant="ghost-muted"
style={{ transform: `rotate(${store.collapsed ? 180 : 0}deg)` }}
onMouseDown={(event) => {
event.preventDefault()
event.stopPropagation()
}}
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
onClick={(event) => {
event.stopPropagation()
toggle()
}}
aria-label={
store.collapsed ? language.t("session.revertDock.expand") : language.t("session.revertDock.collapse")
}
/>
</div>
{/* Sacrificial space the composer overlaps via its negative lift (18px), so the header stays fully visible */}
<Show when={store.collapsed}>
<div class="h-[18px]" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
{/* Scroll viewport ends above the composer; the 18px sacrificial below is what the composer overlaps */}
<div class="flex max-h-42 flex-col gap-2 overflow-y-auto px-4 pt-px pb-3 no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex h-6 min-w-0 items-center gap-2">
<span class="min-w-0 flex-1 truncate text-[13px] font-[400] leading-5 tracking-[-0.04px] text-v2-text-text-muted">
{item.text}
</span>
<ButtonV2
size="small"
variant="neutral"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</ButtonV2>
</div>
)}
</For>
</div>
<div class="h-[18px]" aria-hidden="true" />
</Show>
</div>
</Show>
<Show when={store.collapsed}>
<div class="h-5" aria-hidden="true" />
</Show>
<Show when={!store.collapsed}>
<div class="px-3 pb-7 flex flex-col gap-1.5 max-h-42 overflow-y-auto no-scrollbar">
<For each={props.items}>
{(item) => (
<div class="flex items-center gap-2 min-w-0 py-1">
<span class="min-w-0 flex-1 truncate text-13-regular text-text-strong">{item.text}</span>
<Button
size="small"
variant="secondary"
class="shrink-0"
disabled={props.disabled || !!props.restoring}
onClick={() => props.onRestore(item.id)}
>
{language.t("session.revertDock.restore")}
</Button>
</div>
)}
</For>
</div>
</Show>
</DockTray>
)
}
@@ -1,6 +1,6 @@
import { describe, expect, test } from "bun:test"
import type { UserMessage } from "@opencode-ai/sdk/v2"
import { resetSessionModel, restorePromptModel, syncPromptModel, syncSessionModel } from "./session-model-helpers"
import { resetSessionModel, syncSessionModel } from "./session-model-helpers"
const message = (input?: { agent?: string; model?: UserMessage["model"] }) =>
({
@@ -50,102 +50,3 @@ 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([])
})
})
@@ -7,24 +7,6 @@ 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()
}
@@ -32,32 +14,3 @@ 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
}
@@ -255,10 +255,8 @@ export function SessionSidePanel(props: {
aria-label={language.t("session.panel.reviewAndFiles")}
aria-hidden={!open()}
inert={!open()}
class="relative min-w-0 flex overflow-hidden"
class="relative min-w-0 flex overflow-hidden bg-background-base"
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(),
@@ -277,20 +275,8 @@ export function SessionSidePanel(props: {
}}
>
<Show when={reviewOpen()}>
<div
class="relative min-w-0 h-full flex-1 overflow-hidden"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<div
class="size-full min-w-0 h-full"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-base": !settings.general.newLayoutDesigns(),
}}
>
<div class="relative min-w-0 h-full flex-1 overflow-hidden bg-background-base">
<div class="size-full min-w-0 h-full bg-background-base">
<DragDropProvider
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
@@ -393,13 +379,7 @@ export function SessionSidePanel(props: {
)}
</For>
</SortableProvider>
<div
class="h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3"
classList={{
"bg-v2-background-bg-base": settings.general.newLayoutDesigns(),
"bg-background-stronger": !settings.general.newLayoutDesigns(),
}}
>
<div class="bg-background-stronger h-full shrink-0 sticky right-0 z-10 flex items-center justify-center pr-3">
<TooltipKeybind
title={language.t("command.file.open")}
keybind={command.keybind("file.open")}
@@ -195,7 +195,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
aria-label={language.t("terminal.title")}
aria-hidden={!opened()}
inert={!opened()}
class="relative shrink-0 overflow-hidden bg-v2-background-bg-base"
class="relative shrink-0 overflow-hidden bg-background-stronger"
classList={{
"w-full": !isDesktop() || stacked(),
"min-w-0 h-full flex-1": isDesktop() && opened() && !stacked(),
@@ -237,7 +237,7 @@ export function TerminalPanelV2(props: { stacked?: boolean } = {}) {
when={terminal.ready()}
fallback={
<div class="flex flex-col h-full pointer-events-none">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-v2-background-bg-base overflow-hidden">
<div class="h-10 flex items-center gap-2 px-2 border-b border-border-weaker-base bg-background-stronger overflow-hidden">
<For each={handoff()}>
{(title) => (
<div class="px-2 py-1 rounded-md bg-surface-base text-14-regular text-text-weak truncate max-w-40">
@@ -1243,13 +1243,12 @@ 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 tool = () => {
const asyncFile = () => {
const value = row()
if (value._tag !== "AssistantPart" || value.group.type !== "part") return
if (value._tag !== "AssistantPart" || value.group.type !== "part") return false
const part = getMsgPart(value.group.ref.messageID, value.group.ref.partID)
if (part?.type === "tool") return part
return part?.type === "tool" && ["edit", "write", "apply_patch"].includes(part.tool)
}
const asyncFile = () => ["edit", "write", "apply_patch"].includes(tool()?.tool ?? "")
const [ready, setReady] = createSignal(initialItem.size <= timelineFallbackItemSize || !asyncFile())
let contentMeasureFrame: number | undefined
@@ -1279,8 +1278,6 @@ 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",
}}
>
<div
@@ -1386,7 +1383,7 @@ export function MessageTimeline(props: {
"w-full": true,
"pb-4": true,
"pr-3": true,
"pl-2.5": settings.general.newLayoutDesigns(),
"pl-2": settings.general.newLayoutDesigns(),
"pl-2 md:pl-4": !settings.general.newLayoutDesigns(),
"md:max-w-200 md:mx-auto 2xl:max-w-[1000px]": props.centered && !settings.general.newLayoutDesigns(),
}}
@@ -1,6 +1,6 @@
import { useCommand, type CommandOption } from "@/context/command"
import { useLanguage } from "@/context/language"
import { useLocal, type ModelSelection } from "@/context/local"
import { useLocal } from "@/context/local"
import { useSettings } from "@/context/settings"
import { useDialog } from "@opencode-ai/ui/context/dialog"
import { getCursorPosition, setCursorPosition } from "@/components/prompt-input/editor-dom"
@@ -14,7 +14,7 @@ const withCategory = (category: string) => {
})
}
export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
export const useComposerCommands = () => {
const command = useCommand()
const dialog = useDialog()
const language = useLanguage()
@@ -22,7 +22,6 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
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"))
@@ -44,7 +43,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
}
const { DialogSelectModel } = await import("@/components/dialog-select-model")
owner.run(() => {
void dialog.show(() => <DialogSelectModel model={model} />, restoreComposer)
void dialog.show(() => <DialogSelectModel model={local.model} />, restoreComposer)
})
}
@@ -62,7 +61,7 @@ export const useComposerCommands = (input: { model?: ModelSelection } = {}) => {
title: language.t("command.model.variant.cycle"),
description: language.t("command.model.variant.cycle.description"),
keybind: "shift+mod+d",
onSelect: () => model.variant.cycle(),
onSelect: () => local.model.variant.cycle(),
}),
agentCommand({
id: "agent.cycle",
@@ -46,7 +46,6 @@ export function SessionFileBrowserTab(props: {
const resultsID = `session-file-browser-results-${createUniqueId()}`
const [filter, setFilter] = createSignal("")
const [explicitHighlight, setExplicitHighlight] = createSignal<string>()
const sidebarOpened = () => props.placeholder || props.state.sidebarOpened()
const query = createMemo(() => filter().trim())
const search = createQuery(() => {
const value = query()
@@ -99,15 +98,15 @@ export function SessionFileBrowserTab(props: {
toolbar
toolbarStart={
<>
<SessionReviewV2SidebarToggle opened={sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!sidebarOpened()}>
<SessionReviewV2SidebarToggle opened={props.state.sidebarOpened()} onToggle={props.state.toggleSidebar} />
<Show when={!props.state.sidebarOpened()}>
<SessionFilePanelV2Title>{title()}</SessionFilePanelV2Title>
</Show>
</>
}
sidebar={
<SessionReviewV2Sidebar
open={sidebarOpened()}
open={props.state.sidebarOpened()}
title={<span class="truncate">{title()}</span>}
filter={filter()}
onFilterChange={setFilter}
+1 -1
View File
@@ -463,7 +463,7 @@ function localStorageDirect(): SyncStorage {
}
}
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "file-view", "layout"]
const DRAFT_PERSISTED_KEYS = ["prompt", "comments", "model-selection", "file-view", "layout"]
export function draftPersistedKeys() {
return DRAFT_PERSISTED_KEYS
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/cli",
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"bin": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/codemode",
"version": "1.17.18",
"version": "1.17.16",
"description": "Effect-native confined code execution over schema-described tools",
"private": true,
"type": "module",
-10
View File
@@ -1,10 +0,0 @@
/* This file is auto-generated by SST. Do not edit. */
/* tslint:disable */
/* eslint-disable */
/* deno-fmt-ignore-file */
/* biome-ignore-all lint: auto-generated */
/// <reference path="../../sst-env.d.ts" />
import "sst"
export {}
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-app",
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -4
View File
@@ -3,7 +3,4 @@ Allow: /
# Disallow shared content pages
Disallow: /s/
Disallow: /share/
Sitemap: https://opencode.ai/sitemap.xml
Sitemap: https://opencode.ai/data/sitemap.xml
Disallow: /share/
@@ -86,7 +86,7 @@ export async function handler(
type CostInfo = ReturnType<typeof calculateCost>
const MAX_FAILOVER_RETRIES = 3
const MAX_RETRYABLE_STATUS_RETRIES = 3
const MAX_429_RETRIES = 3
const dict = i18n(localeFromRequest(input.request))
const t = (key: Key, params?: Record<string, string | number>) => resolve(dict[key], params)
const ADMIN_WORKSPACES = [
@@ -213,7 +213,7 @@ export async function handler(
logger.debug("REQUEST URL: " + reqUrl)
logger.debug("REQUEST: " + reqBody.substring(0, 300) + "...")
const isNewInference = providerInfo.id.startsWith("console.") || providerInfo.id.startsWith("console-go.")
const res = await fetchWithRetryableStatus(
const res = await fetchWith429Retry(
reqUrl,
{
method: "POST",
@@ -246,7 +246,7 @@ export async function handler(
// abandoned Console requests do not leave orphaned inference work open.
signal: input.request.signal,
},
{ count: isNewInference ? MAX_RETRYABLE_STATUS_RETRIES : 0 },
{ count: isNewInference ? MAX_429_RETRIES : 0 },
)
if (isNewInference) {
@@ -307,7 +307,7 @@ export async function handler(
logger.debug("STATUS: " + res.status + " " + res.statusText)
// Handle non-streaming response
if (!isStream || [400, 404, 429, 529].includes(res.status)) {
if (!isStream || [400, 404, 429].includes(res.status)) {
const json = await res.json()
await rateLimiter?.track()
const usage = providerInfo.extractUsage(json)
@@ -999,11 +999,11 @@ export async function handler(
providerInfo.apiKey = authInfo.provider.credentials
}
async function fetchWithRetryableStatus(url: string, options: RequestInit, retry = { count: 0 }) {
async function fetchWith429Retry(url: string, options: RequestInit, retry = { count: 0 }) {
const res = await fetch(url, options)
if ([429, 529].includes(res.status) && retry.count < MAX_RETRYABLE_STATUS_RETRIES) {
if (res.status === 429 && retry.count < MAX_429_RETRIES) {
await new Promise((resolve) => setTimeout(resolve, Math.pow(2, retry.count) * 500))
return fetchWithRetryableStatus(url, options, { count: retry.count + 1 })
return fetchWith429Retry(url, options, { count: retry.count + 1 })
}
return res
}
@@ -4,7 +4,6 @@ type Usage = {
input_tokens?: number
input_tokens_details?: {
cached_tokens?: number
cache_write_tokens?: number
}
output_tokens?: number
output_tokens_details?: {
@@ -49,13 +48,12 @@ 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: cacheWriteTokens,
cacheWrite5mTokens: undefined,
cacheWrite1hTokens: undefined,
}
},
@@ -65,20 +65,4 @@ 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,
})
})
})
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/console-core",
"version": "1.17.18",
"version": "1.17.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-function",
"version": "1.17.18",
"version": "1.17.16",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-mail",
"version": "1.17.18",
"version": "1.17.16",
"dependencies": {
"@jsx-email/all": "2.2.3",
"@jsx-email/cli": "1.4.3",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/console-support",
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.16",
"name": "@opencode-ai/core",
"type": "module",
"license": "MIT",
-5
View File
@@ -40,8 +40,6 @@ import { Snapshot } from "../../snapshot"
import { makeLocationNode } from "../../effect/app-node"
import { llmClient } from "../../effect/app-node-platform"
const MAX_OUTPUT_TOKENS = 32_000
/**
* Runs one durable coding-agent Session until it settles.
*
@@ -212,9 +210,6 @@ const layer = Layer.effect(
.map(SystemPart.make),
messages: [...toLLMMessages(context, model), ...(isLastStep ? [Message.assistant(MAX_STEPS_PROMPT)] : [])],
tools: toolMaterialization?.definitions ?? [],
generation: {
maxTokens: Math.min(model.route.defaults.limits?.output ?? 0, MAX_OUTPUT_TOKENS) || MAX_OUTPUT_TOKENS,
},
toolChoice: isLastStep ? "none" : undefined,
})
if (yield* compaction.compactIfNeeded({ sessionID: session.id, entries, model, request }))
-21
View File
@@ -108,11 +108,6 @@ const recoveryModel = Model.make({
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 20_000, output: 1_000 } }),
})
const fullContextOutputModel = Model.make({
id: "full-context-output",
provider: "fake",
route: OpenAIChat.route.with({ limits: { context: 500_000, output: 500_000 } }),
})
const authorizations: Tool.Context[] = []
const executions: string[] = []
const permission = Layer.succeed(
@@ -660,22 +655,6 @@ describe("SessionRunnerLLM", () => {
}),
)
it.effect("caps output when the catalog output limit consumes the full context window", () =>
Effect.gen(function* () {
yield* setup
const session = yield* SessionV2.Service
currentModel = fullContextOutputModel
yield* session.prompt({ sessionID, prompt: Prompt.make({ text: "hi" }), resume: false })
requests.length = 0
response = []
yield* session.resume(sessionID)
expect(requests).toHaveLength(1)
expect(requests[0]?.generation).toEqual({ maxTokens: 32_000 })
}),
)
it.effect("retries the first provider turn after system context becomes available", () =>
Effect.gen(function* () {
yield* setup
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "@opencode-ai/desktop",
"private": true,
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"homepage": "https://opencode.ai",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.16",
"name": "@opencode-ai/effect-drizzle-sqlite",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.16",
"name": "@opencode-ai/effect-sqlite-node",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/enterprise",
"version": "1.17.18",
"version": "1.17.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/function",
"version": "1.17.18",
"version": "1.17.16",
"$schema": "https://json.schemastore.org/package.json",
"private": true,
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.16",
"name": "@opencode-ai/http-recorder",
"description": "Record and replay Effect HTTP client traffic with deterministic cassettes",
"type": "module",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.16",
"name": "@opencode-ai/llm",
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"$schema": "https://json.schemastore.org/package.json",
"version": "1.17.18",
"version": "1.17.16",
"name": "opencode",
"type": "module",
"license": "MIT",
+1 -5
View File
@@ -207,11 +207,7 @@ export const TuiThreadCommand = cmd({
}
const cwd = Filesystem.resolve(process.cwd())
const worker = new Worker(file, {
env: Object.fromEntries(
Object.entries(process.env).filter((entry): entry is [string, string] => entry[1] !== undefined),
),
})
const worker = new Worker(file)
const client = Rpc.client<typeof rpc>(worker)
const reload = () => {
client.call("reload", undefined).catch(() => {})
@@ -99,7 +99,7 @@ function build(key: string, remote: SelectableItem, url: string, prev?: Model):
: undefined
const prices = remote.billing?.token_prices
// Copilot prices are AIC per billing batch; OpenCode stores USD per million tokens.
const usdPerMillion = prices && prices.batch_size > 0 ? 10_000 / prices.batch_size : 0
const usdPerMillion = prices ? 10_000 / prices.batch_size : 0
const model: CopilotModel = {
id: key,
+5 -18
View File
@@ -207,13 +207,6 @@ function custom(dep: CustomDep): Record<string, CustomLoader> {
},
options: { headerTimeout: OPENAI_HEADER_TIMEOUT_DEFAULT },
}),
meta: () =>
Effect.succeed({
autoload: false,
async getModel(sdk: any, modelID: string, _options?: Record<string, any>) {
return sdk.responses(modelID)
},
}),
xai: () =>
Effect.succeed({
autoload: false,
@@ -1071,17 +1064,11 @@ export type ConfigProvidersResult = Types.DeepMutable<Schema.Schema.Type<typeof
export function toPublicInfo(provider: Info): Info {
return JSON.parse(
JSON.stringify(
{
...provider,
models: Object.fromEntries(Object.entries(provider.models).filter(([, model]) => Schema.is(Model)(model))),
},
(_, value) => {
if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined
if (typeof value === "bigint") return value.toString()
return value
},
),
JSON.stringify(provider, (_, value) => {
if (typeof value === "function" || typeof value === "symbol" || value === undefined) return undefined
if (typeof value === "bigint") return value.toString()
return value
}),
)
}
+1 -22
View File
@@ -884,18 +884,6 @@ export function variants(model: Provider.Model): Record<string, Record<string, a
)
case "@ai-sdk/amazon-bedrock/mantle":
case "@ai-sdk/openai": {
if (model.providerID === "meta") {
return Object.fromEntries(
OPENAI_EFFORTS.map((effort) => [
effort,
{
reasoningEffort: effort,
reasoningSummary: "auto",
include: INCLUDE_ENCRYPTED_REASONING,
},
]),
)
}
// https://v5.ai-sdk.dev/providers/ai-sdk-providers/openai
const efforts = openaiReasoningEfforts(model.api.id, model.release_date)
return Object.fromEntries(
@@ -1140,20 +1128,11 @@ export function options(input: {
if (
input.providerOptions?.setCacheKey !== false &&
(input.model.providerID === "openai" ||
input.model.api.npm === "@ai-sdk/openai" ||
input.model.api.npm === "@ai-sdk/xai" ||
input.providerOptions?.setCacheKey)
(input.model.providerID === "openai" || input.model.api.npm === "@ai-sdk/xai" || input.providerOptions?.setCacheKey)
) {
result["promptCacheKey"] = input.sessionID
}
if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") {
result["reasoningEffort"] = "high"
result["reasoningSummary"] = "auto"
result["include"] = INCLUDE_ENCRYPTED_REASONING
}
if (input.model.api.npm === "@ai-sdk/google" || input.model.api.npm === "@ai-sdk/google-vertex") {
if (input.model.capabilities.reasoning) {
result["thinkingConfig"] = {
@@ -1,76 +0,0 @@
You are OpenCode, the best coding agent on the planet.
You are based on a large language model trained by Meta MSL named Muse Spark.
When asked who you are, identify yourself as OpenCode powered by Meta Muse Spark by name.
You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user.
IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files.
If the user asks for help or wants to give feedback inform them of the following:
- ctrl+p to list available actions
- To give feedback, users should report the issue at
https://github.com/anomalyco/opencode
When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs
# Tone and style
- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked.
- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification.
- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session.
- NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files.
# Professional objectivity
Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs.
# Task Management
You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress.
These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable.
It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed.
Examples:
<example>
user: Run the build and fix any type errors
assistant: I'm going to use the TodoWrite tool to write the following items to the todo list:
- Run the build
- Fix any type errors
I'm now going to run the build using Bash.
Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list.
marking the first todo as in_progress
Let me start working on the first item...
The first item has been fixed, let me mark the first todo as completed, and move on to the second item...
..
..
</example>
In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors.
<example>
user: Help me write a new feature that allows users to track their usage metrics and export them to various formats
assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task.
Adding the following todos to the todo list:
1. Research existing metrics tracking in the codebase
2. Design the metrics collection system
3. Implement core metrics tracking functionality
4. Create export functionality for different formats
Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that.
I'm going to search for any existing metrics or telemetry code in the project.
I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned...
[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go]
</example>
# Doing tasks
The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended:
- Use the TodoWrite tool to plan the task if required
- Tool results and user messages may include <system-reminder> tags. <system-reminder> tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear.
# Tool usage policy
- When doing file search, prefer to use the Task tool in order to reduce context usage.
- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description.
- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response.
- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls.
- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls.
- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead.
- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly.
<example>
user: Where are errors from the client handled?
assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly]
</example>
<example>
user: What is the codebase structure?
assistant: [Uses the Task tool]
</example>
IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation.
# Code References
When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location.
<example>
user: Where are errors from the client handled?
assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712.
</example>
-2
View File
@@ -9,7 +9,6 @@ import PROMPT_BEAST from "./prompt/beast.txt"
import PROMPT_GEMINI from "./prompt/gemini.txt"
import PROMPT_GPT from "./prompt/gpt.txt"
import PROMPT_KIMI from "./prompt/kimi.txt"
import PROMPT_META from "./prompt/meta.txt"
import PROMPT_CODEX from "./prompt/codex.txt"
import PROMPT_TRINITY from "./prompt/trinity.txt"
@@ -25,7 +24,6 @@ import { MCP } from "@/mcp"
import { PermissionV1 } from "@opencode-ai/core/v1/permission"
export function provider(model: Provider.Model) {
if (model.api.id.includes("muse-spark")) return [PROMPT_META]
if (model.api.id.includes("gpt-4") || model.api.id.includes("o1") || model.api.id.includes("o3"))
return [PROMPT_BEAST]
if (model.api.id.includes("gpt")) {
@@ -16,12 +16,6 @@ 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")
@@ -187,60 +187,6 @@ test("converts Copilot AIC token prices to USD per million tokens", async () =>
expect(models["ignored-non-chat-record"]).toBeUndefined()
})
test("uses zero cost when Copilot reports a zero billing batch size", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
new Response(
JSON.stringify({
data: [
{
model_picker_enabled: true,
id: "mercury-alpha",
name: "Mercury Alpha",
version: "mercury-alpha-2026-07-09",
billing: {
token_prices: {
batch_size: 0,
default: {
input_price: 0,
output_price: 0,
cache_price: 0,
},
},
},
capabilities: {
family: "mercury",
limits: {
max_context_window_tokens: 128000,
max_output_tokens: 16384,
max_prompt_tokens: 128000,
},
supports: {
streaming: true,
tool_calls: true,
},
},
},
],
}),
{ status: 200 },
),
),
) as unknown as typeof fetch
const model = (await CopilotModels.get("https://api.githubcopilot.com")).models["mercury-alpha"]
expect(model.cost).toEqual({
input: 0,
output: 0,
cache: {
read: 0,
write: 0,
},
})
expect(JSON.stringify(model)).not.toContain("null")
})
test("records Copilot advertised responses endpoint for non-GPT model IDs", async () => {
globalThis.fetch = mock(() =>
Promise.resolve(
@@ -1426,32 +1426,6 @@ test("models.dev normalization fills required response fields", () => {
expect(model.release_date).toBe("")
})
test("public provider info omits invalid models", () => {
const provider = Provider.fromModelsDevProvider({
id: "test",
name: "Test",
env: [],
models: {
valid: {
id: "valid",
name: "Valid",
cost: { input: 1, output: 1 },
limit: { context: 128_000, output: 16_000 },
},
},
} as unknown as ModelsDev.Provider)
provider.models.invalid = {
...provider.models.valid,
id: ModelV2.ID.make("invalid"),
cost: { ...provider.models.valid.cost, input: Number.NaN },
}
const result = Provider.toPublicInfo(provider)
expect(result.models.valid).toBeDefined()
expect(result.models.invalid).toBeUndefined()
})
it.instance("model variants are generated for reasoning models", () =>
Effect.gen(function* () {
yield* set("ANTHROPIC_API_KEY", "test-api-key")
@@ -1,11 +1,10 @@
import { describe, expect, test } from "bun:test"
import { describe, expect } from "bun:test"
import { LayerNode } from "@opencode-ai/core/effect/layer-node"
import { Effect, Layer } from "effect"
import type { Agent } from "../../src/agent/agent"
import { NamedError } from "@opencode-ai/core/util/error"
import { Skill } from "../../src/skill"
import { Permission } from "../../src/permission"
import type { Provider } from "../../src/provider/provider"
import { SystemPrompt } from "../../src/session/system"
import { MCP } from "../../src/mcp"
import { testEffect } from "../lib/effect"
@@ -84,12 +83,6 @@ const it = testEffect(
)
describe("session.system", () => {
test("selects the Meta prompt for Muse Spark model IDs", () => {
expect(SystemPrompt.provider({ api: { id: "meta/muse-spark-preview" } } as Provider.Model)[0]).toContain(
"Meta Muse Spark",
)
})
it.effect("skills output is sorted by name and stable across calls", () =>
Effect.gen(function* () {
const prompt = yield* SystemPrompt.Service
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/plugin",
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/sdk",
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/server",
"version": "1.17.18",
"version": "1.17.16",
"private": true,
"type": "module",
"license": "MIT",
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/session-ui",
"version": "1.17.18",
"version": "1.17.16",
"private": true,
"type": "module",
"license": "MIT",
@@ -185,14 +185,6 @@
background-color 0.15s ease,
color 0.15s ease;
[data-component="task-tool-surface"] {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1 1 auto;
}
[data-slot="basic-tool-tool-info-structured"] {
flex: 1 1 auto;
min-width: 0;
@@ -258,30 +250,16 @@
body[data-new-layout] {
[data-component="task-tool-card"] {
gap: 8px;
padding: 0;
border-radius: 0;
padding: 8px 12px 8px 10px;
border-radius: 8px;
border: 0;
border-color: transparent;
background: transparent;
box-shadow: none;
[data-component="task-tool-surface"] {
padding: 8px 12px;
border-radius: 8px;
background: var(--v2-background-bg-layer-01);
}
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
box-shadow: inset 0 0 0 0.5px var(--task-agent-border, var(--v2-border-border-base));
[data-component="task-tool-spinner"],
[data-component="task-tool-icon"] {
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
[data-component="icon"] {
color: var(--task-agent-color, var(--v2-icon-icon-base));
}
[data-component="task-tool-title"] {
color: var(--task-agent-color, var(--text-strong));
}
[data-component="task-tool-title"] {
@@ -292,7 +270,6 @@ body[data-new-layout] {
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
flex: none;
flex-grow: 0;
}
@@ -304,7 +281,7 @@ body[data-new-layout] {
font-size: 13px;
line-height: 20px;
letter-spacing: -0.04px;
color: var(--v2-text-text-base);
color: light-dark(var(--v2-text-text-base), #fafafa);
font-variation-settings: "slnt" 0;
flex: none;
flex-grow: 0;
@@ -317,11 +294,7 @@ body[data-new-layout] {
&:hover,
&:focus-visible {
border-color: transparent;
background: transparent;
[data-component="task-tool-surface"] {
background: var(--v2-background-bg-layer-01);
}
background: var(--task-agent-background, light-dark(#fafafa, rgba(250, 250, 250, 0.15)));
}
}
}
@@ -371,12 +371,22 @@ const agentTones: Record<string, string> = {
plan: "var(--icon-agent-plan-base)",
}
const v2AgentTones: Record<string, string> = {
build: "var(--v2-agent-build-solid)",
explore: "var(--v2-agent-explore-solid)",
plan: "var(--v2-agent-plan-solid)",
review: "var(--v2-agent-review-solid)",
writer: "var(--v2-agent-writer-solid)",
const v2AgentTones: Record<string, { color: string; border: string; background: string }> = {
build: {
color: "var(--v2-agent-build-solid)",
border: "var(--v2-agent-build-border)",
background: "var(--v2-agent-build-background)",
},
explore: {
color: "var(--v2-agent-explore-solid)",
border: "var(--v2-agent-explore-border)",
background: "var(--v2-agent-explore-background)",
},
plan: {
color: "var(--v2-agent-plan-solid)",
border: "var(--v2-agent-plan-border)",
background: "var(--v2-agent-plan-background)",
},
}
const agentThemeColors: Record<string, string> = {
@@ -423,17 +433,19 @@ function tone(name: string) {
function taskAgent(
raw: unknown,
list?: readonly { name: string; color?: string }[],
): { name?: string; color?: string; v2Color?: string } {
): { name?: string; color?: string; v2Color?: string; border?: string; background?: string } {
if (typeof raw !== "string" || !raw) return {}
const key = raw.toLowerCase()
const item = list?.find((entry) => entry.name === raw || entry.name.toLowerCase() === key)
const v2Tone = item?.color ? undefined : v2AgentTones[key]
const color = agentColor(item?.color, agentThemeColors) ?? agentTones[key] ?? tone(key)
const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone ?? color
const v2Color = agentColor(item?.color, v2AgentThemeColors) ?? v2Tone?.color ?? color
return {
name: item?.name ?? `${raw[0]!.toUpperCase()}${raw.slice(1)}`,
color,
v2Color,
border: v2Tone?.border ?? `color-mix(in srgb, ${v2Color} 48%, transparent)`,
background: v2Tone?.background ?? `color-mix(in srgb, ${v2Color} 12%, transparent)`,
}
}
@@ -1925,6 +1937,8 @@ ToolRegistry.register({
const title = createMemo(() => agent().name ?? i18n.t("ui.tool.agent.default"))
const tone = createMemo(() => agent().color)
const v2Tone = createMemo(() => agent().v2Color)
const border = createMemo(() => agent().border)
const background = createMemo(() => agent().background)
const subtitle = createMemo(() => {
const value =
typeof props.input.description === "string" && props.input.description
@@ -1969,34 +1983,25 @@ ToolRegistry.register({
style={{
"--task-agent-color": v2Tone(),
"--task-agent-legacy-color": tone(),
"--task-agent-border": border(),
"--task-agent-background": background(),
}}
>
<div data-component="task-tool-surface">
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<Show
when={running()}
fallback={
<Show when={newLayout()}>
<span data-component="task-tool-icon">
<Icon name="subagent" size="small" />
</span>
</Show>
}
>
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Show when={newLayout()} fallback={<Spinner />}>
<SessionProgressIndicatorV2
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
/>
</Show>
</span>
</Show>
<span data-component="task-tool-title">{title()}</span>
<Show when={subtitle()}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>
</div>
<div data-slot="basic-tool-tool-info-structured">
<div data-slot="basic-tool-tool-info-main">
<Show when={running()}>
<span data-component="task-tool-spinner" style={{ color: tone() ?? "var(--icon-interactive-base)" }}>
<Show when={newLayout()} fallback={<Spinner />}>
<SessionProgressIndicatorV2
style={{ color: v2Tone() ?? "light-dark(var(--v2-text-text-base), #ffffff)" }}
/>
</Show>
</span>
</Show>
<span data-component="task-tool-title">{title()}</span>
<Show when={subtitle()}>
<span data-slot="basic-tool-tool-subtitle">{subtitle()}</span>
</Show>
</div>
</div>
<Show when={clickable()}>
@@ -359,34 +359,6 @@ const TOOL_SAMPLES = {
title: "Agent (Explore)",
metadata: { sessionId: "sub-session-1" },
},
"task build": {
tool: "task",
input: { description: "Implement the settings page", subagent_type: "build", prompt: "Build it" },
output: "Implemented the settings page.",
title: "Agent (Build)",
metadata: { sessionId: "sub-session-2" },
},
"task plan": {
tool: "task",
input: { description: "Plan the migration strategy", subagent_type: "plan", prompt: "Plan it" },
output: "Drafted a 4-step migration plan.",
title: "Agent (Plan)",
metadata: { sessionId: "sub-session-3" },
},
"task themed": {
tool: "task",
input: { description: "Review the diff for regressions", subagent_type: "review", prompt: "Review it" },
output: "No regressions found.",
title: "Agent (Review)",
metadata: { sessionId: "sub-session-4" },
},
"task fallback": {
tool: "task",
input: { description: "Write release notes", subagent_type: "writer", prompt: "Write them" },
output: "Release notes drafted.",
title: "Agent (Writer)",
metadata: { sessionId: "sub-session-5" },
},
webfetch: {
tool: "webfetch",
input: { url: "https://solidjs.com/docs/latest/api" },
@@ -1244,7 +1216,6 @@ function Playground() {
const data = createMemo(() => ({
session: [session()],
agent: [{ name: "build" }, { name: "plan" }, { name: "explore" }, { name: "review" }],
session_status: {},
session_diff: {},
message: { [session().id]: state.messages },
@@ -1371,19 +1342,6 @@ function Playground() {
])
}
const addAgentTasksTurn = () => {
addFullTurn("Run subagents with every agent type", [
toolPart(TOOL_SAMPLES.task),
toolPart(TOOL_SAMPLES["task build"]),
toolPart(TOOL_SAMPLES["task plan"]),
toolPart(TOOL_SAMPLES["task themed"]),
toolPart(TOOL_SAMPLES["task fallback"]),
toolPart(TOOL_SAMPLES.task, "running"),
toolPart(TOOL_SAMPLES["task build"], "running"),
toolPart(TOOL_SAMPLES["task plan"], "running"),
])
}
const addKitchenSink = () => {
// User message variants
addUser("short")
@@ -1769,9 +1727,6 @@ function Playground() {
<button style={btnStyle} onClick={addReasoningFullTurn}>
full turn
</button>
<button style={btnStyle} onClick={addAgentTasksTurn}>
agent tasks
</button>
<button style={btnAccent} onClick={addKitchenSink}>
kitchen sink
</button>
@@ -2011,7 +1966,7 @@ function Playground() {
{/* Main area: timeline preview */}
<div
ref={previewRef!}
style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--v2-background-bg-base)" }}
style={{ flex: "1", overflow: "auto", "min-width": "0", "background-color": "var(--background-stronger)" }}
>
<DataProvider data={data()} directory="/project">
<FileComponentProvider component={FileStub}>
@@ -4,7 +4,7 @@
height: 100%;
min-height: 0;
overflow: hidden;
background: var(--v2-background-bg-base);
background: var(--background-stronger, 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(--v2-background-bg-base);
background: var(--background-stronger, var(--v2-background-bg-base));
}
[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@opencode-ai/slack",
"version": "1.17.18",
"version": "1.17.16",
"type": "module",
"license": "MIT",
"scripts": {
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://json.schemastore.org/package.json",
"name": "@opencode-ai/stats-app",
"version": "1.17.18",
"version": "1.17.16",
"private": true,
"type": "module",
"license": "MIT",
File diff suppressed because it is too large Load Diff
@@ -1,286 +0,0 @@
import { catalogSlug, findModelCatalogEntry, type ModelCatalog, type ModelCatalogEntry } from "../routes/model-catalog"
type ComparisonFamilyDefinition = {
slug: string
name: string
lab: string
prefixes: string[]
aliases?: string[]
preferredFamilies?: string[]
}
export type ResolvedComparisonFamily = ComparisonFamilyDefinition & {
model: ModelCatalogEntry
}
export const comparisonFamilies: ComparisonFamilyDefinition[] = [
{
slug: "gpt",
name: "GPT",
lab: "openai",
prefixes: ["gpt", "o"],
aliases: ["openai"],
preferredFamilies: ["gpt", "o"],
},
{
slug: "claude",
name: "Claude",
lab: "anthropic",
prefixes: ["claude"],
aliases: ["anthropic"],
preferredFamilies: ["claude-sonnet", "claude-opus"],
},
{
slug: "gemini",
name: "Gemini",
lab: "google",
prefixes: ["gemini"],
aliases: ["google"],
preferredFamilies: ["gemini-pro", "gemini-flash", "gemini"],
},
{
slug: "deepseek",
name: "DeepSeek",
lab: "deepseek",
prefixes: ["deepseek"],
preferredFamilies: ["deepseek-thinking", "deepseek"],
},
{
slug: "qwen",
name: "Qwen",
lab: "alibaba",
prefixes: ["qwen"],
aliases: ["alibaba"],
preferredFamilies: ["qwen"],
},
{
slug: "glm",
name: "GLM",
lab: "zhipuai",
prefixes: ["glm"],
aliases: ["zhipu", "zhipuai", "zai"],
preferredFamilies: ["glm"],
},
{
slug: "kimi",
name: "Kimi",
lab: "moonshotai",
prefixes: ["kimi"],
aliases: ["moonshot", "moonshotai"],
preferredFamilies: ["kimi-k2", "kimi-thinking"],
},
{
slug: "minimax",
name: "MiniMax",
lab: "minimax",
prefixes: ["minimax"],
},
{
slug: "grok",
name: "Grok",
lab: "xai",
prefixes: ["grok"],
aliases: ["xai"],
preferredFamilies: ["grok"],
},
{
slug: "mistral",
name: "Mistral",
lab: "mistral",
prefixes: ["mistral", "magistral", "devstral", "codestral"],
preferredFamilies: ["mistral-large", "mistral-medium", "mistral-small"],
},
{
slug: "llama",
name: "Llama",
lab: "meta",
prefixes: ["llama"],
aliases: ["meta"],
},
{
slug: "nemotron",
name: "Nemotron",
lab: "nvidia",
prefixes: ["nemotron", "llama-nemotron"],
aliases: ["nvidia"],
},
{
slug: "mimo",
name: "MiMo",
lab: "xiaomi",
prefixes: ["mimo"],
aliases: ["xiaomi"],
},
{
slug: "command",
name: "Command",
lab: "cohere",
prefixes: ["command"],
aliases: ["cohere"],
preferredFamilies: ["command-a", "command-r"],
},
{
slug: "sonar",
name: "Sonar",
lab: "perplexity",
prefixes: ["sonar"],
aliases: ["perplexity"],
preferredFamilies: ["sonar-pro", "sonar-reasoning", "sonar"],
},
{
slug: "longcat",
name: "LongCat",
lab: "meituan",
prefixes: ["longcat"],
aliases: ["meituan"],
},
{
slug: "step",
name: "Step",
lab: "stepfun",
prefixes: ["step"],
aliases: ["stepfun"],
},
{
slug: "mai",
name: "MAI",
lab: "microsoft",
prefixes: ["mai"],
aliases: ["microsoft"],
},
]
export function resolveComparisonFamily(catalog: ModelCatalog, value: string) {
const family = findComparisonFamily(value)
if (!family) return undefined
const model = comparisonFamilyCandidates(catalog, family.slug)[0]
if (!model) return undefined
return { ...family, model } satisfies ResolvedComparisonFamily
}
export function findComparisonFamily(value: string) {
const slug = catalogSlug(value)
return comparisonFamilies.find((family) => family.slug === slug || family.aliases?.includes(slug))
}
export function comparisonFamilyCandidates(catalog: ModelCatalog, value: string) {
const family = findComparisonFamily(value)
if (!family) return []
const matches = catalog.models
.filter((model) => model.lab === family.lab && isFamilyModel(model, family) && isGeneralComparisonModel(model))
.toSorted((a, b) => comparisonFamilyModelSort(a, b, family))
return matches.filter((model) => !isDuplicateAliasModel(model, matches))
}
export function comparisonSitemapModels(
catalog: ModelCatalog,
leaderboard: { model: string; provider: string }[] = [],
) {
return uniqueModels([
...comparisonFamilies.flatMap((family) => comparisonFamilyCandidates(catalog, family.slug).slice(0, 2)),
...leaderboard.flatMap((entry) => {
const model =
findModelCatalogEntry(catalog, entry.model, entry.provider) ?? findModelCatalogEntry(catalog, entry.model)
return model && isGeneralComparisonModel(model) ? [model] : []
}),
]).toSorted((a, b) => a.id.localeCompare(b.id))
}
export function canonicalModelComparisonPath(first: ModelCatalogEntry, second: ModelCatalogEntry) {
const models = [first, second].toSorted((a, b) => a.id.localeCompare(b.id))
return `/data/compare/${models[0].lab}/${models[0].slug}/${models[1].lab}/${models[1].slug}`
}
export function canonicalFamilyComparisonPath(first: ResolvedComparisonFamily, second: ResolvedComparisonFamily) {
const families = [first, second].toSorted((a, b) => a.slug.localeCompare(b.slug))
return `/data/compare/${families[0].slug}/${families[1].slug}`
}
export function latestFamilyComparisonPath(catalog: ModelCatalog, first: ModelCatalogEntry, second: ModelCatalogEntry) {
const firstFamily = comparisonFamilyForModel(catalog, first)
const secondFamily = comparisonFamilyForModel(catalog, second)
if (!firstFamily || !secondFamily || firstFamily.slug === secondFamily.slug) return undefined
if (firstFamily.model.id !== first.id || secondFamily.model.id !== second.id) return undefined
return canonicalFamilyComparisonPath(firstFamily, secondFamily)
}
export function comparisonFamilyForModel(catalog: ModelCatalog, model: ModelCatalogEntry) {
const family = comparisonFamilies.find(
(candidate) => candidate.lab === model.lab && isFamilyModel(model, candidate) && isGeneralComparisonModel(model),
)
if (!family) return undefined
const latest = comparisonFamilyCandidates(catalog, family.slug)[0]
if (!latest) return undefined
return { ...family, model: latest } satisfies ResolvedComparisonFamily
}
function isFamilyModel(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
const values = [model.family, model.slug, model.name]
.filter((value): value is string => Boolean(value))
.map(catalogSlug)
return family.prefixes.some((prefix) => values.some((value) => value === prefix || value.startsWith(`${prefix}-`)))
}
function isGeneralComparisonModel(model: ModelCatalogEntry) {
const input = model.modalities.input.map(catalogSlug)
const output = model.modalities.output.map(catalogSlug)
if (!input.includes("text") || !output.includes("text")) return false
return !/(?:^|-)(?:audio|embedding|guard|image|moderation|omni|rerank|safety|speech|transcribe|tts|vision)(?:-|$)/.test(
model.slug,
)
}
function comparisonFamilyModelSort(
first: ModelCatalogEntry,
second: ModelCatalogEntry,
family: ComparisonFamilyDefinition,
) {
return (
displayDateTime(second.releaseDate ?? second.lastUpdated) -
displayDateTime(first.releaseDate ?? first.lastUpdated) ||
preferredFamilyIndex(first, family) - preferredFamilyIndex(second, family) ||
modelVariantPenalty(first) - modelVariantPenalty(second) ||
first.slug.length - second.slug.length ||
first.name.localeCompare(second.name)
)
}
function preferredFamilyIndex(model: ModelCatalogEntry, family: ComparisonFamilyDefinition) {
const index = family.preferredFamilies?.indexOf(catalogSlug(model.family ?? "")) ?? -1
return index === -1 ? (family.preferredFamilies?.length ?? 0) : index
}
function modelVariantPenalty(model: ModelCatalogEntry) {
return /(?:highspeed|latest|preview|turbo|ultraspeed)/.test(model.slug) ? 1 : 0
}
function isDuplicateAliasModel(model: ModelCatalogEntry, models: ModelCatalogEntry[]) {
if (!/(?:-latest|-highspeed|-ultraspeed)$/.test(model.slug)) return false
return models.some(
(candidate) =>
candidate.id !== model.id &&
candidate.releaseDate === model.releaseDate &&
candidate.family === model.family &&
!/(?:-latest|-highspeed|-ultraspeed)$/.test(candidate.slug),
)
}
function uniqueModels(models: ModelCatalogEntry[]) {
return models.reduce<{ ids: Set<string>; models: ModelCatalogEntry[] }>(
(result, model) => {
if (result.ids.has(model.id)) return result
result.ids.add(model.id)
result.models.push(model)
return result
},
{ ids: new Set(), models: [] },
).models
}
function displayDateTime(value: string | undefined) {
if (!value) return 0
const date = new Date(value)
if (!Number.isNaN(date.getTime())) return date.getTime()
const year = Number(value.match(/\d{4}/)?.[0] ?? 0)
return Number.isFinite(year) ? year : 0
}
+21 -103
View File
@@ -1,4 +1,3 @@
import { ProviderIcon } from "@opencode-ai/ui/provider-icon"
import { For, Show } from "solid-js"
import { catalogSlug, formatCatalogLabName, type ModelCatalogEntry } from "./model-catalog"
@@ -14,7 +13,6 @@ export type ComparisonPair = {
first: ComparisonModelRef
second: ComparisonModelRef
detail: string
description?: string
}
export function modelRefFromCatalog(entry: ModelCatalogEntry): ComparisonModelRef {
@@ -32,11 +30,6 @@ export function comparisonHref(first: ComparisonModelRef, second: ComparisonMode
)}/${catalogSlug(second.slug)}`
}
export function canonicalComparisonHref(first: ComparisonModelRef, second: ComparisonModelRef) {
const models = [first, second].toSorted((a, b) => modelKey(a).localeCompare(modelKey(b)))
return comparisonHref(models[0], models[1])
}
export function uniqueComparisonPairs(pairs: ComparisonPair[]) {
return pairs.reduce<{ keys: Set<string>; pairs: ComparisonPair[] }>(
(result, pair) => {
@@ -55,25 +48,34 @@ export function ComparisonCardsSection(props: {
title?: string
description?: string
compact?: boolean
variant?: "panel" | "featured"
}) {
const featured = () => props.variant === "featured"
const pairs = () => (featured() ? props.pairs.slice(0, 4) : props.pairs)
return (
<Show when={props.pairs.length > 0}>
<section
id="model-comparison"
data-section={featured() ? "compare-home-related" : "model-panel"}
data-variant={!featured() && props.compact ? "compact" : undefined}
>
<section id="model-comparison" data-section="model-panel" data-variant={props.compact ? "compact" : undefined}>
<p data-slot="section-title">
<strong>{props.title ?? "Model Comparisons"}.</strong>{" "}
<span>{props.description ?? "Compare usage, cost, limits, and features."}</span>
</p>
<div data-component={featured() ? "compare-home-card-grid" : "comparison-card-grid"}>
<For each={pairs()}>
{(pair) => (featured() ? <FeaturedComparisonCard pair={pair} /> : <ComparisonPanelCard pair={pair} />)}
<div data-component="comparison-card-grid">
<For each={props.pairs}>
{(pair) => (
<a data-component="comparison-card" href={comparisonHref(pair.first, pair.second)}>
<span>{pair.detail}</span>
<strong>
{pair.first.name} <em>vs</em> {pair.second.name}
</strong>
<p>
<b>{pair.first.labName ?? formatCatalogLabName(pair.first.lab)}</b>
<i />
<b>{pair.second.labName ?? formatCatalogLabName(pair.second.lab)}</b>
</p>
<Show when={pair.first.metric || pair.second.metric}>
<small>
{pair.first.metric ?? "Listed"} / {pair.second.metric ?? "Listed"}
</small>
</Show>
</a>
)}
</For>
</div>
</section>
@@ -81,90 +83,6 @@ export function ComparisonCardsSection(props: {
)
}
function FeaturedComparisonCard(props: { pair: ComparisonPair }) {
return (
<a
data-component="compare-home-card"
href={canonicalComparisonHref(props.pair.first, props.pair.second)}
aria-label={`${props.pair.detail}: ${props.pair.first.name} vs ${props.pair.second.name}`}
>
<span data-slot="compare-home-card-head">
<span>
<strong>{props.pair.detail}</strong>
<em>{props.pair.description ?? `${props.pair.first.name} vs ${props.pair.second.name}`}</em>
</span>
<ComparisonCardIcon />
</span>
<span data-slot="compare-home-card-divider" aria-hidden="true" />
<span data-slot="compare-home-card-models">
<span>{props.pair.first.name}</span>
<i aria-hidden="true">·</i>
<span>{props.pair.second.name}</span>
</span>
<span data-slot="compare-home-card-avatars" aria-hidden="true">
<ComparisonLabLogo model={props.pair.first} />
<ComparisonLabLogo model={props.pair.second} />
</span>
</a>
)
}
function ComparisonCardIcon() {
return (
<b aria-hidden="true">
<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg">
<path
d="M12.9509 12.9884L14.4069 14.4444M2.44431 2.44434H6.44431V6.44434H2.44431V2.44434ZM2.44431 9.55542H6.44431V13.5554H2.44431V9.55542ZM9.55539 2.44434H13.5554V6.44434H9.55539V2.44434ZM13.5554 11.5554C13.5554 12.66 12.66 13.5554 11.5554 13.5554C10.4508 13.5554 9.55539 12.66 9.55539 11.5554C9.55539 10.4509 10.4508 9.55542 11.5554 9.55542C12.66 9.55542 13.5554 10.4509 13.5554 11.5554Z"
stroke="#808080"
/>
</svg>
</b>
)
}
function ComparisonPanelCard(props: { pair: ComparisonPair }) {
return (
<a data-component="comparison-card" href={canonicalComparisonHref(props.pair.first, props.pair.second)}>
<span>{props.pair.detail}</span>
<strong>
{props.pair.first.name} <em>vs</em> {props.pair.second.name}
</strong>
<p>
<b>{props.pair.first.labName ?? formatCatalogLabName(props.pair.first.lab)}</b>
<i />
<b>{props.pair.second.labName ?? formatCatalogLabName(props.pair.second.lab)}</b>
</p>
<Show when={props.pair.first.metric || props.pair.second.metric}>
<small>
{props.pair.first.metric ?? "Listed"} / {props.pair.second.metric ?? "Listed"}
</small>
</Show>
</a>
)
}
function ComparisonLabLogo(props: { model: ComparisonModelRef }) {
const iconId = () => providerIconId(props.model.lab)
return (
<span
data-slot="compare-home-avatar"
data-lab={iconId()}
data-size="small"
aria-label={props.model.labName ?? formatCatalogLabName(props.model.lab)}
>
<ProviderIcon aria-hidden="true" id={iconId()} />
</span>
)
}
function modelKey(model: ComparisonModelRef) {
return `${catalogSlug(model.lab)}/${catalogSlug(model.slug)}`
}
function providerIconId(provider: string) {
const id = provider.toLowerCase().replace(/[^a-z0-9]+/g, "")
if (id === "moonshot") return "moonshotai"
if (id === "zhipu") return "zhipuai"
return id
}
@@ -1,391 +0,0 @@
import { createMemo, createSignal, For, Show, type JSX } from "solid-js"
import type { ModelCatalogBenchmark, ModelCatalogEntry } from "./model-catalog"
const radarRingCount = 5
const radarColors = ["#294bdb", "#159447", "#d24a3b", "#8a4fd2", "#b47400", "#008c95"] as const
const codingBenchmarkPattern = /(swe|aider|code|coding|nl2repo)/
const reasoningBenchmarkPattern = /(gpqa|humanity|last exam|reasoning|aime|hmmt|math|mmlu|mrcr|charxiv|cti realm)/
const toolUseBenchmarkPattern = /(terminal bench|claw eval|tau ?(?:bench|2|3))/
export type ComparisonRadarModel = {
name: string
labName: string
catalog: ModelCatalogEntry | null
}
type ComparisonRadarProps = {
models: readonly ComparisonRadarModel[]
catalogModels: readonly ModelCatalogEntry[]
}
type RadarAxis = {
label: string
description: string
score: (model: ModelCatalogEntry) => number | undefined
}
type RadarPoint = {
x: number
y: number
}
export function ComparisonRadar(props: ComparisonRadarProps) {
const [activeAxis, setActiveAxis] = createSignal<number>()
const axes = createMemo(() => buildRadarAxes(props.catalogModels))
const series = createMemo(() =>
props.models.map((model, index) => ({
name: model.name,
labName: model.labName,
color: radarColors[index % radarColors.length],
scores: axes().map((axis) => (model.catalog ? axis.score(model.catalog) : undefined)),
})),
)
const accessibleDescription = createMemo(() =>
series()
.map(
(model) =>
`${model.name}: ${axes()
.map((axis, index) => `${axis.label} ${formatRadarScore(model.scores[index])}`)
.join(", ")}`,
)
.join(". "),
)
const clearActiveAxis = (index: number) => setActiveAxis((active) => (active === index ? undefined : active))
return (
<section data-section="compare-radar" aria-label="Model capabilities">
<ol data-slot="compare-radar-legend">
<For each={series()}>
{(model) => (
<li>
<i style={{ background: model.color }} aria-hidden="true" />
<span>
<strong>{model.name}</strong>
<small>{model.labName}</small>
</span>
</li>
)}
</For>
</ol>
<div data-slot="compare-radar-chart" role="img" aria-label={accessibleDescription()}>
<div data-slot="compare-radar-plot" aria-hidden="true">
<svg viewBox="0 0 100 100" preserveAspectRatio="xMidYMid meet">
<g data-slot="compare-radar-grid">
<For each={Array.from({ length: radarRingCount })}>
{(_, index) => (
<polygon points={radarPolygonPoints(axes().length, ((index() + 1) / radarRingCount) * 100)} />
)}
</For>
<For each={axes()}>
{(_, index) => {
const point = () => radarPoint(index(), axes().length, 100)
return <line x1="50" y1="50" x2={point().x} y2={point().y} />
}}
</For>
</g>
<For each={series()}>
{(model) => (
<g data-slot="compare-radar-series" style={{ color: model.color }}>
<Show when={radarSeriesPolygon(model.scores)}>
{(points) => <polygon data-slot="compare-radar-area" points={points()} />}
</Show>
<Show when={!radarSeriesPolygon(model.scores)}>
<For each={radarSeriesConnections(model.scores)}>
{(connection) => (
<line
data-slot="compare-radar-line"
x1={connection.start.x}
y1={connection.start.y}
x2={connection.end.x}
y2={connection.end.y}
/>
)}
</For>
</Show>
<For each={model.scores}>
{(score, index) => {
if (score === undefined) return null
const point = () => radarPoint(index(), axes().length, score)
return (
<>
<circle data-slot="compare-radar-point" cx={point().x} cy={point().y} r="0.95" />
<circle
data-slot="compare-radar-point-hit"
cx={point().x}
cy={point().y}
r="3"
onMouseEnter={() => setActiveAxis(index())}
onMouseLeave={() => clearActiveAxis(index())}
/>
</>
)
}}
</For>
</g>
)}
</For>
</svg>
</div>
<For each={axes()}>
{(axis, index) => (
<span
data-slot="compare-radar-axis"
data-active={activeAxis() === index() ? "true" : undefined}
style={radarAxisStyle(index(), axes().length)}
tabIndex="0"
aria-label={`${axis.label}. ${axis.description}`}
onMouseEnter={() => setActiveAxis(index())}
onMouseLeave={() => clearActiveAxis(index())}
onFocus={() => setActiveAxis(index())}
onBlur={() => clearActiveAxis(index())}
onKeyDown={(event) => {
if (event.key === "Escape") event.currentTarget.blur()
}}
>
<span data-slot="compare-radar-axis-label">{axis.label}</span>
</span>
)}
</For>
<Show when={activeAxis() !== undefined}>
<div
data-slot="compare-radar-tooltip"
role="tooltip"
style={radarTooltipStyle(activeAxis() ?? 0, axes().length)}
>
<strong>{axes()[activeAxis() ?? 0]?.label}</strong>
<p>{axes()[activeAxis() ?? 0]?.description}</p>
</div>
</Show>
</div>
<div data-slot="compare-radar-data">
<table>
<caption>Normalized model capability scores</caption>
<thead>
<tr>
<th>Model</th>
<For each={axes()}>{(axis) => <th>{axis.label}</th>}</For>
</tr>
</thead>
<tbody>
<For each={series()}>
{(model) => (
<tr>
<th>{model.name}</th>
<For each={model.scores}>{(score) => <td>{formatRadarScore(score)}</td>}</For>
</tr>
)}
</For>
</tbody>
</table>
</div>
</section>
)
}
function buildRadarAxes(catalogModels: readonly ModelCatalogEntry[]): RadarAxis[] {
const benchmarks = benchmarkScoreGroups(catalogModels)
const toolUseBenchmarks = benchmarkScoreGroups(catalogModels, true)
const costs = catalogModels.flatMap((model) => {
const cost = modelCost(model)
return cost === undefined ? [] : [cost]
})
const contexts = catalogModels.flatMap((model) => (model.limit?.context === undefined ? [] : [model.limit.context]))
const multimodalMaximum = Math.max(...catalogModels.map(multimodalFeatureCount), 0)
// Speed and safety stay out until the catalog exposes comparable values for them.
return [
{
label: "Reasoning",
description: "Ability to solve complex, multi-step problems. Based on reasoning benchmarks when available.",
score: (model) =>
benchmarkPercentile(model, benchmarks, reasoningBenchmarkPattern) ?? (model.reasoning ? 100 : 0),
},
{
label: "Coding",
description: "Performance on software engineering and coding benchmarks.",
score: (model) => benchmarkPercentile(model, benchmarks, codingBenchmarkPattern),
},
{
label: "Cost efficiency",
description: "Relative input and output pricing. Lower-cost models score higher.",
score: (model) => {
const cost = modelCost(model)
if (cost === undefined) return
if (cost === 0) return 100
return percentileScore(cost, costs, "lower")
},
},
{
label: "Context window",
description: "How much input the model can process at once. Larger context windows score higher.",
score: (model) => {
const context = model.limit?.context
if (context === undefined) return
return percentileScore(context, contexts, "higher")
},
},
{
label: "Multimodal",
description: "Support for non-text input and output, including images, audio, and video.",
score: (model) => {
if (multimodalMaximum === 0) return
return (multimodalFeatureCount(model) / multimodalMaximum) * 100
},
},
{
label: "Tool use",
description: "Performance on agent benchmarks including Terminal-Bench, Tau3, and Claw-Eval.",
score: (model) =>
benchmarkPercentile(model, toolUseBenchmarks, toolUseBenchmarkPattern, {
aggregate: "average",
includeHarness: true,
}),
},
]
}
function benchmarkScoreGroups(catalogModels: readonly ModelCatalogEntry[], includeHarness = false) {
return catalogModels.reduce<Map<string, number[]>>((groups, model) => {
model.benchmarks
.reduce<Map<string, number>>((scores, benchmark) => {
const key = benchmarkKey(benchmark, includeHarness)
scores.set(key, Math.max(scores.get(key) ?? -Infinity, benchmark.score))
return scores
}, new Map())
.forEach((score, key) => {
groups.set(key, [...(groups.get(key) ?? []), score])
})
return groups
}, new Map())
}
function benchmarkKey(benchmark: ModelCatalogBenchmark, includeHarness: boolean) {
const name = normalizeBenchmarkName(benchmark.name)
const version = normalizeBenchmarkName(benchmark.version ?? "")
const versioned = version && !name.endsWith(version) ? `${name} ${version}` : name
if (!includeHarness) return versioned
const harness = normalizeBenchmarkName(benchmark.harness ?? benchmark.variant ?? "")
return harness ? `${versioned} | ${harness}` : versioned
}
function benchmarkPercentile(
model: ModelCatalogEntry,
benchmarks: Map<string, number[]>,
pattern: RegExp,
options?: { aggregate?: "average" | "best"; includeHarness?: boolean },
) {
const scores = Object.entries(
model.benchmarks.reduce<Record<string, number>>((result, benchmark) => {
const key = benchmarkKey(benchmark, options?.includeHarness ?? false)
if (!pattern.test(key)) return result
result[key] = Math.max(result[key] ?? -Infinity, benchmark.score)
return result
}, {}),
).flatMap(([key, score]) => {
const values = benchmarks.get(key)
const percentile = values ? percentileScore(score, values, "higher") : undefined
return percentile === undefined ? [] : [percentile]
})
if (scores.length === 0) return
if (options?.aggregate === "average") return scores.reduce((sum, score) => sum + score, 0) / scores.length
// Benchmark coverage varies by model, so additional published results should not lower a model's score.
return Math.max(...scores)
}
function normalizeBenchmarkName(value: string) {
return value
.toLowerCase()
.replace(/\u03c4/g, "tau")
.replace(/\u00b2/g, "2")
.replace(/\u00b3/g, "3")
.replace(/[^a-z0-9]+/g, " ")
.trim()
}
function modelCost(model: ModelCatalogEntry) {
if (!model.cost) return
return model.cost.input + model.cost.output
}
function multimodalFeatureCount(model: ModelCatalogEntry) {
return new Set([
...model.modalities.input.filter((modality) => modality !== "text").map((modality) => `input:${modality}`),
...model.modalities.output.filter((modality) => modality !== "text").map((modality) => `output:${modality}`),
...(model.attachment ? ["attachment"] : []),
]).size
}
function percentileScore(value: number, values: number[], direction: "higher" | "lower") {
const finite = values.filter(Number.isFinite)
if (!Number.isFinite(value) || finite.length < 2) return
const below = finite.filter((candidate) => candidate < value).length
const equal = finite.filter((candidate) => candidate === value).length
const percentile = ((below + (equal - 1) / 2) / (finite.length - 1)) * 100
return direction === "higher" ? percentile : 100 - percentile
}
function radarPoint(index: number, count: number, score: number): RadarPoint {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
const radius = Math.max(0, Math.min(100, score)) / 2
return {
x: roundRadarCoordinate(50 + Math.cos(angle) * radius),
y: roundRadarCoordinate(50 + Math.sin(angle) * radius),
}
}
function radarPolygonPoints(count: number, score: number) {
return Array.from({ length: count })
.map((_, index) => radarPoint(index, count, score))
.map((point) => `${point.x},${point.y}`)
.join(" ")
}
function radarSeriesPolygon(scores: (number | undefined)[]) {
if (scores.some((score) => score === undefined)) return
return scores
.map((score, index) => radarPoint(index, scores.length, score ?? 0))
.map((point) => `${point.x},${point.y}`)
.join(" ")
}
function radarSeriesConnections(scores: (number | undefined)[]) {
return scores.flatMap((score, index) => {
const nextIndex = (index + 1) % scores.length
const next = scores[nextIndex]
if (score === undefined || next === undefined) return []
return [
{
start: radarPoint(index, scores.length, score),
end: radarPoint(nextIndex, scores.length, next),
},
]
})
}
function radarAxisStyle(index: number, count: number) {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
const horizontal = Math.cos(angle)
return {
"--compare-radar-axis-x": `${roundRadarCoordinate(50 + horizontal * 42)}%`,
"--compare-radar-axis-mobile-x": `${roundRadarCoordinate(50 + horizontal * 36)}%`,
"--compare-radar-axis-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
"--compare-radar-axis-translate-x": horizontal > 0.25 ? "0%" : horizontal < -0.25 ? "-100%" : "-50%",
} as JSX.CSSProperties
}
function radarTooltipStyle(index: number, count: number) {
const angle = -Math.PI / 2 + (index * Math.PI * 2) / count
return {
"--compare-radar-tooltip-x": `${roundRadarCoordinate(50 + Math.cos(angle) * 42)}%`,
"--compare-radar-tooltip-y": `${roundRadarCoordinate(50 + Math.sin(angle) * 42)}%`,
"--compare-radar-tooltip-translate-y": Math.sin(angle) < -0.9 ? "20px" : "calc(-100% - 12px)",
} as JSX.CSSProperties
}
function roundRadarCoordinate(value: number) {
return Math.round(value * 1000) / 1000
}
function formatRadarScore(score: number | undefined) {
return score === undefined ? "No data" : `${Math.round(score)}/100`
}

Some files were not shown because too many files have changed in this diff Show More