From 035a5a1a671b484a7bec79b18400d1183356940e Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Wed, 29 Jul 2026 10:34:37 +0200 Subject: [PATCH 01/57] feat(desktop): refine tab states (#39472) --- .../app/src/components/titlebar-tab-nav.css | 74 ++++++++++++++----- .../app/src/components/titlebar-tab-nav.tsx | 12 +-- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 08bed7cf26..95fe212a5d 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -9,53 +9,91 @@ justify-content: center; } +[data-titlebar-tab] { + --tab-base: var(--v2-background-bg-deep); + --tab-overlay: transparent; + background: + linear-gradient(var(--tab-overlay), var(--tab-overlay)), + var(--tab-base); +} + +[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( + [data-dragging="true"] + ):not([data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-hover); +} + +[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-pressed); +} + +[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { + background: + linear-gradient(var(--tab-overlay), var(--tab-overlay)), + var(--tab-base); +} + [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { display: none; } [data-titlebar-tab-list] { - gap: 13.5px; + gap: 6px; } [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-03); position: relative; } +[data-color-scheme="dark"] [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-02); +} + [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { content: ""; position: absolute; top: 8px; - left: -6.75px; + left: -3.75px; width: 1.5px; height: 12px; border-radius: 9999px; - background: var(--v2-background-bg-layer-02); + background: var(--tab-separator); } [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab] [data-slot="tab-close"]::before { - content: ""; - position: absolute; - top: 0; - height: 28px; - width: 16px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--tab-bg)); +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { - display: block; - right: 0; +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { + --tab-title-fade-offset: 4px; + -webkit-mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); } -[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, -[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) - [data-slot="tab-close"]::before { - right: 100%; +[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + --tab-title-fade-offset: 24px; } [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 35f600c9f6..3823cf10b8 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -180,11 +180,11 @@ export function TabNavItem(props: { data-slot="titlebar-tab-item" data-title-overflow={titleOverflowing()} data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" classList={{ invisible: props.hidden }} data-active={props.active} data-dragging={props.dragging} - data-pressed={props.pressed} + data-state={props.active || props.pressed ? "pressed" : undefined} onMouseDown={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return event.preventDefault() @@ -276,7 +276,7 @@ export function TabNavItem(props: { /> -
+
{ if (event.button !== MIDDLE_MOUSE_BUTTON) return @@ -381,7 +381,7 @@ export function DraftTabItem(props: { {props.title} -
+
Date: Wed, 29 Jul 2026 08:36:03 +0000 Subject: [PATCH 02/57] chore: generate --- packages/app/src/components/titlebar-tab-nav.css | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 95fe212a5d..13dfbe04fc 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -12,9 +12,7 @@ [data-titlebar-tab] { --tab-base: var(--v2-background-bg-deep); --tab-overlay: transparent; - background: - linear-gradient(var(--tab-overlay), var(--tab-overlay)), - var(--tab-base); + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); } [data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( @@ -30,9 +28,7 @@ } [data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { - background: - linear-gradient(var(--tab-overlay), var(--tab-overlay)), - var(--tab-base); + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); } [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { @@ -68,8 +64,7 @@ } [data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, -[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) - + [data-titlebar-tab-slot]::before { +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { display: none; } From 02f50a6e803eb039af28f12efa45d0212efa2e38 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Wed, 29 Jul 2026 17:45:40 +0000 Subject: [PATCH 03/57] chore: bump gitlab-ai-provider to 6.12.1 (#39490) --- bun.lock | 6 +++--- packages/core/package.json | 2 +- packages/opencode/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 19f5a97cb2..aaf43212b7 100644 --- a/bun.lock +++ b/bun.lock @@ -339,7 +339,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -632,7 +632,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -3855,7 +3855,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/core/package.json b/packages/core/package.json index 2325bcc01e..cc1f5d6c61 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -108,7 +108,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b7ee6973d8..105f307319 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -120,7 +120,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", From a6f7fe739c691e8b086c50390cf3205f0b5d431e Mon Sep 17 00:00:00 2001 From: Lakshya Agarwal Date: Wed, 29 Jul 2026 13:52:12 -0400 Subject: [PATCH 04/57] docs(ecosystem): add opencode-tavily plugin (#38709) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 2fc3b65486..ce4f3100af 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -54,6 +54,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI | | [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform | | [opencode-goal-plugin](https://github.com/willytop8/OpenCode-goal-plugin) | Session-scoped `/goal` workflow that keeps objectives in context and auto-continues until complete | +| [opencode-tavily](https://github.com/tavily-ai/opencode-tavily) | Web search, extraction, crawling, and deep research via the Tavily CLI | --- From 1e17856ba4b5b052650c8115060852f3f023844e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Jul 2026 18:06:26 +0000 Subject: [PATCH 05/57] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 62788beafd..59ba15e5b2 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=", - "aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=", - "aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=", - "x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM=" + "x86_64-linux": "sha256-Z/YgK5sf0L9MG7hMIb+wj3ias6iNo8L4oFHYNtRHreM=", + "aarch64-linux": "sha256-MoYZmeh6RcDUN7a132wexFNwkHz+Q9Ijs+qiacuKJ90=", + "aarch64-darwin": "sha256-OHo3x7UcJzrPMAF9Ij/9Rhamd+dfpb5QuxP89cy7Rw4=", + "x86_64-darwin": "sha256-ElzZVqkQPYTAd0nvLCB5uel9xtOqMK07QFW8MDr174E=" } } From 341c64cc97859719e401dd5edfacb6cca4a36970 Mon Sep 17 00:00:00 2001 From: Deven Navani <4031146+devennavani@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:06:19 -0700 Subject: [PATCH 06/57] feat: discover Modal models (#39066) --- packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/plugin/modal/modal.ts | 17 ++ packages/opencode/src/plugin/modal/models.ts | 133 ++++++++++++ .../opencode/test/plugin/modal-models.test.ts | 198 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 packages/opencode/src/plugin/modal/modal.ts create mode 100644 packages/opencode/src/plugin/modal/models.ts create mode 100644 packages/opencode/test/plugin/modal-models.test.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1558c707c3..d68e8b34f9 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { ModalPlugin } from "./modal/modal" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" @@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }), }), CopilotAuthPlugin, + ModalPlugin, GitlabAuthPlugin, PoeAuthPlugin, CloudflareWorkersAuthPlugin, diff --git a/packages/opencode/src/plugin/modal/modal.ts b/packages/opencode/src/plugin/modal/modal.ts new file mode 100644 index 0000000000..942166e290 --- /dev/null +++ b/packages/opencode/src/plugin/modal/modal.ts @@ -0,0 +1,17 @@ +import type { Hooks } from "@opencode-ai/plugin" +import { ModalModels } from "./models" + +export async function ModalPlugin(): Promise { + return { + provider: { + id: "modal", + async models(provider, ctx) { + const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN + const baseURL = Object.values(provider.models)[0]?.api.url + if (!apiKey || !baseURL) return {} + + return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({})) + }, + }, + } +} diff --git a/packages/opencode/src/plugin/modal/models.ts b/packages/opencode/src/plugin/modal/models.ts new file mode 100644 index 0000000000..2f9d2b7e49 --- /dev/null +++ b/packages/opencode/src/plugin/modal/models.ts @@ -0,0 +1,133 @@ +import type { Model } from "@opencode-ai/sdk/v2" +import { Schema } from "effect" + +const reasoningOption = Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), +}) + +const response = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + base_model_id: Schema.optional(Schema.String), + hugging_face_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + input_modalities: Schema.optional(Schema.Array(Schema.String)), + output_modalities: Schema.optional(Schema.Array(Schema.String)), + context_length: Schema.optional(Schema.Number), + max_output_length: Schema.optional(Schema.Number), + pricing: Schema.optional( + Schema.Struct({ + prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + }), + ), + supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)), + supported_features: Schema.optional(Schema.Array(Schema.String)), + reasoning_options: Schema.optional(Schema.Array(reasoningOption)), + interleaved: Schema.optional( + Schema.Union([ + Schema.Boolean, + Schema.Struct({ + field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + }), + ]), + ), + }), + ), +}) + +const decode = Schema.decodeUnknownSync(response) + +function price(value: string | number | undefined, fallback: number) { + if (value === undefined) return fallback + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback +} + +export async function get(baseURL: string, apiKey: string, existing: Record) { + const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(3_000), + }).then(async (res) => { + if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`) + return decode(await res.json()) + }) + + return Object.fromEntries( + data.data.map((item) => { + const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id] + const model: Model = { + id: item.id, + providerID: "modal", + name: item.name ?? template?.name ?? item.id, + family: template?.family, + api: { + id: item.id, + url: baseURL, + npm: template?.api.npm ?? "@ai-sdk/openai-compatible", + }, + status: template?.status ?? "active", + headers: { ...template?.headers }, + options: { ...template?.options }, + cost: { + input: price(item.pricing?.prompt, template?.cost.input ?? 0), + output: price(item.pricing?.completion, template?.cost.output ?? 0), + cache: { + read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0), + write: template?.cost.cache.write ?? 0, + }, + }, + limit: { + context: item.context_length ?? template?.limit.context ?? 0, + input: template?.limit.input, + output: item.max_output_length ?? template?.limit.output ?? 0, + }, + capabilities: { + temperature: + item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false, + reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false, + attachment: + item.input_modalities?.some((modality) => modality !== "text") ?? + template?.capabilities.attachment ?? + false, + toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true, + input: { + text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true, + audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false, + image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false, + video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false, + pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false, + }, + output: { + text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true, + audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false, + image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false, + video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false, + pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false, + }, + interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false, + }, + release_date: template?.release_date ?? "", + } + model.variants = + item.reasoning_options === undefined + ? template?.variants + : Object.fromEntries( + item.reasoning_options.flatMap((option) => + option.values.map((value) => { + const effort = value ?? "none" + return [effort, { reasoningEffort: effort }] + }), + ), + ) + return [item.id, model] + }), + ) +} + +export * as ModalModels from "./models" diff --git a/packages/opencode/test/plugin/modal-models.test.ts b/packages/opencode/test/plugin/modal-models.test.ts new file mode 100644 index 0000000000..1b37b31747 --- /dev/null +++ b/packages/opencode/test/plugin/modal-models.test.ts @@ -0,0 +1,198 @@ +import { expect, test } from "bun:test" +import type { Model, Provider } from "@opencode-ai/sdk/v2" +import { ModalPlugin } from "@/plugin/modal/modal" + +const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4" +const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct" +const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct" + +function makeProvider(baseURL: string): Provider { + const template: Model = { + id: BASE_MODEL_ID, + providerID: "modal", + name: "Inkling", + family: "ling", + api: { + id: BASE_MODEL_ID, + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + status: "active", + headers: {}, + options: {}, + cost: { + input: 1, + output: 4, + cache: { + read: 0.2, + write: 0, + }, + }, + limit: { + context: 128_000, + output: 8_192, + }, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: true, + image: true, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: { + field: "reasoning_content", + }, + }, + release_date: "2026-07-15", + variants: { + fallback: { + reasoningEffort: "fallback", + }, + }, + } + + return { + id: "modal", + name: "Modal", + source: "api", + env: ["MODAL_PROXY_TOKEN"], + options: {}, + models: { + [template.id]: template, + }, + } +} + +test("discovers Modal workspace models", async () => { + const requests: Array<{ authorization: string | null; path: string }> = [] + using server = Bun.serve({ + port: 0, + fetch(request) { + requests.push({ + authorization: request.headers.get("authorization"), + path: new URL(request.url).pathname, + }) + return Response.json({ + data: [ + { + id: RUNTIME_MODEL_ID, + base_model_id: BASE_MODEL_ID, + name: "Thinking Machines: Inkling", + input_modalities: ["text", "image", "audio"], + output_modalities: ["text"], + context_length: 1_048_576, + max_output_length: 262_144, + pricing: { + prompt: "0.0000012", + completion: "0.000005", + input_cache_read: "0.00000027", + }, + supported_sampling_parameters: ["temperature"], + supported_features: ["tools", "reasoning"], + reasoning_options: [ + { + type: "effort", + values: ["none", "low", "medium", "high", "xhigh", "max"], + }, + ], + interleaved: { + field: "reasoning_content", + }, + }, + { + id: FALLBACK_RUNTIME_MODEL_ID, + base_model_id: BASE_MODEL_ID, + }, + ], + }) + }, + }) + const provider = makeProvider(`${server.url}v1`) + + const plugin = await ModalPlugin() + const models = await plugin.provider!.models!(provider, { + auth: { + type: "api", + key: "test-token", + }, + }) + const model = models[RUNTIME_MODEL_ID] + + expect(requests).toEqual([ + { + authorization: "Bearer test-token", + path: "/v1/models", + }, + ]) + expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID]) + expect(model.api).toEqual({ + id: RUNTIME_MODEL_ID, + url: `${server.url}v1`, + npm: "@ai-sdk/openai-compatible", + }) + expect(model.family).toBe("ling") + expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" }) + expect(model.capabilities.input).toEqual({ + text: true, + audio: true, + image: true, + video: false, + pdf: false, + }) + expect(model.cost).toEqual({ + input: 1.2, + output: 5, + cache: { + read: 0.27, + write: 0, + }, + }) + expect(model.limit).toEqual({ + context: 1_048_576, + output: 262_144, + }) + expect(model.variants).toEqual({ + none: { reasoningEffort: "none" }, + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + xhigh: { reasoningEffort: "xhigh" }, + max: { reasoningEffort: "max" }, + }) + expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({ + fallback: { + reasoningEffort: "fallback", + }, + }) +}) + +test("hides Modal models when discovery fails", async () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response(null, { status: 503 }) + }, + }) + + const plugin = await ModalPlugin() + const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), { + auth: { + type: "api", + key: "test-token", + }, + }) + + expect(models).toEqual({}) +}) From f7204902192624481e526b1852cdedaadb914e24 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Jul 2026 00:40:47 -0400 Subject: [PATCH 07/57] fix(console): add public fetch compatibility flag --- infra/console.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 5fbf35de00..79556f5e0c 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: { - tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], + worker: (args) => { + args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ + ...(flags ?? []), + "global_fetch_strictly_public", + ]) + args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] }, }, }, From 73009f0b09d866f2efe1ccc9fad91a7430345f7d Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:26:16 +0530 Subject: [PATCH 08/57] feat(app): update arrow buttons in review tab top bar (#39469) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- .../src/v2/components/session-review-v2.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index abbe90d112..bfd6778c54 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -165,11 +165,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const prev = () => { - return props.files[fileIndex() - 1] + if (!canCycle()) return + return props.files[(fileIndex() - 1 + props.files.length) % props.files.length] } const next = () => { - return props.files[fileIndex() + 1] + if (!canCycle()) return + return props.files[(fileIndex() + 1) % props.files.length] } const canCycle = () => props.files.length > 0 @@ -183,15 +185,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) { props.onSelectFile(file) } - // The prev/next tooltips advertise < and >; keep the keys working while the + // Keep the advertised arrow keys working while the // pane is mounted, but never while typing in an input or comment editor. makeEventListener(document, "keydown", (event) => { if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return - if (event.key !== "<" && event.key !== ">") return + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return const target = event.target if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return if (!props.hasDiffs || !canCycle()) return - const file = event.key === "<" ? prev() : next() + const file = event.key === "ArrowLeft" ? prev() : next() if (!file) return event.preventDefault() cycle(file) @@ -219,7 +221,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { value={ <> {i18n.t("ui.sessionReviewV2.previousFile")} - + } > @@ -239,7 +241,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { value={ <> {i18n.t("ui.sessionReviewV2.nextFile")} - "]} variant="neutral" /> + } > From a9eda2e99e8ade65f735aadb184685d704dc18ea Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:27:19 +0530 Subject: [PATCH 09/57] feat(app): migrate to solid-sonner (#39519) Co-authored-by: Brendan Allan --- bun.lock | 4 + package.json | 1 + packages/app/package.json | 4 +- packages/app/src/pages/layout.tsx | 9 +- packages/app/src/utils/toast.tsx | 17 +- packages/app/test-browser/toast-owner.test.ts | 40 ++- packages/ui/package.json | 1 + packages/ui/src/v2/components/toast-v2.css | 336 +++++++++--------- .../ui/src/v2/components/toast-v2.stories.tsx | 8 +- packages/ui/src/v2/components/toast-v2.tsx | 277 +++++++++++---- 10 files changed, 447 insertions(+), 250 deletions(-) diff --git a/bun.lock b/bun.lock index aaf43212b7..9f6ef7b628 100644 --- a/bun.lock +++ b/bun.lock @@ -1002,6 +1002,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1146,6 +1147,7 @@ "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", + "solid-sonner": "0.3.1", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", @@ -5127,6 +5129,8 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], + "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=="], diff --git a/package.json b/package.json index e479eb7406..2a8eef6356 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", + "solid-sonner": "0.3.1", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12" } diff --git a/packages/app/package.json b/packages/app/package.json index 7199ad9425..08eb9a059f 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,9 +19,9 @@ "build": "vite build", "serve": "vite preview", "test": "bun run test:unit && bun run test:browser", - "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", + "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", - "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b348b44d8a..694b610903 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -33,8 +33,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" -import { toaster } from "@opencode-ai/ui/toast" -import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" +import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" @@ -384,7 +383,7 @@ export default function LegacyLayout(props: ParentProps) { const dismissSessionAlert = (sessionKey: string) => { const toastId = toastBySession.get(sessionKey) if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } @@ -1459,7 +1458,7 @@ export default function LegacyLayout(props: ParentProps) { title: language.t("workspace.resetting.title"), description: language.t("workspace.resetting.description"), }) - const dismiss = () => toaster.dismiss(progress) + const dismiss = () => dismissToast(progress) const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) @@ -2450,7 +2449,7 @@ function UpdateAvailableToast(props: { onCleanup(() => { if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(toastId) }) return null diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx index e444548508..6f23b63d1e 100644 --- a/packages/app/src/utils/toast.tsx +++ b/packages/app/src/utils/toast.tsx @@ -1,6 +1,12 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon" -import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast" -import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2" +import { + Toast, + showToast as showLegacyToast, + toaster as legacyToaster, + type ToastOptions, + type ToastVariant, +} from "@opencode-ai/ui/toast" +import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" let v2 = false @@ -27,6 +33,13 @@ export function showToast(options: ToastOptions | string) { }) } +// v1 and v2 ids come from separate registries, so dismissal has to use the same +// implementation that issued the id. +export function dismissToast(toastId: number) { + if (!v2) return legacyToaster.dismiss(toastId) + return toasterV2.dismiss(toastId) +} + function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) { const name = icon ?? (variant === "success" ? "check" : undefined) if (!name) return diff --git a/packages/app/test-browser/toast-owner.test.ts b/packages/app/test-browser/toast-owner.test.ts index 104a505851..25ba5c000e 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,8 +1,46 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { createSignal, type JSX } from "solid-js" import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" describe("showToastV2", () => { + // The toast registry is module state, so each test starts from an empty stack. + beforeEach(() => { + toasterV2.dismiss() + }) + + test("coalesces exact active content", () => { + const first = showToastV2({ title: "Repeated error", description: "Try again" }) + const second = showToastV2({ title: "Repeated error", description: "Try again" }) + const different = showToastV2({ title: "Repeated error", description: "A different error" }) + + expect(second).toBe(first) + expect(different).not.toBe(first) + + toasterV2.dismiss(first) + toasterV2.dismiss(different) + }) + + test("allows dismissed content to appear again", () => { + const first = showToastV2("Dismiss and retry") + toasterV2.dismiss(first) + + const second = showToastV2("Dismiss and retry") + expect(second).not.toBe(first) + + toasterV2.dismiss(second) + }) + + test("recreates matching content when it is not the topmost toast", () => { + const first = showToastV2("First toast") + const topmost = showToastV2("Topmost toast") + const repeated = showToastV2("First toast") + + expect(repeated).not.toBe(first) + + toasterV2.dismiss(topmost) + toasterV2.dismiss(repeated) + }) + test("creates no reactive computations at call time", () => { const [tick, setTick] = createSignal(0) let reads = 0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 05c4ffbf63..f994110b9f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -104,6 +104,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2" }, "peerDependencies": { diff --git a/packages/ui/src/v2/components/toast-v2.css b/packages/ui/src/v2/components/toast-v2.css index 5bce87dbdc..db173489a9 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/packages/ui/src/v2/components/toast-v2.css @@ -1,58 +1,182 @@ -[data-component="toast-v2-region"] { - position: fixed; - bottom: 48px; - right: 32px; +.toast-v2-region { z-index: 1000; - display: flex; - flex-direction: column; - gap: 12px; max-width: min(320px, calc(100vw - 64px)); - max-height: none; - width: 100%; - overflow: visible; pointer-events: none; - - [data-slot="toast-v2-list"] { - display: flex; - flex-direction: column; - gap: 12px; - list-style: none; - margin: 0; - padding: 0; - max-height: none; - overflow-y: visible; - overflow-x: visible; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - } + user-select: none; + -webkit-user-select: none; } -[data-component="toast-v2"] { - position: relative; - display: flex; - flex-direction: column; - gap: 12px; +.toast-v2 { + display: grid; + grid-template-columns: minmax(0, 1fr) 20px; + column-gap: 12px; + row-gap: 12px; width: 320px; + max-width: 100%; padding: 12px; max-height: min(420px, calc(100dvh - 96px)); overflow: hidden; pointer-events: auto; - transition: transform 140ms ease-out; - + box-sizing: border-box; + outline: none; + user-select: none; + -webkit-user-select: none; border-radius: 8px; color: var(--v2-text-text-base); background: var(--v2-background-bg-layer-01); box-shadow: var(--v2-elevation-floating); + transition: + transform 280ms cubic-bezier(0.2, 0, 0, 1), + opacity 160ms ease-out, + height 280ms cubic-bezier(0.2, 0, 0, 1), + box-shadow 160ms ease-out; - &[data-opened] { - animation: toastV2PopIn 140ms ease-out; + &:has(> [data-icon]) { + grid-template-columns: 16px minmax(0, 1fr) 20px; } - &[data-closed] { - animation: toastV2PopOut 100ms ease-in forwards; + &:focus-visible { + box-shadow: + var(--v2-elevation-floating), + 0 0 0 2px var(--v2-border-border-focus); + } + + &[data-expanded="true"] { + overflow: visible; + } + + &[data-expanded="false"][data-front="false"] > * { + opacity: 0; + } + + &[data-expanded="false"][data-front="false"] { + --y: translateY(calc(-1 * var(--toasts-before) * 9.5px)) scale(calc(1 - var(--toasts-before) * 0.05)); + } + + > [data-icon] { + grid-column: 1; + grid-row: 1; + align-self: start; + display: flex; + align-items: center; + justify-content: center; + width: 16px; + height: 20px; + color: var(--v2-icon-icon-base); + } + + > [data-content] { + grid-column: 1; + grid-row: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + overflow: hidden; + } + + &:has(> [data-icon]) > [data-content] { + grid-column: 2; + } + + > [data-close-button] { + grid-column: 2; + grid-row: 1; + position: static; + align-self: start; + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + transform: none; + background: transparent; + color: var(--v2-icon-icon-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &:has(> [data-icon]) > [data-close-button] { + grid-column: 3; + } + + > [data-close-button]:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-icon-icon-base); + } + + > [data-close-button]:active { + background: var(--v2-overlay-simple-overlay-pressed); + } + + > [data-close-button]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; + } + + > [data-icon] > *, + > [data-icon] svg, + > [data-close-button] svg { + width: 16px; + height: 16px; + display: block; + flex-shrink: 0; + } + + [data-title], + [data-slot="toast-v2-title"] { + color: var(--v2-text-text-base); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 13px; + font-style: normal; + font-weight: 530; + line-height: 20px; + letter-spacing: -0.04px; + font-synthesis: none; + margin: 0; + } + + [data-description], + [data-slot="toast-v2-description"] { + color: var(--v2-text-text-muted); + text-wrap-style: pretty; + overflow-wrap: anywhere; + word-break: break-word; + font-size: 13px; + font-style: normal; + font-weight: 440; + line-height: 20px; + letter-spacing: -0.04px; + font-synthesis: none; + margin: 0; + } + + [data-slot="toast-v2-actions"] { + grid-column: 1; + grid-row: 2; + display: flex; + flex-wrap: wrap; + gap: 8px; + } + + &:has(> [data-icon]) [data-slot="toast-v2-actions"] { + grid-column: 2; + } + + [data-slot="toast-v2-actions"] [data-component="button-v2"] { + min-height: 24px; + font-size: 13px; + font-style: normal; + font-weight: 530; + line-height: 20px; + letter-spacing: -0.04px; + font-synthesis: none; } [data-slot="toast-v2-header"] { @@ -69,34 +193,7 @@ justify-content: center; width: 16px; height: 20px; - min-width: 16px; - min-height: 20px; color: var(--v2-icon-icon-base); - - [data-component="icon"] { - color: var(--v2-icon-icon-base); - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - > * { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - } - - svg { - width: 16px; - height: 16px; - display: block; - flex-shrink: 0; - } } [data-slot="toast-v2-content"] { @@ -104,112 +201,25 @@ display: flex; flex-direction: column; gap: 4px; - min-height: 0; min-width: 0; overflow: hidden; } +} - [data-slot="toast-v2-title"] { - color: var(--v2-text-text-base); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 13px; - font-style: normal; - font-weight: 530; - line-height: 20px; - letter-spacing: -0.04px; - font-synthesis: none; - margin: 0; - } - - [data-slot="toast-v2-description"] { - color: var(--v2-text-text-muted); - text-wrap-style: pretty; - overflow-wrap: anywhere; - word-break: break-word; - font-size: 13px; - font-style: normal; - font-weight: 440; - line-height: 20px; - letter-spacing: -0.04px; - font-synthesis: none; - margin: 0; - } - - [data-slot="toast-v2-actions"] { - display: flex; - flex-wrap: wrap; - gap: 8px; - padding-left: 28px; - } - - [data-slot="toast-v2-actions"] [data-component="button-v2"] { - min-height: 24px; - font-size: 13px; - font-style: normal; - font-weight: 530; - line-height: 20px; - letter-spacing: -0.04px; - font-synthesis: none; - } - - [data-slot="toast-v2-close-button"] { - flex-shrink: 0; - width: 20px; - height: 20px; - min-width: 20px; - min-height: 20px; - padding: 0; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--v2-icon-icon-muted); - cursor: pointer; - display: inline-flex; - align-items: center; - justify-content: center; - - &:hover { - background: var(--v2-overlay-simple-overlay-hover); - color: var(--v2-icon-icon-base); - } - - &:active { - background: var(--v2-overlay-simple-overlay-pressed); - } - - &:focus-visible { - outline: 2px solid var(--v2-border-border-focus); - outline-offset: 2px; - } - - svg { - width: 16px; - height: 16px; - display: block; - } +@media (prefers-reduced-motion: reduce) { + .toast-v2, + .toast-v2 > * { + animation: none !important; + transition: none !important; } } -@keyframes toastV2PopIn { - from { - opacity: 0.8; - transform: translateY(6px); +@media (max-width: 600px) { + .toast-v2-region { + max-width: calc(100vw - 32px); } - to { - transform: translateY(0); - opacity: 1; - } -} -@keyframes toastV2PopOut { - from { - transform: translateY(0); - opacity: 1; - } - to { - transform: translateY(6px); - opacity: 0.8; + .toast-v2 { + width: 100%; } } diff --git a/packages/ui/src/v2/components/toast-v2.stories.tsx b/packages/ui/src/v2/components/toast-v2.stories.tsx index f096459739..b2af8c038d 100644 --- a/packages/ui/src/v2/components/toast-v2.stories.tsx +++ b/packages/ui/src/v2/components/toast-v2.stories.tsx @@ -19,7 +19,7 @@ Use brief titles/descriptions; limit actions to 1-2. - Toasts render in a portal and auto-dismiss unless persistent. ### Accessibility -- TODO: confirm aria-live behavior from Kobalte Toast. +- The region announces additions through an ARIA live region and expands with Alt+T. ### Theming/tokens - Uses \`data-component="toast-v2"\` and slot data attributes. @@ -57,10 +57,10 @@ export const AllExamples = { - - + + ), actions: [ diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index d2dc1b3605..efb6ecd40c 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -1,40 +1,78 @@ -import { Toast as Kobalte, toaster } from "@kobalte/core/toast" -import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" +import { Toaster, toast, type ToasterProps } from "solid-sonner" import type { ComponentProps, JSX } from "solid-js" -import { Show, children } from "solid-js" +import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js" import { Portal } from "solid-js/web" -import { ButtonV2 } from "./button-v2" +import "./button-v2.css" import "./toast-v2.css" -export interface ToastV2RegionProps extends ComponentProps {} +export interface ToastV2RegionProps extends ToasterProps {} function ToastV2Region(props: ToastV2RegionProps) { + const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"]) + onMount(() => { + const sync = () => { + document.querySelectorAll(".toast-v2-region .toast-v2").forEach((element) => { + const hidden = element.dataset.visible === "false" + element.inert = hidden + element.tabIndex = hidden ? -1 : 0 + }) + } + let connected = false + const connect = () => { + const regions = document.querySelectorAll(".toast-v2-region") + if (!regions.length) return + observer.disconnect() + regions.forEach((region) => { + observer.observe(region, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-visible"], + }) + }) + connected = true + sync() + } + const observer = new MutationObserver(() => { + if (!connected) connect() + else sync() + }) + observer.observe(document.body, { subtree: true, childList: true }) + queueMicrotask(connect) + onCleanup(() => observer.disconnect()) + }) return ( - - - + ) } -export interface ToastV2RootComponentProps extends ToastRootProps { - class?: string - classList?: ComponentProps<"li">["classList"] +const ToastV2Context = createContext() + +export interface ToastV2RootComponentProps { + toastId: number children?: JSX.Element } function ToastV2Root(props: ToastV2RootComponentProps) { - return ( - - ) + return {props.children} } function ToastV2Icon(props: ComponentProps<"div">) { @@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) { return
} -function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) { - return +function ToastV2Title(props: ComponentProps<"div">) { + return
} -function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) { - return +function ToastV2Description(props: ComponentProps<"div">) { + return
} function ToastV2Actions(props: ComponentProps<"div">) { return
} -function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { +function ToastV2CloseButton(props: ComponentProps<"button">) { + const toastId = useContext(ToastV2Context) + const [local, rest] = splitProps(props, ["children", "onClick"]) return ( - - - + + ) +} + +function CloseIcon() { + return ( + ) } @@ -78,7 +133,29 @@ export const ToastV2 = Object.assign(ToastV2Root, { CloseButton: ToastV2CloseButton, }) -export { toaster as toasterV2 } +let toastV2Id = 0 + +export const toasterV2 = { + show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) { + const toastId = --toastV2Id + toast.custom((id) => render({ toastId: Number(id) }), { + id: toastId, + className: "toast-v2", + duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration, + unstyled: true, + }) + return toastId + }, + dismiss(toastId?: number) { + if (toastId === undefined) { + activeToastV2ByKey.clear() + activeToastV2ById.clear() + } else { + releaseToastV2(activeToastV2ById.get(toastId)) + } + return toast.dismiss(toastId) + }, +} export interface ToastV2Action { label: string @@ -90,53 +167,107 @@ export interface ToastV2Options { title?: string description?: string icon?: JSX.Element + variant?: "default" | "success" | "error" | "loading" duration?: number persistent?: boolean actions?: ToastV2Action[] } +interface ActiveToastV2 { + id: number + key: string + options: ToastV2Options + actions?: JSX.Element +} + +const activeToastV2ByKey = new Map() +const activeToastV2ById = new Map() + export function showToastV2(options: ToastV2Options | string) { - const opts = typeof options === "string" ? { description: options } : options - return toaster.show((props) => { - const resolvedIcon = children(() => opts.icon) - return ( - -
- - {resolvedIcon()} - - - - {opts.title} - - - {opts.description} - - - -
- - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") { - action.onClick() - } - toaster.dismiss(props.toastId) - }} - > - {action.label} - - ))} - - -
- ) + const opts: ToastV2Options = typeof options === "string" ? { description: options } : options + const key = JSON.stringify({ + title: opts.title, + description: opts.description, + variant: opts.variant, + duration: opts.duration, + persistent: opts.persistent, + actions: opts.actions?.map((action) => [action.label, action.variant]), }) + const active = activeToastV2ByKey.get(key) + const toasts = toast.getToasts() + + if (active && toasts.at(-1)?.id === active.id) { + active.options = opts + publishToastV2(active) + pulseToastV2(active.id) + return active.id + } + + if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id) + releaseToastV2(active) + + const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts } + entry.actions = createToastV2Actions(entry) + activeToastV2ByKey.set(key, entry) + activeToastV2ById.set(entry.id, entry) + publishToastV2(entry) + return entry.id +} + +function publishToastV2(entry: ActiveToastV2) { + const release = () => releaseToastV2(entry) + toast(entry.options.title ?? "", { + id: entry.id, + description: entry.options.description, + icon: entry.options.icon, + action: entry.actions, + closeButton: true, + duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration, + className: `toast-v2 toast-v2--${entry.options.variant ?? "default"}`, + testId: `toast-v2-${entry.id}`, + unstyled: true, + onDismiss: release, + onAutoClose: release, + }) +} + +function createToastV2Actions(entry: ActiveToastV2) { + if (!entry.options.actions?.length) return undefined + return ( + + {entry.options.actions.map((action, index) => ( + + ))} + + ) +} + +function pulseToastV2(toastId: number) { + if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return + requestAnimationFrame(() => { + const element = document.querySelector(`[data-testid="toast-v2-${toastId}"]`) + if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" }) + }) +} + +function releaseToastV2(entry: ActiveToastV2 | undefined) { + if (!entry) return + if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key) + if (activeToastV2ById.get(entry.id) === entry) activeToastV2ById.delete(entry.id) } export interface ToastV2PromiseOptions { From 5b11635d97ecc5dceb66752c876b4cd6c5da8043 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:28:38 +0530 Subject: [PATCH 10/57] feat(app): always show new session button (#39520) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/app/src/components/titlebar.tsx | 40 +++++++++++------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 32d7e80b2f..649a8f5777 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> - - - {language.t("command.session.new")} - - - } - > - } - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - - + + {language.t("command.session.new")} + + + } + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> +
From eca5e68a5ea7f2f54ec0d81a46a41110c43e62b4 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:32:15 +0530 Subject: [PATCH 11/57] feat(app): disallow duplicate attachments (#39464) --- .../app/src/components/prompt-input-v2.tsx | 1 + packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/uk.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + .../test-browser/prompt-attachments.test.ts | 86 +++++++++++++++++++ .../v2/components/prompt-input/attachments.ts | 22 ++++- 21 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 634e90f78f..2d6a5e98d6 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -368,6 +368,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): title: language.t("prompt.toast.pasteUnsupported.title"), description: language.t("prompt.toast.pasteUnsupported.description"), }), + duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), onError: (error) => showToast({ variant: "error", diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 320738a6b0..89fa394637 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.action.send": "إرسال", "prompt.action.stop": "توقف", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", + "prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 9d6765ec94..f6e5d8df9d 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.action.send": "Enviar", "prompt.action.stop": "Parar", "prompt.toast.pasteUnsupported.title": "Anexo não suportado", + "prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 915313c3e2..3343df7a2b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -298,6 +298,7 @@ export const dict = { "prompt.action.stop": "Zaustavi", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog", + "prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.", "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model", "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 22fcdf39ce..52bfb642c3 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -296,6 +296,7 @@ export const dict = { "prompt.action.stop": "Stop", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning", + "prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.", "prompt.toast.modelAgentRequired.title": "Vælg en agent og model", "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index bc2ac9be38..8619546079 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -282,6 +282,7 @@ export const dict = { "prompt.action.send": "Senden", "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang", + "prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen", "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.", "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell", "prompt.toast.modelAgentRequired.description": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f03147f151..76623f4468 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -301,6 +301,7 @@ export const dict = { "prompt.toast.pasteUnsupported.title": "Unsupported attachment", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.", + "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded", "prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 00dca6704b..043c997c04 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "Detener", "prompt.toast.pasteUnsupported.title": "Adjunto no compatible", + "prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido", "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.", "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo", "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index d1487ff61e..fc5c5bbb34 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.action.send": "Envoyer", "prompt.action.stop": "Arrêter", "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge", + "prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé", "prompt.toast.pasteUnsupported.description": "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.", "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 6a3b1ebcb0..56c63e0e70 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -276,6 +276,7 @@ export const dict = { "prompt.action.send": "送信", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル", + "prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています", "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。", "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択", "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 3c262ce3b9..02d9a5e0e1 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -264,6 +264,7 @@ export const dict = { "prompt.action.send": "전송", "prompt.action.stop": "중지", "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일", + "prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다", "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.", "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택", "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a4e8efb79d..959e4c68cb 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -288,6 +288,7 @@ export const dict = { "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg", + "prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp", "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.", "prompt.toast.modelAgentRequired.title": "Velg en agent og modell", "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f2e32ffeb8..7517f16b81 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -278,6 +278,7 @@ export const dict = { "prompt.action.send": "Wyślij", "prompt.action.stop": "Zatrzymaj", "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik", + "prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany", "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.", "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model", "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index fa6fb04d04..17d6ba58a9 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "Остановить", "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение", + "prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен", "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.", "prompt.toast.modelAgentRequired.title": "Выберите агента и модель", "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0ae67ee34e..2c0b7d081c 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "หยุด", "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ", + "prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว", "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น", "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล", "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index d401519af9..7c123c69aa 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -302,6 +302,7 @@ export const dict = { "prompt.action.stop": "Durdur", "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek", + "prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi", "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.", "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin", "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index c949f41de2..d8c9ae8e43 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -299,6 +299,7 @@ export const dict = { "prompt.action.stop": "Зупинити", "prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення", + "prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено", "prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.", "prompt.toast.modelAgentRequired.title": "Виберіть агента та модель", "prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 82d73ac3a6..724bcc1175 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -316,6 +316,7 @@ export const dict = { "prompt.action.send": "发送", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支持的附件", + "prompt.toast.attachmentDuplicate.title": "此文件已上传", "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。", "prompt.toast.modelAgentRequired.title": "请选择智能体和模型", "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index fbe80d95ee..6f66822165 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支援的附件", + "prompt.toast.attachmentDuplicate.title": "此檔案已上傳", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。", "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型", "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。", diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts index ca7686598e..d604477e9e 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" import { createPromptState } from "@/context/prompt" +import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments" +import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types" describe("prompt attachment session ownership", () => { test("adds an asynchronously read image to the session where the read started", async () => { @@ -83,6 +86,89 @@ describe("prompt attachment session ownership", () => { dispose() }) }) + +}) + +test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => { + await createRoot(async (dispose) => { + const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) + const duplicate = Promise.withResolvers() + const files = [ + new File(["hello"], "clipboard-1.txt", { type: "text/plain" }), + new File(["hello"], "clipboard-2.txt", { type: "text/plain" }), + ] + const attachments = createPromptInputV2Attachments({ + capture: () => ({ + current: () => state.prompt, + cursor: () => 0, + set: (prompt) => setState("prompt", prompt), + }), + editor: () => document.createElement("div"), + focusEditor: () => undefined, + addPart: () => false, + setDraggingType: () => undefined, + directory: () => "/", + isDialogActive: () => false, + warn: () => undefined, + duplicate: duplicate.resolve, + onError: () => undefined, + readClipboardImage: async () => files.shift() ?? null, + }) + const event = { + clipboardData: { items: [], getData: () => "" }, + preventDefault: () => undefined, + stopPropagation: () => undefined, + } as unknown as ClipboardEvent + + await attachments.handlePaste(event) + await attachments.handlePaste(event) + await duplicate.promise + + expect(state.prompt).toHaveLength(1) + dispose() + }) +}) + +test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => { + await createRoot(async (dispose) => { + const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) + const duplicates: string[] = [] + const attachments = createPromptInputV2Attachments({ + capture: () => ({ + current: () => state.prompt, + cursor: () => 0, + set: (prompt) => setState("prompt", prompt), + }), + editor: () => document.createElement("div"), + focusEditor: () => undefined, + addPart: () => false, + setDraggingType: () => undefined, + directory: () => "/", + isDialogActive: () => false, + warn: () => undefined, + duplicate: () => duplicates.push("duplicate"), + onError: () => undefined, + getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`), + }) + const first = new File(["first"], "a.txt", { type: "text/plain" }) + const second = new File(["second"], "b.txt", { type: "text/plain" }) + + await attachments.addAttachments([first, second]) + await attachments.addAttachments([first, second]) + expect(state.prompt).toHaveLength(2) + expect(duplicates).toEqual(["duplicate", "duplicate"]) + + await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })]) + expect(state.prompt).toHaveLength(3) + + await attachments.addAttachments([ + new File(["same"], "browser.txt", { type: "text/plain" }), + new File(["same"], "browser.txt", { type: "text/plain" }), + ]) + expect(state.prompt).toHaveLength(4) + expect(duplicates).toHaveLength(3) + dispose() + }) }) function images(prompt: ReturnType) { diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts index 8c7b12e9ae..cfe3c43bd8 100644 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -74,6 +74,7 @@ export type PromptInputV2AttachmentConfig = { directory: () => string isDialogActive: () => boolean warn: () => void + duplicate: () => void onError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string @@ -94,7 +95,7 @@ export function createPromptInputV2Attachments( if (!editor) return return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } } - const add = async (file: File, toast = true, target = capture()) => { + const add = async (file: File, toast = true, target = capture(), clipboard = false) => { if (!target) return false const mime = await attachmentMime(file) if (!mime) { @@ -103,11 +104,26 @@ export function createPromptInputV2Attachments( } const url = await dataUrl(file, mime) if (!url) return false + const sourcePath = input.getPathForFile?.(file) || undefined + // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical + // clipboard content is matched on bytes alone. + const duplicate = target.prompt + .current() + .some( + (part) => + part.type === "image" && + part.dataUrl === url && + (sourcePath ? part.sourcePath === sourcePath : !part.sourcePath && (clipboard || part.filename === file.name)), + ) + if (duplicate) { + input.duplicate() + return true + } const attachment: PromptInputV2Attachment = { type: "image", id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), filename: file.name, - sourcePath: input.getPathForFile?.(file) || undefined, + sourcePath, mime, dataUrl: url, } @@ -141,7 +157,7 @@ export function createPromptInputV2Attachments( const plainText = clipboardData.getData("text/plain") ?? "" if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() - if (file && (await add(file, true, target))) return + if (file && (await add(file, true, target, true))) return } if (!plainText) return const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText From 005bf088c9371ab886affed91b021fae33388503 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:33:23 +0530 Subject: [PATCH 12/57] feat(app): fix file tree button bg (#38629) --- packages/session-ui/src/v2/components/session-review-v2.css | 2 +- packages/session-ui/src/v2/components/session-review-v2.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index 751c258a52..e28a6787d4 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -244,7 +244,7 @@ } [data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] { - background-color: var(--v2-overlay-simple-overlay-pressed); + background-color: var(--v2-background-bg-layer-02); } [data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index bfd6778c54..d2f3b6b8a6 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -333,6 +333,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled? class="session-review-v2-sidebar-toggle" aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-expanded={props.opened} + data-expanded={props.opened ? "" : undefined} disabled={props.disabled} onClick={props.onToggle} icon={} From a277557a5fa767c59a301aacf7f7dfa07c8a6060 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 09:05:12 +0000 Subject: [PATCH 13/57] chore: generate --- packages/app/test-browser/prompt-attachments.test.ts | 1 - .../session-ui/src/v2/components/prompt-input/attachments.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts index d604477e9e..49dad279cd 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -86,7 +86,6 @@ describe("prompt attachment session ownership", () => { dispose() }) }) - }) test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => { diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts index cfe3c43bd8..afa97a7f3e 100644 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -113,7 +113,9 @@ export function createPromptInputV2Attachments( (part) => part.type === "image" && part.dataUrl === url && - (sourcePath ? part.sourcePath === sourcePath : !part.sourcePath && (clipboard || part.filename === file.name)), + (sourcePath + ? part.sourcePath === sourcePath + : !part.sourcePath && (clipboard || part.filename === file.name)), ) if (duplicate) { input.duplicate() From 3dbce3dade7373ff532f3ed82af9b389ff073f44 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:06 +0530 Subject: [PATCH 14/57] feat(app): adds fades to file tree (#38549) --- .../src/v2/components/session-review-v2.css | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index e28a6787d4..704e8fd7d7 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -63,7 +63,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 12px 16px 8px 8px; + padding: 12px 16px 12px 8px; flex-shrink: 0; } @@ -91,7 +91,7 @@ } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] { - padding: 0 8px 8px; + padding: 0 8px 4px; flex-shrink: 0; min-width: 0; box-sizing: border-box; @@ -109,10 +109,71 @@ [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { flex: 1; min-height: 0; + timeline-scope: --session-review-v2-sidebar-tree-scroll; +} + +@keyframes session-review-v2-sidebar-tree-fade-top { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes session-review-v2-sidebar-tree-fade-bottom { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before, +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + content: ""; + position: absolute; + inset-inline: 0; + z-index: 10; + height: 16px; + pointer-events: none; + visibility: hidden; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + top: 0; + background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + bottom: 0; + background: linear-gradient(to top, var(--v2-background-bg-base), transparent); +} + +@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and + (timeline-scope: --session-review-v2-sidebar-tree-scroll) { + [data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__viewport { + scroll-timeline: --session-review-v2-sidebar-tree-scroll y; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + animation: session-review-v2-sidebar-tree-fade-top linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: 0 0.1px; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + animation: session-review-v2-sidebar-tree-fade-bottom linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport { - padding: 0 8px 12px; + padding: 4px 8px 12px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { From c5cf416633e4b12daf21b4ea230d52f2dae03f32 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:35 +0530 Subject: [PATCH 15/57] feat(app): fix composer attachment fades (#38547) --- .../components/prompt-input/attachments.css | 44 +++++++++++++++++++ .../src/v2/components/prompt-input/index.tsx | 15 +++++-- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 packages/session-ui/src/v2/components/prompt-input/attachments.css diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.css b/packages/session-ui/src/v2/components/prompt-input/attachments.css new file mode 100644 index 0000000000..3b8173d105 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.css @@ -0,0 +1,44 @@ +@keyframes prompt-input-v2-attachments-fade-left { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes prompt-input-v2-attachments-fade-right { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="prompt-input-v2-attachments"] { + timeline-scope: --prompt-input-v2-attachments-scroll; + + [data-slot^="prompt-attachments-fade-"] { + visibility: hidden; + } +} + +@supports (animation-timeline: --prompt-input-v2-attachments-scroll) and + (timeline-scope: --prompt-input-v2-attachments-scroll) { + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-scroll"] { + scroll-timeline: --prompt-input-v2-attachments-scroll x; + } + + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-left"] { + animation: prompt-input-v2-attachments-fade-left linear both; + animation-timeline: --prompt-input-v2-attachments-scroll; + animation-range: 0 0.1px; + } + + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-right"] { + animation: prompt-input-v2-attachments-fade-right linear both; + animation-timeline: --prompt-input-v2-attachments-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index c09b5a1b5f..0989e2ef65 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -21,6 +21,7 @@ import type { PromptInputV2Suggestion, } from "./types" import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" +import "./attachments.css" export type { PromptInputV2Attachment, @@ -107,7 +108,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
0 || (props.comments?.length ?? 0) > 0}> -
+
-
-
+
+
) From c08350b83e3d976c0f93af68d22f5b1518194f30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 09:11:45 +0000 Subject: [PATCH 16/57] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 59ba15e5b2..a7b441535c 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Z/YgK5sf0L9MG7hMIb+wj3ias6iNo8L4oFHYNtRHreM=", - "aarch64-linux": "sha256-MoYZmeh6RcDUN7a132wexFNwkHz+Q9Ijs+qiacuKJ90=", - "aarch64-darwin": "sha256-OHo3x7UcJzrPMAF9Ij/9Rhamd+dfpb5QuxP89cy7Rw4=", - "x86_64-darwin": "sha256-ElzZVqkQPYTAd0nvLCB5uel9xtOqMK07QFW8MDr174E=" + "x86_64-linux": "sha256-fK6zAHJC3ut/KUdfqLPxVMH7Z1yv65YZ7qcHF45kPas=", + "aarch64-linux": "sha256-CSWZY/vVUCKzlwfisQQHqHjawUwL0BRc8Y9o2y/QZYA=", + "aarch64-darwin": "sha256-IyFm5NbnU63BaOO/F4/v1exz3VbvkY96yjg9iun+O9Q=", + "x86_64-darwin": "sha256-XnXFoUxqXmEx7AKx5YhaJuLY2NYMtI0ICafaenU5L6k=" } } From ac5380614f310586949e0947b1be244268ebdb7c Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:29:40 +0200 Subject: [PATCH 17/57] fix(app): repair malformed persisted tabs (#39645) --- packages/app/src/context/tab-migration.ts | 23 +++++++++++++++++++++++ packages/app/src/context/tabs.test.ts | 23 +++++++++++++++++++++++ packages/app/src/context/tabs.tsx | 9 ++------- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/context/tab-migration.ts diff --git a/packages/app/src/context/tab-migration.ts b/packages/app/src/context/tab-migration.ts new file mode 100644 index 0000000000..04c1cb6804 --- /dev/null +++ b/packages/app/src/context/tab-migration.ts @@ -0,0 +1,23 @@ +import type { ServerConnection } from "./server" +import type { Tab } from "./tabs" + +export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] { + if (!Array.isArray(value)) return [] + return value.flatMap((tab) => { + if (!tab || typeof tab !== "object") return [] + if ("server" in tab && typeof tab.server !== "string") return [] + const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key + if (tab.type === "session" && typeof tab.sessionId === "string") { + return [{ type: tab.type, server, sessionId: tab.sessionId }] + } + if ( + tab.type === "draft" && + typeof tab.draftID === "string" && + typeof tab.directory === "string" && + (tab.worktree === undefined || typeof tab.worktree === "string") + ) { + return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }] + } + return [] + }) +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index b56ea2dfcf..17371efd80 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -3,6 +3,7 @@ import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import type { SessionTab, Tab } from "./tabs" +import { migrateTabs } from "./tab-migration" import type { ServerConnection } from "./server" const server = "local\nhttp://localhost:4096" as ServerConnection.Key @@ -11,6 +12,28 @@ function sessionTab(sessionId: string): SessionTab { return { type: "session", server, sessionId } } +describe("tab migration", () => { + test("drops null and malformed persisted tabs", () => { + expect( + migrateTabs( + [null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], + server, + ), + ).toEqual([sessionTab("a")]) + }) + + test("adds the fallback server to valid legacy tabs", () => { + expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([ + sessionTab("a"), + ]) + }) + + test("replaces invalid top-level persisted data", () => { + expect(migrateTabs(null, server)).toEqual([]) + expect(migrateTabs({}, server)).toEqual([]) + }) +}) + describe("tab memory", () => { test("keeps state until its tab is removed", () => { createRoot((dispose) => { diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 75191d54cf..e4d50ac161 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -12,6 +12,7 @@ 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" +import { migrateTabs } from "./tab-migration" export type SessionTab = { type: "session" @@ -59,13 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const [store, setStore, _, ready] = persisted( { ...Persist.window("tabs"), - migrate: (value: unknown) => { - if (!Array.isArray(value)) return value - return value.map((tab) => { - if (!tab || typeof tab !== "object" || "server" in tab) return tab - return { ...tab, server: fallback } - }) - }, + migrate: (value: unknown) => migrateTabs(value, fallback), }, createStore([]), ) From ff0382e97145cb6585b575dcc1269fa1512e853b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 09:31:27 +0000 Subject: [PATCH 18/57] chore: generate --- packages/app/src/context/tabs.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 17371efd80..abd62a4dae 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -15,17 +15,12 @@ function sessionTab(sessionId: string): SessionTab { describe("tab migration", () => { test("drops null and malformed persisted tabs", () => { expect( - migrateTabs( - [null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], - server, - ), + migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server), ).toEqual([sessionTab("a")]) }) test("adds the fallback server to valid legacy tabs", () => { - expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([ - sessionTab("a"), - ]) + expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")]) }) test("replaces invalid top-level persisted data", () => { From a4f25a94b4e470ec10cd97a0c8e2f2c23e215b38 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:12:08 -0400 Subject: [PATCH 19/57] fix: use OpenCode model catalog URL (#39672) Co-authored-by: Dax Raad --- packages/cli/script/generate.ts | 2 +- packages/core/src/models-dev.ts | 4 ++-- packages/llm/script/recording-cost-report.ts | 2 +- packages/stats/app/src/routes/model-catalog.ts | 6 +++--- packages/ui/vite.config.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index d98565e298..e162f2ea7e 100755 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -1,4 +1,4 @@ -const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" export const modelsData = process.env.MODELS_DEV_API_JSON ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 602133d904..7e3118722f 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -151,10 +151,10 @@ const layer = Layer.effect( ), ) - const source = Flag.OPENCODE_MODELS_URL || "https://models.dev" + const source = Flag.OPENCODE_MODELS_URL || "https://models.opencode.ai" const filepath = path.join( Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`, ) const ttl = Duration.minutes(5) const lockKey = `models-dev:${filepath}` diff --git a/packages/llm/script/recording-cost-report.ts b/packages/llm/script/recording-cost-report.ts index 5b08e72d5c..1f42dc5932 100644 --- a/packages/llm/script/recording-cost-report.ts +++ b/packages/llm/script/recording-cost-report.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises" import * as path from "node:path" const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") -const MODELS_DEV_URL = "https://models.dev/api.json" +const MODELS_DEV_URL = "https://models.opencode.ai/api.json" type JsonRecord = Record diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index efd6f6b2e0..47fa1cf347 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -1,8 +1,8 @@ import { query } from "@solidjs/router" -export const modelCatalogSourceUrl = "https://models.dev/catalog.json" -export const modelCatalogPricingUrl = "https://models.dev/api.json" -export const modelCatalogLabSourceUrl = "https://models.dev/labs" +export const modelCatalogSourceUrl = "https://models.opencode.ai/catalog.json" +export const modelCatalogPricingUrl = "https://models.opencode.ai/api.json" +export const modelCatalogLabSourceUrl = "https://models.opencode.ai/labs" export type ModelCatalogCost = { input: number diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 1e38adede2..1d5af6934c 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -45,7 +45,7 @@ function providerIconsPlugin() { } async function fetchProviderIcons() { - const url = process.env.OPENCODE_MODELS_URL || "https://models.dev" + const url = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" const providers = await fetch(`${url}/api.json`) .then((res) => res.json()) .then((json) => Object.keys(json)) From 8c38d260eb6555d2824230be100fb2a7eadd7513 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 30 Jul 2026 14:43:50 +0000 Subject: [PATCH 20/57] sync release versions for v1.18.10 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 9f6ef7b628..160958f8da 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.9", + "version": "1.18.10", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.9", + "version": "1.18.10", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.9", + "version": "1.18.10", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -841,7 +841,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +854,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +888,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +907,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +949,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1028,7 +1028,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 08eb9a059f..e6e95e35ff 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.9", + "version": "1.18.10", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index d64acf4840..46a372229b 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 83cfeaac0c..e0c89ec26e 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.9", + "version": "1.18.10", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b8fd9a04c6..f27b89305f 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f922ab9ef2..63ba0a50f2 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index aadfa42be1..52581a8c93 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.9", + "version": "1.18.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1a551ed0a5..f54268c9c8 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 6d90a394cb..6b6a65b6e6 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index cc1f5d6c61..a8a5041e73 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1a5baf91b0..d00b2bda45 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 6ae8d912a1..9577e7a463 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index d26bea7f85..7c084da87b 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d298dd1dee..117d2220ad 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 6acd31710e..f8e1ee3d86 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.9", + "version": "1.18.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index cca67e0c30..56224a41c7 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 69e1ea92eb..d575867f1c 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 105f307319..84f03d88ab 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2607c317d6..33b6d2b0dc 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f92dca0c4d..68313859b6 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 89d7360a79..fcfa76143e 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index eb67c17d19..f34feebf25 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 7ec9f938b7..20f1ce0598 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 8e69fd7193..3a19ff5aad 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 45e6acf976..c139702674 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 5d34525aa1..efa68e21dc 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 9900a37849..a96b6a7088 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index f994110b9f..36cd5ad027 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index bdc41cdd67..260ca51941 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.9", + "version": "1.18.10", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 33c79c001d..15943599b3 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.9", + "version": "1.18.10", "publisher": "sst-dev", "repository": { "type": "git", From a1ab489e6175cabbb3b46a15c2efac78d49d5083 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:10:34 -0500 Subject: [PATCH 21/57] fix(provider): widen interleaved reasoning fields (#39556) Co-authored-by: Aiden Cline --- packages/core/src/models-dev.ts | 10 ++++-- packages/core/src/v1/config/provider.ts | 10 ++++-- packages/opencode/src/provider/provider.ts | 11 +++++-- .../opencode/test/provider/provider.test.ts | 6 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 10 ++++-- packages/sdk/openapi.json | 32 +++++++++++++++---- 6 files changed, 63 insertions(+), 16 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 7e3118722f..e577935f06 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -15,6 +15,11 @@ import { httpClient } from "./effect/app-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type +const InterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` const CostTier = Schema.Struct({ @@ -71,9 +76,10 @@ export const Model = Schema.Struct({ reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), interleaved: Schema.optional( Schema.Union([ - Schema.Literal(true), + Schema.Boolean, + InterleavedField, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: InterleavedField, }), ]), ), diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f9..f860a2b4ac 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -5,6 +5,11 @@ import { PositiveInt } from "../../schema" export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +const InterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + export const Model = Schema.Struct({ id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), @@ -16,9 +21,10 @@ export const Model = Schema.Struct({ tool_call: Schema.optional(Schema.Boolean), interleaved: Schema.optional( Schema.Union([ - Schema.Literal(true), + Schema.Boolean, + InterleavedField, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: InterleavedField, }), ]), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a0ab5eb347..85b7fd3978 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -976,10 +976,15 @@ const ProviderModalities = Schema.Struct({ pdf: Schema.Boolean, }) +const ProviderInterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + const ProviderInterleaved = Schema.Union([ Schema.Boolean, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: ProviderInterleavedField, }), ]) @@ -1243,7 +1248,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model video: model.modalities?.output?.includes("video") ?? false, pdf: model.modalities?.output?.includes("pdf") ?? false, }, - interleaved: model.interleaved ?? false, + interleaved: typeof model.interleaved === "string" ? { field: model.interleaved } : (model.interleaved ?? false), }, release_date: model.release_date ?? "", variants: {}, @@ -1475,7 +1480,7 @@ const layer = Layer.effect( pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, }, interleaved: - model.interleaved ?? + (typeof model.interleaved === "string" ? { field: model.interleaved } : model.interleaved) ?? existingModel?.capabilities.interleaved ?? (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek") ? { field: "reasoning_content" } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 058bad23a1..44e82c7e1f 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -252,6 +252,8 @@ it.instance( const provider = providers[ProviderV2.ID.make("custom-provider")] expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) + expect(provider.models["deepseek-text"].capabilities.interleaved).toEqual({ field: "reasoning_text" }) + expect(provider.models["custom-reasoning"].capabilities.interleaved).toEqual({ field: "vendor_reasoning" }) expect(provider.models["custom-model"].capabilities.interleaved).toBe(false) expect( providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved, @@ -267,6 +269,8 @@ it.instance( models: { "deepseek-r1": { name: "DeepSeek R1" }, "deepseek-details": { name: "DeepSeek Details", interleaved: { field: "reasoning_details" } }, + "deepseek-text": { name: "DeepSeek Text", interleaved: "reasoning_text" }, + "custom-reasoning": { name: "Custom Reasoning", interleaved: { field: "vendor_reasoning" } }, "custom-model": { name: "Custom Model" }, }, options: { apiKey: "custom-key" }, @@ -1462,6 +1466,7 @@ test("models.dev normalization fills required response fields", () => { id: "gpt-5.4", name: "GPT-5.4", family: "gpt", + interleaved: "reasoning_text", cost: { input: 2.5, output: 15 }, limit: { context: 1_050_000, input: 922_000, output: 128_000 }, }, @@ -1474,6 +1479,7 @@ test("models.dev normalization fills required response fields", () => { expect(model.capabilities.reasoning).toBe(false) expect(model.capabilities.attachment).toBe(false) expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.interleaved).toEqual({ field: "reasoning_text" }) expect(model.release_date).toBe("") }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d..90c91e9158 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1768,9 +1768,13 @@ export type ProviderConfig = { temperature?: boolean tool_call?: boolean interleaved?: - | true + | boolean + | "reasoning" + | "reasoning_content" + | "reasoning_text" + | string | { - field: "reasoning" | "reasoning_content" | "reasoning_details" + field: "reasoning" | "reasoning_content" | "reasoning_text" | string } cost?: { input: number @@ -2057,7 +2061,7 @@ export type Model = { interleaved: | boolean | { - field: "reasoning" | "reasoning_content" | "reasoning_details" + field: "reasoning" | "reasoning_content" | "reasoning_text" | string } } cost: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b300754bc8..afe14bb604 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -20820,15 +20820,28 @@ "interleaved": { "anyOf": [ { - "type": "boolean", - "enum": [true] + "type": "boolean" + }, + { + "type": "string", + "enum": ["reasoning", "reasoning_content", "reasoning_text"] + }, + { + "type": "string" }, { "type": "object", "properties": { "field": { - "type": "string", - "enum": ["reasoning", "reasoning_content", "reasoning_details"] + "anyOf": [ + { + "type": "string", + "enum": ["reasoning", "reasoning_content", "reasoning_text"] + }, + { + "type": "string" + } + ] } }, "required": ["field"], @@ -21641,8 +21654,15 @@ "type": "object", "properties": { "field": { - "type": "string", - "enum": ["reasoning", "reasoning_content", "reasoning_details"] + "anyOf": [ + { + "type": "string", + "enum": ["reasoning", "reasoning_content", "reasoning_text"] + }, + { + "type": "string" + } + ] } }, "required": ["field"], From c1ee3c6e344c1822814a1a18e99bc14cd1ea36f3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:26:48 -0500 Subject: [PATCH 22/57] fix(opencode): stop MCP SSE error reconnect loops (#39697) --- packages/opencode/test/mcp/transport.test.ts | 38 +++++++++++++++++++ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 20 +++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/mcp/transport.test.ts diff --git a/packages/opencode/test/mcp/transport.test.ts b/packages/opencode/test/mcp/transport.test.ts new file mode 100644 index 0000000000..f7a74d4d72 --- /dev/null +++ b/packages/opencode/test/mcp/transport.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" + +test("does not reconnect an SSE stream after a JSON-RPC error response", async () => { + let requests = 0 + const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), { + fetch: async () => { + requests += 1 + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n")) + controller.enqueue( + new TextEncoder().encode( + 'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n', + ), + ) + controller.close() + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ) + }, + reconnectionOptions: { + initialReconnectionDelay: 1, + maxReconnectionDelay: 1, + reconnectionDelayGrowFactor: 1, + maxRetries: 2, + }, + }) + + await transport.start() + await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + await Bun.sleep(25) + await transport.close() + + expect(requests).toBe(1) +}) diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index 13b8000a01..3b70bea558 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -141,6 +141,15 @@ diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp. index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js +@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport { + if (!event.event || event.event === 'message') { + try { + const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data)); +- if ((0, types_js_1.isJSONRPCResultResponse)(message)) { ++ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { @@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { this.onclose?.(); } @@ -518,10 +527,19 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 @@ -1,5 +1,5 @@ import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; -+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; ++import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections +@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport { + if (!event.event || event.event === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); +- if (isJSONRPCResultResponse(message)) { ++ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { @@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { this.onclose?.(); } From 3f239b3834e7ef30a454af6830053d2e82af009f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 16:46:38 +0000 Subject: [PATCH 23/57] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index a7b441535c..b1975757b4 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-fK6zAHJC3ut/KUdfqLPxVMH7Z1yv65YZ7qcHF45kPas=", - "aarch64-linux": "sha256-CSWZY/vVUCKzlwfisQQHqHjawUwL0BRc8Y9o2y/QZYA=", - "aarch64-darwin": "sha256-IyFm5NbnU63BaOO/F4/v1exz3VbvkY96yjg9iun+O9Q=", - "x86_64-darwin": "sha256-XnXFoUxqXmEx7AKx5YhaJuLY2NYMtI0ICafaenU5L6k=" + "x86_64-linux": "sha256-AD5kt1o035hdvYPyrvjiz2mcsXFJ7UJVEmbE9AJFDFs=", + "aarch64-linux": "sha256-f1gVXUpIRWC/I8dxIigdGGvPsju04MXVjZhtq5EYL64=", + "aarch64-darwin": "sha256-c+JRpjs2gsgQ2czyM6bTbYEdMbX/erOSQdX6TdrI59k=", + "x86_64-darwin": "sha256-iB7l4PtQQ9Mx/XAdtyayiLe6pZKhuXB9lpmrbTr+uWc=" } } From ceb4890ca3651899dd3e2b1564168ab098ac540d Mon Sep 17 00:00:00 2001 From: Deven Navani <4031146+devennavani@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:29:32 -0700 Subject: [PATCH 24/57] docs: add Modal provider setup (#39710) --- packages/web/src/content/docs/providers.mdx | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index d3c541311f..7931d4e03f 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1505,6 +1505,30 @@ To use Kimi K2 from Moonshot AI: --- +### Modal + +1. Create a [shared Endpoint](https://modal.com/endpoints) for the model you want to use. + +2. Create a [proxy token](https://modal.com/docs/guide/endpoints#proxy-tokens), then join its ID and secret with a period: + + ```txt + wk-.ws- + ``` + +3. Run the `/connect` command, search for **Modal**, and enter the combined proxy token. + + ```txt + /connect + ``` + +4. Run the `/models` command to select one of the endpoints in your Modal workspace. + + ```txt + /models + ``` + +--- + ### NVIDIA NVIDIA provides access to Nemotron models and many other open models through [build.nvidia.com](https://build.nvidia.com) for free. From e024e2ef92293a81a06b6cc418422c52207ce85d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:46:36 +1000 Subject: [PATCH 25/57] fix(app): align debug gutter text (#39782) --- packages/app/src/components/debug-bar.tsx | 54 +++++++++++++++-------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 67c9a89929..adadeda303 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -66,7 +66,7 @@ function Cell(props: {
- {props.label} -
-
- {props.value} +
+ {props.label} +
+
+ {props.value} +
) @@ -116,15 +124,25 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo aria-pressed={props.active} classList={{ "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true, - "min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline, + "min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline, "min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline, "bg-surface-raised-base text-text-strong": props.active, }} onClick={props.onClick} > - FOCUS - - {props.active ? "ON" : "OFF"} + + FOCUS + + {props.active ? "ON" : "OFF"} + ) From 7ea343cabd057e29d4f34d07d7aeae832293bfae Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:24:19 +0200 Subject: [PATCH 26/57] fix(app): prevent file tree tab clipping (#39770) --- packages/app/src/pages/session/session-side-panel.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 22c52e73fe..6f074fa203 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -59,6 +59,7 @@ import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/ses type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff +const FILE_TREE_WIDTH_MIN = 240 function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" @@ -104,13 +105,14 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) + const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" if (reviewOpen()) return "auto" - return `${layout.fileTree.width()}px` + return `${fileTreeWidth()}px` }) - const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) + const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) @@ -845,8 +847,8 @@ export function SessionSidePanel(props: { { props.size.touch() From d3f30df1806ea9f95190948c59f6e8b4c97301d0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:38:34 +0000 Subject: [PATCH 27/57] fix(app): open legacy picker at home (#39804) Co-authored-by: Brendan Allan --- packages/app/src/components/dialog-select-directory.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 2a6c877df1..96bcedb9db 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -57,9 +57,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [filter, setFilter] = createSignal("") let list: ListRef | undefined - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const missingHome = createMemo(() => !sync.data.path.home) const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), + () => (missingHome() ? true : undefined), async (): Promise => { if ((await sdk.protocol) !== "v1") return return sdk.client.path From db4dbaa2894a1a98e90e814416eb9f7c35711819 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:14:09 +0200 Subject: [PATCH 28/57] fix(app): prevent stale session tab reads (#39767) --- .../app/src/components/session/session-header.tsx | 12 ++++++------ packages/app/src/components/titlebar-tab-nav.tsx | 5 +++-- .../app/src/pages/new-session/new-session-view.tsx | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index da1770cd13..dc6cdaa6f3 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -289,9 +289,9 @@ export function SessionHeader() { return ( <> - + {(mount) => ( - +
- + {(keybind) => ( - {keybind()} + {keybind} )} @@ -319,9 +319,9 @@ export function SessionHeader() { )} - + {(mount) => ( - +
@@ -886,9 +886,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.opencodeZen.line2")}
{language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
@@ -967,9 +967,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - + {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1006,7 +1006,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
@@ -1077,7 +1079,9 @@ function ProviderConnection(props: {
{language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}

{language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} - + {language.t("provider.custom.description.suffix")}

diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index e428d4c2bb..bf5da751e2 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 0000000000..133e752eab --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, splitProps } from "solid-js" + +export interface ExternalLinkProps extends Omit, "href"> { + href: string +} + +export function ExternalLink(props: ExternalLinkProps) { + const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) + + return ( + + {local.children} + + ) +} diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx deleted file mode 100644 index 85f7efc539..0000000000 --- a/packages/app/src/components/link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" -import { usePlatform } from "@/context/platform" - -export interface LinkProps extends Omit, "href"> { - href: string -} - -export function Link(props: LinkProps) { - const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children", "class"]) - - return ( - { - if (!local.href) return - event.preventDefault() - platform.openLink(local.href) - }} - {...rest} - > - {local.children} - - ) -} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a20e66de3e..8adaa1f0fd 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -29,7 +29,7 @@ import { } from "@/context/settings" import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { Link } from "./link" +import { ExternalLink } from "./external-link" import { SettingsList } from "./settings-list" let demoSoundState = { @@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index e50bd316c9..b41da107ed 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" import { useSettings } from "@/context/settings" -import { Link } from "../link" +import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" @@ -150,9 +150,9 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }> description={ <> {language.t("settings.general.row.theme.description")}{" "} - + {language.t("common.learnMore")} - + } > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 6503503476..7d25e7c649 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index 017ac4cf8d..e41c2a584a 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -43,7 +43,7 @@ export function WindowsAppMenu(props: { runAction(entry.action) return } - if (entry.href) props.platform.openLink(entry.href) + if (entry.href) props.platform.openExternal(entry.href) } return ( diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 9bfa13d854..fb562a0bf5 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,6 +1,6 @@ import { createStore, reconcile } from "solid-js/store" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" -import { useParams, useSearchParams } from "@solidjs/router" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" import type { ServerSync } from "./server-sync" @@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const global = useGlobal() const server = useServer() const tabs = useTabs() + const navigate = useNavigate() const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi platform, settings, language, + navigate, }), }), owner ?? undefined, @@ -217,6 +219,7 @@ function createServerNotificationState(input: { platform: ReturnType settings: ReturnType language: ReturnType + navigate: (href: string) => void }) { const serverSDK = () => input.sdk const serverSync = () => input.sync @@ -353,7 +356,11 @@ function createServerNotificationState(input: { const href = `/${base64Encode(directory)}/session/${sessionID}` if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + void platform.notify( + language.t("notification.session.responseReady.title"), + session.title ?? sessionID, + () => input.navigate(href), + ) } }) } @@ -386,7 +393,7 @@ function createServerNotificationState(input: { (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, href) + void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) } }) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 4c374961a3..08686361c5 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -31,26 +31,23 @@ type PlatformBase = { /** App version */ version?: string - /** Open a URL in the default browser */ - openLink(url: string): void + /** Open a web or mail URL in the default system application */ + openExternal(url: string): void /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Open a local file URL in its default app (desktop only) */ + openLocalFile?(url: string): void + /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ revealPath?(path: string): Promise /** Restart the app */ restart(): Promise - /** Navigate back in history */ - back(): void - - /** Navigate forward in history */ - forward(): void - - /** Send a system notification (optional deep link) */ - notify(title: string, description?: string, href?: string): Promise + /** Send a system notification */ + notify(title: string, description?: string, onClick?: () => void): Promise /** Open a native attachment picker and read selected files sequentially (desktop only) */ openAttachmentPickerDialog?( @@ -91,9 +88,6 @@ type PlatformBase = { /** Set the preferred display backend (desktop only) */ setDisplayBackend?(backend: DisplayBackend): Promise - /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ - parseMarkdown?(markdown: string): Promise - /** Webview zoom level (desktop only) */ webviewZoom?: Accessor diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c41c1bb4b6..6823d1c6d7 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -6,7 +6,6 @@ import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" -import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -54,7 +53,7 @@ const setStorage = (key: string, value: string | null) => { const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) -const notify: Platform["notify"] = async (title, description, href) => { +const notify: Platform["notify"] = async (title, description, onClick) => { if (!("Notification" in window)) return const permission = @@ -73,21 +72,17 @@ const notify: Platform["notify"] = async (title, description, href) => { }) notification.onclick = () => { - handleNotificationClick(href) + window.focus() + onClick?.() notification.close() } } -const openLink: Platform["openLink"] = (url) => { - window.open(url, "_blank") -} - -const back: Platform["back"] = () => { - window.history.back() -} - -const forward: Platform["forward"] = () => { - window.history.forward() +const openExternal: Platform["openExternal"] = (value) => { + if (!URL.canParse(value)) return + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return + window.open(url.href, "_blank", "noopener,noreferrer") } const restart: Platform["restart"] = async () => { @@ -122,9 +117,7 @@ const clearAuthToken = () => { const platform: Platform = { platform: "web", version: pkg.version, - openLink, - back, - forward, + openExternal, restart, notify, getDefaultServer: async () => { diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d18e05fc9c..2267ebf081 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -27,4 +27,3 @@ export { type WslServersState, } from "./wsl/types" export { ServerConnection } from "./context/server" -export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 77c120e57b..a0b3576396 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -63,6 +63,7 @@ export function DirectoryDataProvider( navigate(href(sessionID))} onSessionHref={href} > diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 86a997b39d..2fc6095412 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -354,7 +354,7 @@ export const ErrorPage: Component = (props) => {