From a4f8d66a9bd8053ae797d129c139363b4fdd1707 Mon Sep 17 00:00:00 2001 From: Wang Siyuan Date: Thu, 12 Mar 2026 22:45:46 +0800 Subject: [PATCH 001/145] docs: clarify subagent session navigation keybinds (#16455) --- packages/web/src/content/docs/agents.mdx | 11 +++++++---- packages/web/src/content/docs/keybinds.mdx | 8 +++++--- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/packages/web/src/content/docs/agents.mdx b/packages/web/src/content/docs/agents.mdx index 53de8af5f0c..87646421248 100644 --- a/packages/web/src/content/docs/agents.mdx +++ b/packages/web/src/content/docs/agents.mdx @@ -120,11 +120,14 @@ Hidden system agent that creates session summaries. It runs automatically and is @general help me search for this function ``` -3. **Navigation between sessions**: When subagents create their own child sessions, you can navigate between the parent session and all child sessions using: - - **\+Right** (or your configured `session_child_cycle` keybind) to cycle forward through parent → child1 → child2 → ... → parent - - **\+Left** (or your configured `session_child_cycle_reverse` keybind) to cycle backward through parent ← child1 ← child2 ← ... ← parent +3. **Navigation between sessions**: When subagents create child sessions, use `session_child_first` (default: **\+Down**) to enter the first child session from the parent. - This allows you to seamlessly switch between the main conversation and specialized subagent work. +4. Once you are in a child session, use: + - `session_child_cycle` (default: **Right**) to cycle to the next child session + - `session_child_cycle_reverse` (default: **Left**) to cycle to the previous child session + - `session_parent` (default: **Up**) to return to the parent session + + This lets you switch between the main conversation and specialized subagent work. --- diff --git a/packages/web/src/content/docs/keybinds.mdx b/packages/web/src/content/docs/keybinds.mdx index 54c15e8621b..74ef30577e1 100644 --- a/packages/web/src/content/docs/keybinds.mdx +++ b/packages/web/src/content/docs/keybinds.mdx @@ -29,9 +29,9 @@ OpenCode has a list of keybinds that you can customize through `tui.json`. "session_interrupt": "escape", "session_compact": "c", "session_child_first": "down", - "session_child_cycle": "right", - "session_child_cycle_reverse": "left", - "session_parent": "up", + "session_child_cycle": "right", + "session_child_cycle_reverse": "left", + "session_parent": "up", "messages_page_up": "pageup,ctrl+alt+b", "messages_page_down": "pagedown,ctrl+alt+f", "messages_line_up": "ctrl+alt+y", @@ -114,6 +114,8 @@ By default, `ctrl+x` is the leader key and most actions require you to first pre You don't need to use a leader key for your keybinds but we recommend doing so. +Some navigation keybinds intentionally do not use the leader key by default. For subagent sessions, the defaults are `session_child_first` = `\down`, `session_child_cycle` = `right`, `session_child_cycle_reverse` = `left`, and `session_parent` = `up`. + --- ## Disable keybind From 1cb7df71596ff80bb13e0606da4d42abef02f12f Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 10:48:17 -0400 Subject: [PATCH 002/145] refactor(provider): flow branded ProviderID/ModelID through internal signatures (#17182) --- bun.lock | 16 ++- package.json | 2 +- packages/app/package.json | 2 +- packages/desktop-electron/package.json | 2 +- packages/opencode/src/acp/agent.ts | 43 ++++---- packages/opencode/src/acp/types.ts | 9 +- packages/opencode/src/agent/agent.ts | 2 +- packages/opencode/src/cli/cmd/models.ts | 7 +- packages/opencode/src/permission/index.ts | 97 +++++++++---------- packages/opencode/src/permission/next.ts | 35 +++---- packages/opencode/src/plugin/codex.ts | 2 +- packages/opencode/src/provider/error.ts | 5 +- packages/opencode/src/provider/provider.ts | 51 +++++----- packages/opencode/src/provider/schema.ts | 12 +++ packages/opencode/src/pty/index.ts | 14 +-- packages/opencode/src/question/index.ts | 39 ++++---- .../src/server/routes/experimental.ts | 3 +- packages/opencode/src/session/prompt.ts | 10 +- packages/opencode/src/share/share-next.ts | 3 +- packages/opencode/src/tool/batch.ts | 3 +- packages/opencode/src/tool/registry.ts | 7 +- .../opencode/test/provider/provider.test.ts | 37 +++---- .../opencode/test/pty/pty-session.test.ts | 21 ++-- packages/opencode/test/session/llm.test.ts | 10 +- 24 files changed, 227 insertions(+), 205 deletions(-) diff --git a/bun.lock b/bun.lock index 248caffa8d2..6140c3497a5 100644 --- a/bun.lock +++ b/bun.lock @@ -46,7 +46,7 @@ "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", @@ -227,7 +227,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "electron-log": "^5", "electron-store": "^10", "electron-updater": "^6", @@ -614,7 +614,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.16-ea816b6", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "fuzzysort": "3.1.0", "hono": "4.10.7", "hono-openapi": "1.1.2", @@ -2738,7 +2738,7 @@ "ee-first": ["ee-first@1.1.1", "", {}, "sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow=="], - "effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="], + "effect": ["effect@4.0.0-beta.31", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-w3QwJnlaLtWWiUSzhCXUTIisnULPsxLzpO6uqaBFjXybKx6FvCqsLJT6v4dV7G9eA9jeTtG6Gv7kF+jGe3HxzA=="], "ejs": ["ejs@3.1.10", "", { "dependencies": { "jake": "^10.8.5" }, "bin": { "ejs": "bin/cli.js" } }, "sha512-UeJmFfOrAQS8OJWPZ4qtgHyWExa088/MtK5UEyoJGFH67cDEXkZSviOiKRCZ4Xij0zxI3JECgYs3oKx+AizQBA=="], @@ -5226,6 +5226,10 @@ "@solidjs/start/vite": ["vite@7.1.10", "", { "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-CmuvUBzVJ/e3HGxhg6cYk88NGgTnBoOo7ogtfJJ0fefUWAxN/WDSUa50o+oVBxuIhO8FoEZW0j2eW7sfjs5EtA=="], + "@standard-community/standard-json/effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="], + + "@standard-community/standard-openapi/effect": ["effect@4.0.0-beta.29", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "fast-check": "^4.5.3", "find-my-way-ts": "^0.1.6", "ini": "^6.0.0", "kubernetes-types": "^1.30.0", "msgpackr": "^1.11.8", "multipasta": "^0.2.7", "toml": "^3.0.0", "uuid": "^13.0.0", "yaml": "^2.8.2" } }, "sha512-7UoBAEiktoS81XLMX/39Mq/Ymq8whxmqFpsI0MEYdMlbDcbytzQlyuyhvrwEIdrd9qrqa8DZ5mKblWasamryqw=="], + "@tailwindcss/oxide/detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="], "@tailwindcss/oxide-wasm32-wasi/@emnapi/core": ["@emnapi/core@1.8.1", "", { "dependencies": { "@emnapi/wasi-threads": "1.1.0", "tslib": "^2.4.0" }, "bundled": true }, "sha512-AvT9QFpxK0Zd8J0jopedNm+w/2fIzvtPKPjqyw9jwvBaReTTqPBk9Hixaz7KbjimP+QNz605/XnjFcDAL2pqBg=="], @@ -6124,6 +6128,10 @@ "@solidjs/start/shiki/@shikijs/types": ["@shikijs/types@1.29.2", "", { "dependencies": { "@shikijs/vscode-textmate": "^10.0.1", "@types/hast": "^3.0.4" } }, "sha512-VJjK0eIijTZf0QSTODEXCqinjBn0joAHQ+aPSBzrv4O2d/QSbsMw+ZeSRx03kV34Hy7NzUvV/7NqfYGRLrASmw=="], + "@standard-community/standard-json/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + + "@standard-community/standard-openapi/effect/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "@tailwindcss/oxide-wasm32-wasi/@napi-rs/wasm-runtime/@tybys/wasm-util": ["@tybys/wasm-util@0.10.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-9tTaPJLSiejZKx+Bmog4uSubteqTvFrVrURwkmHixBo0G4seD0zUxp98E1DzUBJxLQ3NPwXrGKDiVjwx/DpPsg=="], "@vitest/expect/@vitest/utils/@vitest/pretty-format": ["@vitest/pretty-format@3.2.4", "", { "dependencies": { "tinyrainbow": "^2.0.0" } }, "sha512-IVNZik8IVRJRTr9fxlitMKeJeXFFFN0JaB9PHPGQ8NKQbGpfjlTx9zO4RefN8gp7eqjNy8nyK3NZmBzOPeIxtA=="], diff --git a/package.json b/package.json index d1358a39663..97087c0e76f 100644 --- a/package.json +++ b/package.json @@ -43,7 +43,7 @@ "dompurify": "3.3.1", "drizzle-kit": "1.0.0-beta.16-ea816b6", "drizzle-orm": "1.0.0-beta.16-ea816b6", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "ai": "5.0.124", "hono": "4.10.7", "hono-openapi": "1.1.2", diff --git a/packages/app/package.json b/packages/app/package.json index f8e2bda5147..1e69a64f78c 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -56,7 +56,7 @@ "@solidjs/router": "catalog:", "@thisbeyond/solid-dnd": "0.7.5", "diff": "catalog:", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "fuzzysort": "catalog:", "ghostty-web": "github:anomalyco/ghostty-web#main", "luxon": "catalog:", diff --git a/packages/desktop-electron/package.json b/packages/desktop-electron/package.json index 4f67f81a684..b2746213a91 100644 --- a/packages/desktop-electron/package.json +++ b/packages/desktop-electron/package.json @@ -30,7 +30,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@solidjs/router": "0.15.4", - "effect": "4.0.0-beta.29", + "effect": "4.0.0-beta.31", "electron-log": "^5", "electron-store": "^10", "electron-updater": "^6", diff --git a/packages/opencode/src/acp/agent.ts b/packages/opencode/src/acp/agent.ts index 1b467bb9a5d..2a6bbbb1e44 100644 --- a/packages/opencode/src/acp/agent.ts +++ b/packages/opencode/src/acp/agent.ts @@ -35,7 +35,7 @@ import { Hash } from "../util/hash" import { ACPSessionManager } from "./session" import type { ACPConfig } from "./types" import { Provider } from "../provider/provider" -import { ProviderID } from "../provider/schema" +import { ModelID, ProviderID } from "../provider/schema" import { Agent as AgentModule } from "../agent/agent" import { Installation } from "@/installation" import { MessageV2 } from "@/session/message-v2" @@ -56,8 +56,8 @@ export namespace ACP { async function getContextLimit( sdk: OpencodeClient, - providerID: string, - modelID: string, + providerID: ProviderID, + modelID: ModelID, directory: string, ): Promise { const providers = await sdk.config @@ -97,7 +97,8 @@ export namespace ACP { if (!lastAssistant) return const msg = lastAssistant.info - const size = await getContextLimit(sdk, msg.providerID, msg.modelID, directory) + if (!msg.providerID || !msg.modelID) return + const size = await getContextLimit(sdk, ProviderID.make(msg.providerID), ModelID.make(msg.modelID), directory) if (!size) { // Cannot calculate usage without known context size @@ -637,8 +638,8 @@ export namespace ACP { if (lastUser?.role === "user") { result.models.currentModelId = `${lastUser.model.providerID}/${lastUser.model.modelID}` this.sessionManager.setModel(sessionId, { - providerID: lastUser.model.providerID, - modelID: lastUser.model.modelID, + providerID: ProviderID.make(lastUser.model.providerID), + modelID: ModelID.make(lastUser.model.modelID), }) if (result.modes?.availableModes.some((m) => m.id === lastUser.agent)) { result.modes.currentModeId = lastUser.agent @@ -1526,7 +1527,7 @@ export namespace ACP { } } - async function defaultModel(config: ACPConfig, cwd?: string) { + async function defaultModel(config: ACPConfig, cwd?: string): Promise<{ providerID: ProviderID; modelID: ModelID }> { const sdk = config.sdk const configured = config.defaultModel if (configured) return configured @@ -1538,11 +1539,7 @@ export namespace ACP { .then((resp) => { const cfg = resp.data if (!cfg || !cfg.model) return undefined - const parsed = Provider.parseModel(cfg.model) - return { - providerID: parsed.providerID, - modelID: parsed.modelID, - } + return Provider.parseModel(cfg.model) }) .catch((error) => { log.error("failed to load user config for default model", { error }) @@ -1567,13 +1564,13 @@ export namespace ACP { const opencodeProvider = providers.find((p) => p.id === "opencode") if (opencodeProvider) { if (opencodeProvider.models["big-pickle"]) { - return { providerID: "opencode", modelID: "big-pickle" } + return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } } const [best] = Provider.sort(Object.values(opencodeProvider.models)) if (best) { return { - providerID: best.providerID, - modelID: best.id, + providerID: ProviderID.make(best.providerID), + modelID: ModelID.make(best.id), } } } @@ -1582,14 +1579,14 @@ export namespace ACP { const [best] = Provider.sort(models) if (best) { return { - providerID: best.providerID, - modelID: best.id, + providerID: ProviderID.make(best.providerID), + modelID: ModelID.make(best.id), } } if (specified) return specified - return { providerID: "opencode", modelID: "big-pickle" } + return { providerID: ProviderID.opencode, modelID: ModelID.make("big-pickle") } } function parseUri( @@ -1652,7 +1649,7 @@ export namespace ACP { function modelVariantsFromProviders( providers: Array<{ id: string; models: Record }> }>, - model: { providerID: string; modelID: string }, + model: { providerID: ProviderID; modelID: ModelID }, ): string[] { const provider = providers.find((entry) => entry.id === model.providerID) if (!provider) return [] @@ -1688,7 +1685,7 @@ export namespace ACP { } function formatModelIdWithVariant( - model: { providerID: string; modelID: string }, + model: { providerID: ProviderID; modelID: ModelID }, variant: string | undefined, availableVariants: string[], includeVariant: boolean, @@ -1699,7 +1696,7 @@ export namespace ACP { } function buildVariantMeta(input: { - model: { providerID: string; modelID: string } + model: { providerID: ProviderID; modelID: ModelID } variant?: string availableVariants: string[] }) { @@ -1715,7 +1712,7 @@ export namespace ACP { function parseModelSelection( modelId: string, providers: Array<{ id: string; models: Record }> }>, - ): { model: { providerID: string; modelID: string }; variant?: string } { + ): { model: { providerID: ProviderID; modelID: ModelID }; variant?: string } { const parsed = Provider.parseModel(modelId) const provider = providers.find((p) => p.id === parsed.providerID) if (!provider) { @@ -1735,7 +1732,7 @@ export namespace ACP { const baseModelInfo = provider.models[baseModelId] if (baseModelInfo?.variants && candidateVariant in baseModelInfo.variants) { return { - model: { providerID: parsed.providerID, modelID: baseModelId }, + model: { providerID: parsed.providerID, modelID: ModelID.make(baseModelId) }, variant: candidateVariant, } } diff --git a/packages/opencode/src/acp/types.ts b/packages/opencode/src/acp/types.ts index de8ac508122..2c3e886bc18 100644 --- a/packages/opencode/src/acp/types.ts +++ b/packages/opencode/src/acp/types.ts @@ -1,5 +1,6 @@ import type { McpServer } from "@agentclientprotocol/sdk" import type { OpencodeClient } from "@opencode-ai/sdk/v2" +import type { ProviderID, ModelID } from "../provider/schema" export interface ACPSessionState { id: string @@ -7,8 +8,8 @@ export interface ACPSessionState { mcpServers: McpServer[] createdAt: Date model?: { - providerID: string - modelID: string + providerID: ProviderID + modelID: ModelID } variant?: string modeId?: string @@ -17,7 +18,7 @@ export interface ACPSessionState { export interface ACPConfig { sdk: OpencodeClient defaultModel?: { - providerID: string - modelID: string + providerID: ProviderID + modelID: ModelID } } diff --git a/packages/opencode/src/agent/agent.ts b/packages/opencode/src/agent/agent.ts index 343f434375d..b247bb7fa25 100644 --- a/packages/opencode/src/agent/agent.ts +++ b/packages/opencode/src/agent/agent.ts @@ -281,7 +281,7 @@ export namespace Agent { return primaryVisible.name } - export async function generate(input: { description: string; model?: { providerID: string; modelID: string } }) { + export async function generate(input: { description: string; model?: { providerID: ProviderID; modelID: ModelID } }) { const cfg = await Config.get() const defaultModel = input.model ?? (await Provider.defaultModel()) const model = await Provider.getModel(defaultModel.providerID, defaultModel.modelID) diff --git a/packages/opencode/src/cli/cmd/models.ts b/packages/opencode/src/cli/cmd/models.ts index 156dae91c67..8395d4628e4 100644 --- a/packages/opencode/src/cli/cmd/models.ts +++ b/packages/opencode/src/cli/cmd/models.ts @@ -1,6 +1,7 @@ import type { Argv } from "yargs" import { Instance } from "../../project/instance" import { Provider } from "../../provider/provider" +import { ProviderID } from "../../provider/schema" import { ModelsDev } from "../../provider/models" import { cmd } from "./cmd" import { UI } from "../ui" @@ -36,7 +37,7 @@ export const ModelsCommand = cmd({ async fn() { const providers = await Provider.list() - function printModels(providerID: string, verbose?: boolean) { + function printModels(providerID: ProviderID, verbose?: boolean) { const provider = providers[providerID] const sortedModels = Object.entries(provider.models).sort(([a], [b]) => a.localeCompare(b)) for (const [modelID, model] of sortedModels) { @@ -56,7 +57,7 @@ export const ModelsCommand = cmd({ return } - printModels(args.provider, args.verbose) + printModels(ProviderID.make(args.provider), args.verbose) return } @@ -69,7 +70,7 @@ export const ModelsCommand = cmd({ }) for (const providerID of providerIDs) { - printModels(providerID, args.verbose) + printModels(ProviderID.make(providerID), args.verbose) } }, }) diff --git a/packages/opencode/src/permission/index.ts b/packages/opencode/src/permission/index.ts index d8283a1faef..565ccf20d1a 100644 --- a/packages/opencode/src/permission/index.ts +++ b/packages/opencode/src/permission/index.ts @@ -15,9 +15,13 @@ export namespace Permission { return pattern === undefined ? [type] : Array.isArray(pattern) ? pattern : [pattern] } - function covered(keys: string[], approved: Record): boolean { - const pats = Object.keys(approved) - return keys.every((k) => pats.some((p) => Wildcard.match(k, p))) + function covered(keys: string[], approved: Map): boolean { + return keys.every((k) => { + for (const p of approved.keys()) { + if (Wildcard.match(k, p)) return true + } + return false + }) } export const Info = z @@ -39,6 +43,12 @@ export namespace Permission { }) export type Info = z.infer + interface PendingEntry { + info: Info + resolve: () => void + reject: (e: any) => void + } + export const Event = { Updated: BusEvent.define("permission.updated", Info), Replied: BusEvent.define( @@ -52,31 +62,13 @@ export namespace Permission { } const state = Instance.state( - () => { - const pending: { - [sessionID: string]: { - [permissionID: string]: { - info: Info - resolve: () => void - reject: (e: any) => void - } - } - } = {} - - const approved: { - [sessionID: string]: { - [permissionID: string]: boolean - } - } = {} - - return { - pending, - approved, - } - }, + () => ({ + pending: new Map>(), + approved: new Map>(), + }), async (state) => { - for (const pending of Object.values(state.pending)) { - for (const item of Object.values(pending)) { + for (const session of state.pending.values()) { + for (const item of session.values()) { item.reject(new RejectedError(item.info.sessionID, item.info.id, item.info.callID, item.info.metadata)) } } @@ -90,8 +82,8 @@ export namespace Permission { export function list() { const { pending } = state() const result: Info[] = [] - for (const items of Object.values(pending)) { - for (const item of Object.values(items)) { + for (const session of pending.values()) { + for (const item of session.values()) { result.push(item.info) } } @@ -114,9 +106,9 @@ export namespace Permission { toolCallID: input.callID, pattern: input.pattern, }) - const approvedForSession = approved[input.sessionID] || {} + const approvedForSession = approved.get(input.sessionID) const keys = toKeys(input.pattern, input.type) - if (covered(keys, approvedForSession)) return + if (approvedForSession && covered(keys, approvedForSession)) return const info: Info = { id: PermissionID.ascending(), type: input.type, @@ -142,13 +134,13 @@ export namespace Permission { return } - pending[input.sessionID] = pending[input.sessionID] || {} + if (!pending.has(input.sessionID)) pending.set(input.sessionID, new Map()) return new Promise((resolve, reject) => { - pending[input.sessionID][info.id] = { + pending.get(input.sessionID)!.set(info.id, { info, resolve, reject, - } + }) Bus.publish(Event.Updated, info) }) } @@ -159,9 +151,11 @@ export namespace Permission { export function respond(input: { sessionID: Info["sessionID"]; permissionID: Info["id"]; response: Response }) { log.info("response", input) const { pending, approved } = state() - const match = pending[input.sessionID]?.[input.permissionID] - if (!match) return - delete pending[input.sessionID][input.permissionID] + const session = pending.get(input.sessionID) + const match = session?.get(input.permissionID) + if (!session || !match) return + session.delete(input.permissionID) + if (session.size === 0) pending.delete(input.sessionID) Bus.publish(Event.Replied, { sessionID: input.sessionID, permissionID: input.permissionID, @@ -173,30 +167,35 @@ export namespace Permission { } match.resolve() if (input.response === "always") { - approved[input.sessionID] = approved[input.sessionID] || {} + if (!approved.has(input.sessionID)) approved.set(input.sessionID, new Map()) + const approvedSession = approved.get(input.sessionID)! const approveKeys = toKeys(match.info.pattern, match.info.type) for (const k of approveKeys) { - approved[input.sessionID][k] = true + approvedSession.set(k, true) } - const items = pending[input.sessionID] + const items = pending.get(input.sessionID) if (!items) return - for (const item of Object.values(items)) { + const toRespond: Info[] = [] + for (const item of items.values()) { const itemKeys = toKeys(item.info.pattern, item.info.type) - if (covered(itemKeys, approved[input.sessionID])) { - respond({ - sessionID: item.info.sessionID, - permissionID: item.info.id, - response: input.response, - }) + if (covered(itemKeys, approvedSession)) { + toRespond.push(item.info) } } + for (const item of toRespond) { + respond({ + sessionID: item.sessionID, + permissionID: item.id, + response: input.response, + }) + } } } export class RejectedError extends Error { constructor( - public readonly sessionID: string, - public readonly permissionID: string, + public readonly sessionID: SessionID, + public readonly permissionID: PermissionID, public readonly toolCallID?: string, public readonly metadata?: Record, public readonly reason?: string, diff --git a/packages/opencode/src/permission/next.ts b/packages/opencode/src/permission/next.ts index 9b8910144bf..3ef3a02304d 100644 --- a/packages/opencode/src/permission/next.ts +++ b/packages/opencode/src/permission/next.ts @@ -108,6 +108,12 @@ export namespace PermissionNext { ), } + interface PendingEntry { + info: Request + resolve: () => void + reject: (e: any) => void + } + const state = Instance.state(() => { const projectID = Instance.project.id const row = Database.use((db) => @@ -115,17 +121,8 @@ export namespace PermissionNext { ) const stored = row?.data ?? ([] as Ruleset) - const pending: Record< - string, - { - info: Request - resolve: () => void - reject: (e: any) => void - } - > = {} - return { - pending, + pending: new Map(), approved: stored, } }) @@ -149,11 +146,11 @@ export namespace PermissionNext { id, ...request, } - s.pending[id] = { + s.pending.set(id, { info, resolve, reject, - } + }) Bus.publish(Event.Asked, info) }) } @@ -170,9 +167,9 @@ export namespace PermissionNext { }), async (input) => { const s = await state() - const existing = s.pending[input.requestID] + const existing = s.pending.get(input.requestID) if (!existing) return - delete s.pending[input.requestID] + s.pending.delete(input.requestID) Bus.publish(Event.Replied, { sessionID: existing.info.sessionID, requestID: existing.info.id, @@ -182,9 +179,9 @@ export namespace PermissionNext { existing.reject(input.message ? new CorrectedError(input.message) : new RejectedError()) // Reject all other pending permissions for this session const sessionID = existing.info.sessionID - for (const [id, pending] of Object.entries(s.pending)) { + for (const [id, pending] of s.pending) { if (pending.info.sessionID === sessionID) { - delete s.pending[id] + s.pending.delete(id) Bus.publish(Event.Replied, { sessionID: pending.info.sessionID, requestID: pending.info.id, @@ -211,13 +208,13 @@ export namespace PermissionNext { existing.resolve() const sessionID = existing.info.sessionID - for (const [id, pending] of Object.entries(s.pending)) { + for (const [id, pending] of s.pending) { if (pending.info.sessionID !== sessionID) continue const ok = pending.info.patterns.every( (pattern) => evaluate(pending.info.permission, pattern, s.approved).action === "allow", ) if (!ok) continue - delete s.pending[id] + s.pending.delete(id) Bus.publish(Event.Replied, { sessionID: pending.info.sessionID, requestID: pending.info.id, @@ -283,6 +280,6 @@ export namespace PermissionNext { export async function list() { const s = await state() - return Object.values(s.pending).map((x) => x.info) + return Array.from(s.pending.values(), (x) => x.info) } } diff --git a/packages/opencode/src/plugin/codex.ts b/packages/opencode/src/plugin/codex.ts index 6b0b73208ec..37bcdd74fa2 100644 --- a/packages/opencode/src/plugin/codex.ts +++ b/packages/opencode/src/plugin/codex.ts @@ -377,7 +377,7 @@ export async function CodexAuthPlugin(input: PluginInput): Promise { if (!provider.models["gpt-5.3-codex"]) { const model = { id: ModelID.make("gpt-5.3-codex"), - providerID: ProviderID.make("openai"), + providerID: ProviderID.openai, api: { id: "gpt-5.3-codex", url: "https://chatgpt.com/backend-api/codex", diff --git a/packages/opencode/src/provider/error.ts b/packages/opencode/src/provider/error.ts index c9987aef45c..c9f83cd8c14 100644 --- a/packages/opencode/src/provider/error.ts +++ b/packages/opencode/src/provider/error.ts @@ -1,6 +1,7 @@ import { APICallError } from "ai" import { STATUS_CODES } from "http" import { iife } from "@/util/iife" +import type { ProviderID } from "./schema" export namespace ProviderError { // Adapted from overflow detection patterns in: @@ -40,7 +41,7 @@ export namespace ProviderError { return /^4(00|13)\s*(status code)?\s*\(no body\)/i.test(message) } - function message(providerID: string, e: APICallError) { + function message(providerID: ProviderID, e: APICallError) { return iife(() => { const msg = e.message if (msg === "") { @@ -164,7 +165,7 @@ export namespace ProviderError { metadata?: Record } - export function parseAPICallError(input: { providerID: string; error: APICallError }): ParsedAPICallError { + export function parseAPICallError(input: { providerID: ProviderID; error: APICallError }): ParsedAPICallError { const m = message(input.providerID, input.error) if (isOverflow(m) || input.error.statusCode === 413) { return { diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 3cca3afa93e..27901032952 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -845,7 +845,7 @@ export namespace Provider { const disabled = new Set(config.disabled_providers ?? []) const enabled = config.enabled_providers ? new Set(config.enabled_providers) : null - function isProviderAllowed(providerID: string): boolean { + function isProviderAllowed(providerID: ProviderID): boolean { if (enabled && !enabled.has(providerID)) return false if (disabled.has(providerID)) return false return true @@ -867,16 +867,16 @@ export namespace Provider { const githubCopilot = database["github-copilot"] database["github-copilot-enterprise"] = { ...githubCopilot, - id: ProviderID.make("github-copilot-enterprise"), + id: ProviderID.githubCopilotEnterprise, name: "GitHub Copilot Enterprise", models: mapValues(githubCopilot.models, (model) => ({ ...model, - providerID: ProviderID.make("github-copilot-enterprise"), + providerID: ProviderID.githubCopilotEnterprise, })), } } - function mergeProvider(providerID: string, provider: Partial) { + function mergeProvider(providerID: ProviderID, provider: Partial) { const existing = providers[providerID] if (existing) { // @ts-expect-error @@ -974,7 +974,8 @@ export namespace Provider { // load env const env = Env.all() - for (const [providerID, provider] of Object.entries(database)) { + for (const [id, provider] of Object.entries(database)) { + const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue const apiKey = provider.env.map((item) => env[item]).find(Boolean) if (!apiKey) continue @@ -985,7 +986,8 @@ export namespace Provider { } // load apikeys - for (const [providerID, provider] of Object.entries(await Auth.all())) { + for (const [id, provider] of Object.entries(await Auth.all())) { + const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue if (provider.type === "api") { mergeProvider(providerID, { @@ -997,7 +999,7 @@ export namespace Provider { for (const plugin of await Plugin.list()) { if (!plugin.auth) continue - const providerID = plugin.auth.provider + const providerID = ProviderID.make(plugin.auth.provider) if (disabled.has(providerID)) continue // For github-copilot plugin, check if auth exists for either github-copilot or github-copilot-enterprise @@ -1006,7 +1008,7 @@ export namespace Provider { if (auth) hasAuth = true // Special handling for github-copilot: also check for enterprise auth - if (providerID === "github-copilot" && !hasAuth) { + if (providerID === ProviderID.githubCopilot && !hasAuth) { const enterpriseAuth = await Auth.get("github-copilot-enterprise") if (enterpriseAuth) hasAuth = true } @@ -1023,8 +1025,8 @@ export namespace Provider { } // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists - if (providerID === "github-copilot") { - const enterpriseProviderID = "github-copilot-enterprise" + if (providerID === ProviderID.githubCopilot) { + const enterpriseProviderID = ProviderID.githubCopilotEnterprise if (!disabled.has(enterpriseProviderID)) { const enterpriseAuth = await Auth.get(enterpriseProviderID) if (enterpriseAuth) { @@ -1042,7 +1044,8 @@ export namespace Provider { } } - for (const [providerID, fn] of Object.entries(CUSTOM_LOADERS)) { + for (const [id, fn] of Object.entries(CUSTOM_LOADERS)) { + const providerID = ProviderID.make(id) if (disabled.has(providerID)) continue const data = database[providerID] if (!data) { @@ -1059,7 +1062,8 @@ export namespace Provider { } // load config - for (const [providerID, provider] of configProviders) { + for (const [id, provider] of configProviders) { + const providerID = ProviderID.make(id) const partial: Partial = { source: "config" } if (provider.env) partial.env = provider.env if (provider.name) partial.name = provider.name @@ -1067,7 +1071,8 @@ export namespace Provider { mergeProvider(providerID, partial) } - for (const [providerID, provider] of Object.entries(providers)) { + for (const [id, provider] of Object.entries(providers)) { + const providerID = ProviderID.make(id) if (!isProviderAllowed(providerID)) { delete providers[providerID] continue @@ -1077,7 +1082,7 @@ export namespace Provider { for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID - if (modelID === "gpt-5-chat-latest" || (providerID === "openrouter" && modelID === "openai/gpt-5-chat")) + if (modelID === "gpt-5-chat-latest" || (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")) delete provider.models[modelID] if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] if (model.status === "deprecated") delete provider.models[modelID] @@ -1230,11 +1235,11 @@ export namespace Provider { } } - export async function getProvider(providerID: string) { + export async function getProvider(providerID: ProviderID) { return state().then((s) => s.providers[providerID]) } - export async function getModel(providerID: string, modelID: string) { + export async function getModel(providerID: ProviderID, modelID: ModelID) { const s = await state() const provider = s.providers[providerID] if (!provider) { @@ -1281,7 +1286,7 @@ export namespace Provider { } } - export async function closest(providerID: string, query: string[]) { + export async function closest(providerID: ProviderID, query: string[]) { const s = await state() const provider = s.providers[providerID] if (!provider) return undefined @@ -1296,7 +1301,7 @@ export namespace Provider { } } - export async function getSmallModel(providerID: string) { + export async function getSmallModel(providerID: ProviderID) { const cfg = await Config.get() if (cfg.small_model) { @@ -1323,7 +1328,7 @@ export namespace Provider { priority = ["gpt-5-mini", "claude-haiku-4.5", ...priority] } for (const item of priority) { - if (providerID === "amazon-bedrock") { + if (providerID === ProviderID.amazonBedrock) { const crossRegionPrefixes = ["global.", "us.", "eu."] const candidates = Object.keys(provider.models).filter((m) => m.includes(item)) @@ -1332,22 +1337,22 @@ export namespace Provider { // 2. User's region prefix (us., eu.) // 3. Unprefixed model const globalMatch = candidates.find((m) => m.startsWith("global.")) - if (globalMatch) return getModel(providerID, globalMatch) + if (globalMatch) return getModel(providerID, ModelID.make(globalMatch)) const region = provider.options?.region if (region) { const regionPrefix = region.split("-")[0] if (regionPrefix === "us" || regionPrefix === "eu") { const regionalMatch = candidates.find((m) => m.startsWith(`${regionPrefix}.`)) - if (regionalMatch) return getModel(providerID, regionalMatch) + if (regionalMatch) return getModel(providerID, ModelID.make(regionalMatch)) } } const unprefixed = candidates.find((m) => !crossRegionPrefixes.some((p) => m.startsWith(p))) - if (unprefixed) return getModel(providerID, unprefixed) + if (unprefixed) return getModel(providerID, ModelID.make(unprefixed)) } else { for (const model of Object.keys(provider.models)) { - if (model.includes(item)) return getModel(providerID, model) + if (model.includes(item)) return getModel(providerID, ModelID.make(model)) } } } diff --git a/packages/opencode/src/provider/schema.ts b/packages/opencode/src/provider/schema.ts index 4d975b8d7ea..9eac235ceb9 100644 --- a/packages/opencode/src/provider/schema.ts +++ b/packages/opencode/src/provider/schema.ts @@ -11,6 +11,18 @@ export const ProviderID = providerIdSchema.pipe( withStatics((schema: typeof providerIdSchema) => ({ make: (id: string) => schema.makeUnsafe(id), zod: z.string().pipe(z.custom()), + // Well-known providers + opencode: schema.makeUnsafe("opencode"), + anthropic: schema.makeUnsafe("anthropic"), + openai: schema.makeUnsafe("openai"), + google: schema.makeUnsafe("google"), + googleVertex: schema.makeUnsafe("google-vertex"), + githubCopilot: schema.makeUnsafe("github-copilot"), + githubCopilotEnterprise: schema.makeUnsafe("github-copilot-enterprise"), + amazonBedrock: schema.makeUnsafe("amazon-bedrock"), + azure: schema.makeUnsafe("azure"), + openrouter: schema.makeUnsafe("openrouter"), + mistral: schema.makeUnsafe("mistral"), })), ) diff --git a/packages/opencode/src/pty/index.ts b/packages/opencode/src/pty/index.ts index 077a9dca56d..d6bc4973a06 100644 --- a/packages/opencode/src/pty/index.ts +++ b/packages/opencode/src/pty/index.ts @@ -91,7 +91,7 @@ export namespace Pty { } const state = Instance.state( - () => new Map(), + () => new Map(), async (sessions) => { for (const session of sessions.values()) { try { @@ -113,7 +113,7 @@ export namespace Pty { return Array.from(state().values()).map((s) => s.info) } - export function get(id: string) { + export function get(id: PtyID) { return state().get(id)?.info } @@ -205,7 +205,7 @@ export namespace Pty { return info } - export async function update(id: string, input: UpdateInput) { + export async function update(id: PtyID, input: UpdateInput) { const session = state().get(id) if (!session) return if (input.title) { @@ -218,7 +218,7 @@ export namespace Pty { return session.info } - export async function remove(id: string) { + export async function remove(id: PtyID) { const session = state().get(id) if (!session) return state().delete(id) @@ -237,21 +237,21 @@ export namespace Pty { Bus.publish(Event.Deleted, { id: session.info.id }) } - export function resize(id: string, cols: number, rows: number) { + export function resize(id: PtyID, cols: number, rows: number) { const session = state().get(id) if (session && session.info.status === "running") { session.process.resize(cols, rows) } } - export function write(id: string, data: string) { + export function write(id: PtyID, data: string) { const session = state().get(id) if (session && session.info.status === "running") { session.process.write(data) } } - export function connect(id: string, ws: Socket, cursor?: number) { + export function connect(id: PtyID, ws: Socket, cursor?: number) { const session = state().get(id) if (!session) { ws.close() diff --git a/packages/opencode/src/question/index.ts b/packages/opencode/src/question/index.ts index 0e145a04077..cf52979fc88 100644 --- a/packages/opencode/src/question/index.ts +++ b/packages/opencode/src/question/index.ts @@ -80,20 +80,15 @@ export namespace Question { ), } - const state = Instance.state(async () => { - const pending: Record< - string, - { - info: Request - resolve: (answers: Answer[]) => void - reject: (e: any) => void - } - > = {} + interface PendingEntry { + info: Request + resolve: (answers: Answer[]) => void + reject: (e: any) => void + } - return { - pending, - } - }) + const state = Instance.state(async () => ({ + pending: new Map(), + })) export async function ask(input: { sessionID: SessionID @@ -112,23 +107,23 @@ export namespace Question { questions: input.questions, tool: input.tool, } - s.pending[id] = { + s.pending.set(id, { info, resolve, reject, - } + }) Bus.publish(Event.Asked, info) }) } - export async function reply(input: { requestID: string; answers: Answer[] }): Promise { + export async function reply(input: { requestID: QuestionID; answers: Answer[] }): Promise { const s = await state() - const existing = s.pending[input.requestID] + const existing = s.pending.get(input.requestID) if (!existing) { log.warn("reply for unknown request", { requestID: input.requestID }) return } - delete s.pending[input.requestID] + s.pending.delete(input.requestID) log.info("replied", { requestID: input.requestID, answers: input.answers }) @@ -141,14 +136,14 @@ export namespace Question { existing.resolve(input.answers) } - export async function reject(requestID: string): Promise { + export async function reject(requestID: QuestionID): Promise { const s = await state() - const existing = s.pending[requestID] + const existing = s.pending.get(requestID) if (!existing) { log.warn("reject for unknown request", { requestID }) return } - delete s.pending[requestID] + s.pending.delete(requestID) log.info("rejected", { requestID }) @@ -167,6 +162,6 @@ export namespace Question { } export async function list() { - return state().then((x) => Object.values(x.pending).map((x) => x.info)) + return state().then((x) => Array.from(x.pending.values(), (x) => x.info)) } } diff --git a/packages/opencode/src/server/routes/experimental.ts b/packages/opencode/src/server/routes/experimental.ts index 98c7ece1052..43be6f245e0 100644 --- a/packages/opencode/src/server/routes/experimental.ts +++ b/packages/opencode/src/server/routes/experimental.ts @@ -1,6 +1,7 @@ import { Hono } from "hono" import { describeRoute, validator, resolver } from "hono-openapi" import z from "zod" +import { ProviderID, ModelID } from "../../provider/schema" import { ToolRegistry } from "../../tool/registry" import { Worktree } from "../../worktree" import { Instance } from "../../project/instance" @@ -77,7 +78,7 @@ export const ExperimentalRoutes = lazy(() => ), async (c) => { const { provider, model } = c.req.valid("query") - const tools = await ToolRegistry.tools({ providerID: provider, modelID: model }) + const tools = await ToolRegistry.tools({ providerID: ProviderID.make(provider), modelID: ModelID.make(model) }) return c.json( tools.map((t) => ({ id: t.id, diff --git a/packages/opencode/src/session/prompt.ts b/packages/opencode/src/session/prompt.ts index 939c50a3d92..171c4b448fd 100644 --- a/packages/opencode/src/session/prompt.ts +++ b/packages/opencode/src/session/prompt.ts @@ -237,7 +237,7 @@ export namespace SessionPrompt { return parts } - function start(sessionID: string) { + function start(sessionID: SessionID) { const s = state() if (s[sessionID]) return const controller = new AbortController() @@ -248,7 +248,7 @@ export namespace SessionPrompt { return controller.signal } - function resume(sessionID: string) { + function resume(sessionID: SessionID) { const s = state() if (!s[sessionID]) return @@ -788,7 +788,7 @@ export namespace SessionPrompt { }) for (const item of await ToolRegistry.tools( - { modelID: input.model.api.id, providerID: input.model.providerID }, + { modelID: ModelID.make(input.model.api.id), providerID: input.model.providerID }, input.agent, )) { const schema = ProviderTransform.schema(input.model, z.toJSONSchema(item.parameters)) @@ -1898,8 +1898,8 @@ NOTE: At any point in time through this workflow you should feel free to ask the async function ensureTitle(input: { session: Session.Info history: MessageV2.WithParts[] - providerID: string - modelID: string + providerID: ProviderID + modelID: ModelID }) { if (input.session.parentID) return if (!Session.isDefaultTitle(input.session.title)) return diff --git a/packages/opencode/src/share/share-next.ts b/packages/opencode/src/share/share-next.ts index d1b09e4bf22..e911656c900 100644 --- a/packages/opencode/src/share/share-next.ts +++ b/packages/opencode/src/share/share-next.ts @@ -2,6 +2,7 @@ import { Bus } from "@/bus" import { Account } from "@/account" import { Config } from "@/config/config" import { Provider } from "@/provider/provider" +import { ProviderID, ModelID } from "@/provider/schema" import { Session } from "@/session" import type { SessionID } from "@/session/schema" import { MessageV2 } from "@/session/message-v2" @@ -262,7 +263,7 @@ export namespace ShareNext { .map((m) => (m.info as SDK.UserMessage).model) .map((m) => [`${m.providerID}/${m.modelID}`, m] as const), ).values(), - ).map((m) => Provider.getModel(m.providerID, m.modelID).then((item) => item)), + ).map((m) => Provider.getModel(ProviderID.make(m.providerID), ModelID.make(m.modelID)).then((item) => item)), ) await sync(sessionID, [ { diff --git a/packages/opencode/src/tool/batch.ts b/packages/opencode/src/tool/batch.ts index 0e864b021b9..00c22bfe6be 100644 --- a/packages/opencode/src/tool/batch.ts +++ b/packages/opencode/src/tool/batch.ts @@ -1,5 +1,6 @@ import z from "zod" import { Tool } from "./tool" +import { ProviderID, ModelID } from "../provider/schema" import DESCRIPTION from "./batch.txt" const DISALLOWED = new Set(["batch"]) @@ -37,7 +38,7 @@ export const BatchTool = Tool.define("batch", async () => { const discardedCalls = params.tool_calls.slice(25) const { ToolRegistry } = await import("./registry") - const availableTools = await ToolRegistry.tools({ modelID: "", providerID: "" }) + const availableTools = await ToolRegistry.tools({ modelID: ModelID.make(""), providerID: ProviderID.make("") }) const toolMap = new Map(availableTools.map((t) => [t.id, t])) const executeCall = async (call: (typeof toolCalls)[0]) => { diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index c6d7fbc1e4b..3ea242a29d7 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -20,6 +20,7 @@ import path from "path" import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" +import { ProviderID, type ModelID } from "../provider/schema" import { WebSearchTool } from "./websearch" import { CodeSearchTool } from "./codesearch" import { Flag } from "@/flag/flag" @@ -130,8 +131,8 @@ export namespace ToolRegistry { export async function tools( model: { - providerID: string - modelID: string + providerID: ProviderID + modelID: ModelID }, agent?: Agent.Info, ) { @@ -141,7 +142,7 @@ export namespace ToolRegistry { .filter((t) => { // Enable websearch/codesearch for zen users OR via enable flag if (t.id === "codesearch" || t.id === "websearch") { - return model.providerID === "opencode" || Flag.OPENCODE_ENABLE_EXA + return model.providerID === ProviderID.opencode || Flag.OPENCODE_ENABLE_EXA } // use apply tool in same format as codex diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 96207f21b27..b14d2752240 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -4,6 +4,7 @@ import path from "path" import { tmpdir } from "../fixture/fixture" import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" +import { ProviderID, ModelID } from "../../src/provider/schema" import { Env } from "../../src/env" test("provider loaded from env variable", async () => { @@ -300,7 +301,7 @@ test("getModel returns model for valid provider/model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getModel("anthropic", "claude-sonnet-4-20250514") + const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model).toBeDefined() expect(String(model.providerID)).toBe("anthropic") expect(String(model.id)).toBe("claude-sonnet-4-20250514") @@ -327,7 +328,7 @@ test("getModel throws ModelNotFoundError for invalid model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - expect(Provider.getModel("anthropic", "nonexistent-model")).rejects.toThrow() + expect(Provider.getModel(ProviderID.anthropic, ModelID.make("nonexistent-model"))).rejects.toThrow() }, }) }) @@ -346,7 +347,7 @@ test("getModel throws ModelNotFoundError for invalid provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - expect(Provider.getModel("nonexistent-provider", "some-model")).rejects.toThrow() + expect(Provider.getModel(ProviderID.make("nonexistent-provider"), ModelID.make("some-model"))).rejects.toThrow() }, }) }) @@ -572,10 +573,10 @@ test("closest finds model by partial match", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const result = await Provider.closest("anthropic", ["sonnet-4"]) + const result = await Provider.closest(ProviderID.anthropic, ["sonnet-4"]) expect(result).toBeDefined() - expect(result?.providerID).toBe("anthropic") - expect(result?.modelID).toContain("sonnet-4") + expect(String(result?.providerID)).toBe("anthropic") + expect(String(result?.modelID)).toContain("sonnet-4") }, }) }) @@ -594,7 +595,7 @@ test("closest returns undefined for nonexistent provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const result = await Provider.closest("nonexistent", ["model"]) + const result = await Provider.closest(ProviderID.make("nonexistent"), ["model"]) expect(result).toBeUndefined() }, }) @@ -630,7 +631,7 @@ test("getModel uses realIdByKey for aliased models", async () => { const providers = await Provider.list() expect(providers["anthropic"].models["my-sonnet"]).toBeDefined() - const model = await Provider.getModel("anthropic", "my-sonnet") + const model = await Provider.getModel(ProviderID.anthropic, ModelID.make("my-sonnet")) expect(model).toBeDefined() expect(String(model.id)).toBe("my-sonnet") expect(model.name).toBe("My Sonnet Alias") @@ -933,7 +934,7 @@ test("getSmallModel returns appropriate small model", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getSmallModel("anthropic") + const model = await Provider.getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(model?.id).toContain("haiku") }, @@ -958,7 +959,7 @@ test("getSmallModel respects config small_model override", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model = await Provider.getSmallModel("anthropic") + const model = await Provider.getSmallModel(ProviderID.anthropic) expect(model).toBeDefined() expect(String(model?.providerID)).toBe("anthropic") expect(String(model?.id)).toBe("claude-sonnet-4-20250514") @@ -1466,8 +1467,8 @@ test("getModel returns consistent results", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const model1 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514") - const model2 = await Provider.getModel("anthropic", "claude-sonnet-4-20250514") + const model1 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) + const model2 = await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonnet-4-20250514")) expect(model1.providerID).toEqual(model2.providerID) expect(model1.id).toEqual(model2.id) expect(model1).toEqual(model2) @@ -1528,7 +1529,7 @@ test("ModelNotFoundError includes suggestions for typos", async () => { }, fn: async () => { try { - await Provider.getModel("anthropic", "claude-sonet-4") // typo: sonet instead of sonnet + await Provider.getModel(ProviderID.anthropic, ModelID.make("claude-sonet-4")) // typo: sonet instead of sonnet expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.data.suggestions).toBeDefined() @@ -1556,7 +1557,7 @@ test("ModelNotFoundError for provider includes suggestions", async () => { }, fn: async () => { try { - await Provider.getModel("antropic", "claude-sonnet-4") // typo: antropic + await Provider.getModel(ProviderID.make("antropic"), ModelID.make("claude-sonnet-4")) // typo: antropic expect(true).toBe(false) // Should not reach here } catch (e: any) { expect(e.data.suggestions).toBeDefined() @@ -1580,7 +1581,7 @@ test("getProvider returns undefined for nonexistent provider", async () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const provider = await Provider.getProvider("nonexistent") + const provider = await Provider.getProvider(ProviderID.make("nonexistent")) expect(provider).toBeUndefined() }, }) @@ -1603,7 +1604,7 @@ test("getProvider returns provider info", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const provider = await Provider.getProvider("anthropic") + const provider = await Provider.getProvider(ProviderID.anthropic) expect(provider).toBeDefined() expect(String(provider?.id)).toBe("anthropic") }, @@ -1627,7 +1628,7 @@ test("closest returns undefined when no partial match found", async () => { Env.set("ANTHROPIC_API_KEY", "test-api-key") }, fn: async () => { - const result = await Provider.closest("anthropic", ["nonexistent-xyz-model"]) + const result = await Provider.closest(ProviderID.anthropic, ["nonexistent-xyz-model"]) expect(result).toBeUndefined() }, }) @@ -1651,7 +1652,7 @@ test("closest checks multiple query terms in order", async () => { }, fn: async () => { // First term won't match, second will - const result = await Provider.closest("anthropic", ["nonexistent", "haiku"]) + const result = await Provider.closest(ProviderID.anthropic, ["nonexistent", "haiku"]) expect(result).toBeDefined() expect(result?.modelID).toContain("haiku") }, diff --git a/packages/opencode/test/pty/pty-session.test.ts b/packages/opencode/test/pty/pty-session.test.ts index 49b2c3ec26b..9063af872d4 100644 --- a/packages/opencode/test/pty/pty-session.test.ts +++ b/packages/opencode/test/pty/pty-session.test.ts @@ -2,6 +2,7 @@ import { describe, expect, test } from "bun:test" import { Bus } from "../../src/bus" import { Instance } from "../../src/project/instance" import { Pty } from "../../src/pty" +import type { PtyID } from "../../src/pty/schema" import { tmpdir } from "../fixture/fixture" import { setTimeout as sleep } from "node:timers/promises" @@ -14,7 +15,7 @@ const wait = async (fn: () => boolean, ms = 2000) => { throw new Error("timeout waiting for pty events") } -const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: string }>, id: string) => { +const pick = (log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }>, id: PtyID) => { return log.filter((evt) => evt.id === id).map((evt) => evt.type) } @@ -27,23 +28,23 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, fn: async () => { - const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = [] + const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] const off = [ Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), ] - let id = "" + let id: PtyID | undefined try { const info = await Pty.create({ command: "/bin/ls", title: "ls" }) id = info.id - await wait(() => pick(log, id).includes("exited")) + await wait(() => pick(log, id!).includes("exited")) await Pty.remove(id) - await wait(() => pick(log, id).length >= 3) - expect(pick(log, id)).toEqual(["created", "exited", "deleted"]) + await wait(() => pick(log, id!).length >= 3) + expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) } finally { off.forEach((x) => x()) if (id) await Pty.remove(id) @@ -60,14 +61,14 @@ describe("pty", () => { await Instance.provide({ directory: dir.path, fn: async () => { - const log: Array<{ type: "created" | "exited" | "deleted"; id: string }> = [] + const log: Array<{ type: "created" | "exited" | "deleted"; id: PtyID }> = [] const off = [ Bus.subscribe(Pty.Event.Created, (evt) => log.push({ type: "created", id: evt.properties.info.id })), Bus.subscribe(Pty.Event.Exited, (evt) => log.push({ type: "exited", id: evt.properties.id })), Bus.subscribe(Pty.Event.Deleted, (evt) => log.push({ type: "deleted", id: evt.properties.id })), ] - let id = "" + let id: PtyID | undefined try { const info = await Pty.create({ command: "/bin/sh", title: "sh" }) id = info.id @@ -75,8 +76,8 @@ describe("pty", () => { await sleep(100) await Pty.remove(id) - await wait(() => pick(log, id).length >= 3) - expect(pick(log, id)).toEqual(["created", "exited", "deleted"]) + await wait(() => pick(log, id!).length >= 3) + expect(pick(log, id!)).toEqual(["created", "exited", "deleted"]) } finally { off.forEach((x) => x()) if (id) await Pty.remove(id) diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 0cc44cac27d..64e73e0def2 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -7,7 +7,7 @@ import { Instance } from "../../src/project/instance" import { Provider } from "../../src/provider/provider" import { ProviderTransform } from "../../src/provider/transform" import { ModelsDev } from "../../src/provider/models" -import { ProviderID } from "../../src/provider/schema" +import { ProviderID, ModelID } from "../../src/provider/schema" import { Filesystem } from "../../src/util/filesystem" import { tmpdir } from "../fixture/fixture" import type { Agent } from "../../src/agent/agent" @@ -266,7 +266,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(providerID, model.id) + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-1") const agent = { name: "test", @@ -396,7 +396,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel("openai", model.id) + const resolved = await Provider.getModel(ProviderID.openai, ModelID.make(model.id)) const sessionID = SessionID.make("session-test-2") const agent = { name: "test", @@ -518,7 +518,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(providerID, model.id) + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-3") const agent = { name: "test", @@ -619,7 +619,7 @@ describe("session.llm.stream", () => { await Instance.provide({ directory: tmp.path, fn: async () => { - const resolved = await Provider.getModel(providerID, model.id) + const resolved = await Provider.getModel(ProviderID.make(providerID), ModelID.make(model.id)) const sessionID = SessionID.make("session-test-4") const agent = { name: "test", From 3533f33ecb8ca7cec1185ed4572df13f80b94ccd Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Mar 2026 14:49:22 +0000 Subject: [PATCH 003/145] chore: generate --- packages/opencode/src/provider/provider.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index 27901032952..fc5b04fa608 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -1082,7 +1082,10 @@ export namespace Provider { for (const [modelID, model] of Object.entries(provider.models)) { model.api.id = model.api.id ?? model.id ?? modelID - if (modelID === "gpt-5-chat-latest" || (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat")) + if ( + modelID === "gpt-5-chat-latest" || + (providerID === ProviderID.openrouter && modelID === "openai/gpt-5-chat") + ) delete provider.models[modelID] if (model.status === "alpha" && !Flag.OPENCODE_ENABLE_EXPERIMENTAL_MODELS) delete provider.models[modelID] if (model.status === "deprecated") delete provider.models[modelID] From 64fb9233bfe2571f301561118c867ceb5c1b0b58 Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Thu, 12 Mar 2026 10:52:20 -0400 Subject: [PATCH 004/145] refactor(import): use .parse() at boundaries instead of manual .make() (#17106) --- packages/opencode/src/cli/cmd/import.ts | 30 +++++++++---------------- 1 file changed, 11 insertions(+), 19 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 8bd24cfec1b..a0c0101feaa 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -1,8 +1,7 @@ import type { Argv } from "yargs" import type { Session as SDKSession, Message, Part } from "@opencode-ai/sdk/v2" import { Session } from "../../session" -import { SessionID, MessageID, PartID } from "../../session/schema" -import { WorkspaceID } from "../../control-plane/schema" +import { MessageV2 } from "../../session/message-v2" import { cmd } from "./cmd" import { bootstrap } from "../bootstrap" import { Database } from "../../storage/db" @@ -154,20 +153,11 @@ export const ImportCommand = cmd({ return } - const row = Session.toRow({ + const info = Session.Info.parse({ ...exportData.info, - id: SessionID.make(exportData.info.id), - parentID: exportData.info.parentID ? SessionID.make(exportData.info.parentID) : undefined, - workspaceID: exportData.info.workspaceID ? WorkspaceID.make(exportData.info.workspaceID) : undefined, projectID: Instance.project.id, - revert: exportData.info.revert - ? { - ...exportData.info.revert, - messageID: MessageID.make(exportData.info.revert.messageID), - partID: exportData.info.revert.partID ? PartID.make(exportData.info.revert.partID) : undefined, - } - : undefined, }) + const row = Session.toRow(info) Database.use((db) => db .insert(SessionTable) @@ -177,14 +167,15 @@ export const ImportCommand = cmd({ ) for (const msg of exportData.messages) { - const { id: _mid, sessionID: _msid, ...msgData } = msg.info + const msgInfo = MessageV2.Info.parse(msg.info) + const { id, sessionID: _, ...msgData } = msgInfo Database.use((db) => db .insert(MessageTable) .values({ - id: MessageID.make(msg.info.id), + id, session_id: row.id, - time_created: msg.info.time?.created ?? Date.now(), + time_created: msgInfo.time?.created ?? Date.now(), data: msgData, }) .onConflictDoNothing() @@ -192,13 +183,14 @@ export const ImportCommand = cmd({ ) for (const part of msg.parts) { - const { id: _pid, sessionID: _psid, messageID: _pmid, ...partData } = part + const partInfo = MessageV2.Part.parse(part) + const { id: partId, sessionID: _s, messageID, ...partData } = partInfo Database.use((db) => db .insert(PartTable) .values({ - id: PartID.make(part.id), - message_id: MessageID.make(msg.info.id), + id: partId, + message_id: messageID, session_id: row.id, data: partData, }) From a776a3ee12d317deb79c86066a99c79b13dad815 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:05:00 -0500 Subject: [PATCH 005/145] fix: non openai azure models that use completions endpoints (#17128) --- packages/opencode/src/provider/provider.ts | 120 +++++++++++++-------- 1 file changed, 73 insertions(+), 47 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fc5b04fa608..92b001a6f69 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -52,44 +52,10 @@ const DEFAULT_CHUNK_TIMEOUT = 120_000 export namespace Provider { const log = Log.create({ service: "provider" }) - function isGpt5OrLater(modelID: string): boolean { - const match = /^gpt-(\d+)/.exec(modelID) - if (!match) { - return false - } - return Number(match[1]) >= 5 - } - function shouldUseCopilotResponsesApi(modelID: string): boolean { - return isGpt5OrLater(modelID) && !modelID.startsWith("gpt-5-mini") - } - - function googleVertexVars(options: Record) { - const project = - options["project"] ?? Env.get("GOOGLE_CLOUD_PROJECT") ?? Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT") - const location = - options["location"] ?? - Env.get("GOOGLE_VERTEX_LOCATION") ?? - Env.get("GOOGLE_CLOUD_LOCATION") ?? - Env.get("VERTEX_LOCATION") ?? - "us-central1" - const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` - - return { - GOOGLE_VERTEX_PROJECT: project, - GOOGLE_VERTEX_LOCATION: location, - GOOGLE_VERTEX_ENDPOINT: endpoint, - } - } - - function loadBaseURL(model: Model, options: Record) { - const raw = options["baseURL"] ?? model.api.url - if (typeof raw !== "string") return raw - const vars = model.providerID === "google-vertex" ? googleVertexVars(options) : undefined - return raw.replace(/\$\{([^}]+)\}/g, (match, key) => { - const val = Env.get(String(key)) ?? vars?.[String(key) as keyof typeof vars] - return val ?? match - }) + const match = /^gpt-(\d+)/.exec(modelID) + if (!match) return false + return Number(match[1]) >= 5 && !modelID.startsWith("gpt-5-mini") } function wrapSSE(res: Response, ms: number, ctl: AbortController) { @@ -166,12 +132,18 @@ export namespace Provider { } type CustomModelLoader = (sdk: any, modelID: string, options?: Record) => Promise + type CustomVarsLoader = (options: Record) => Record type CustomLoader = (provider: Info) => Promise<{ autoload: boolean getModel?: CustomModelLoader + vars?: CustomVarsLoader options?: Record }> + function useLanguageModel(sdk: any) { + return sdk.responses === undefined && sdk.chat === undefined + } + const CUSTOM_LOADERS: Record = { async anthropic() { return { @@ -219,7 +191,7 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, options: {}, @@ -229,16 +201,23 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { - if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, options: {}, } }, - azure: async () => { + azure: async (provider) => { + const resource = iife(() => { + const name = provider.options?.resourceName + if (typeof name === "string" && name.trim() !== "") return name + return Env.get("AZURE_RESOURCE_NAME") + }) + return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) if (options?.["useCompletionUrls"]) { return sdk.chat(modelID) } else { @@ -246,6 +225,11 @@ export namespace Provider { } }, options: {}, + vars(_options) { + return { + ...(resource && { AZURE_RESOURCE_NAME: resource }), + } + }, } }, "azure-cognitive-services": async () => { @@ -253,6 +237,7 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, options?: Record) { + if (useLanguageModel(sdk)) return sdk.languageModel(modelID) if (options?.["useCompletionUrls"]) { return sdk.chat(modelID) } else { @@ -441,17 +426,26 @@ export namespace Provider { Env.get("GCP_PROJECT") ?? Env.get("GCLOUD_PROJECT") - const location = + const location = String( provider.options?.location ?? - Env.get("GOOGLE_VERTEX_LOCATION") ?? - Env.get("GOOGLE_CLOUD_LOCATION") ?? - Env.get("VERTEX_LOCATION") ?? - "us-central1" + Env.get("GOOGLE_VERTEX_LOCATION") ?? + Env.get("GOOGLE_CLOUD_LOCATION") ?? + Env.get("VERTEX_LOCATION") ?? + "us-central1", + ) const autoload = Boolean(project) if (!autoload) return { autoload: false } return { autoload: true, + vars(_options: Record) { + const endpoint = location === "global" ? "aiplatform.googleapis.com" : `${location}-aiplatform.googleapis.com` + return { + ...(project && { GOOGLE_VERTEX_PROJECT: project }), + GOOGLE_VERTEX_LOCATION: location, + GOOGLE_VERTEX_ENDPOINT: endpoint, + } + }, options: { project, location, @@ -583,11 +577,15 @@ export namespace Provider { autoload: !!apiKey, options: { apiKey, - baseURL: `https://api.cloudflare.com/client/v4/accounts/${accountId}/ai/v1`, }, async getModel(sdk: any, modelID: string) { return sdk.languageModel(modelID) }, + vars(_options) { + return { + CLOUDFLARE_ACCOUNT_ID: accountId, + } + }, } }, "cloudflare-ai-gateway": async (input) => { @@ -856,6 +854,9 @@ export namespace Provider { const modelLoaders: { [providerID: string]: CustomModelLoader } = {} + const varsLoaders: { + [providerID: string]: CustomVarsLoader + } = {} const sdk = new Map() log.info("init") @@ -1055,6 +1056,7 @@ export namespace Provider { const result = await fn(data) if (result && (result.autoload || providers[providerID])) { if (result.getModel) modelLoaders[providerID] = result.getModel + if (result.vars) varsLoaders[providerID] = result.vars const opts = result.options ?? {} const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } mergeProvider(providerID, patch) @@ -1121,6 +1123,7 @@ export namespace Provider { providers, sdk, modelLoaders, + varsLoaders, } }) @@ -1145,7 +1148,30 @@ export namespace Provider { options["includeUsage"] = true } - const baseURL = loadBaseURL(model, options) + const baseURL = iife(() => { + let url = + typeof options["baseURL"] === "string" && options["baseURL"] !== "" ? options["baseURL"] : model.api.url + if (!url) return + + // some models/providers have variable urls, ex: "https://${AZURE_RESOURCE_NAME}.services.ai.azure.com/anthropic/v1" + // We track this in models.dev, and then when we are resolving the baseURL + // we need to string replace that literal: "${AZURE_RESOURCE_NAME}" + const loader = s.varsLoaders[model.providerID] + if (loader) { + const vars = loader(options) + for (const [key, value] of Object.entries(vars)) { + const field = "${" + key + "}" + url = url.replaceAll(field, value) + } + } + + url = url.replace(/\$\{([^}]+)\}/g, (item, key) => { + const val = Env.get(String(key)) + return val ?? item + }) + return url + }) + if (baseURL !== undefined) options["baseURL"] = baseURL if (options["apiKey"] === undefined && provider.key) options["apiKey"] = provider.key if (model.headers) From c455d418760f1dc1925da4c888bb2923fc4aab8a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 12 Mar 2026 15:07:54 +0000 Subject: [PATCH 006/145] 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 1f1ef9d31af..06f54dc950f 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-5+9GAHej/EWz87Z3eTI9yBDRL1Ko0RoXsLo/Q3t42WA=", - "aarch64-linux": "sha256-4FWmoWkLKWKita3+XHZEiDy5grOQgdzOY1AZzb0TDWE=", - "aarch64-darwin": "sha256-L4FPB1E5AtV3V6qZjmX6YM7Q/mwSYlhYyZXPXAxrLFU=", - "x86_64-darwin": "sha256-bJCcrzDF2tIsKScxw5CoW+ZRUHe4KbUWLSqiR/M7vu8=" + "x86_64-linux": "sha256-WJgo6UclmtQOEubnKMZybdIEhZ1uRTucF61yojjd+l0=", + "aarch64-linux": "sha256-QfZ/g7EZFpe6ndR3dG8WvVfMj5Kyd/R/4kkTJfGJxL4=", + "aarch64-darwin": "sha256-ezr/R70XJr9eN5l3mgb7HzLF6QsofNEKUOtuxbfli80=", + "x86_64-darwin": "sha256-MbsBGS415uEU/n1RQ/5H5pqh+udLY3+oimJ+eS5uJVI=" } } From 776e7a9c15f3e352c5abf0b0949a5d7b767adfa3 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 10:35:23 -0500 Subject: [PATCH 007/145] feat(app): better themes (#16889) --- packages/ui/src/theme/color.ts | 50 ++- packages/ui/src/theme/resolve.ts | 315 ++++++++++-------- packages/ui/src/theme/themes/aura.json | 16 +- packages/ui/src/theme/themes/ayu.json | 16 +- packages/ui/src/theme/themes/carbonfox.json | 16 +- packages/ui/src/theme/themes/catppuccin.json | 8 +- packages/ui/src/theme/themes/dracula.json | 10 +- packages/ui/src/theme/themes/gruvbox.json | 8 +- packages/ui/src/theme/themes/monokai.json | 10 +- packages/ui/src/theme/themes/nightowl.json | 8 +- packages/ui/src/theme/themes/nord.json | 10 +- packages/ui/src/theme/themes/oc-2.json | 20 ++ packages/ui/src/theme/themes/onedarkpro.json | 8 +- .../ui/src/theme/themes/shadesofpurple.json | 16 +- packages/ui/src/theme/themes/solarized.json | 14 +- packages/ui/src/theme/themes/tokyonight.json | 12 +- packages/ui/src/theme/themes/vesper.json | 17 +- 17 files changed, 377 insertions(+), 177 deletions(-) diff --git a/packages/ui/src/theme/color.ts b/packages/ui/src/theme/color.ts index 89d9a653d76..284aaf34d6e 100644 --- a/packages/ui/src/theme/color.ts +++ b/packages/ui/src/theme/color.ts @@ -135,12 +135,25 @@ export function generateScale(seed: HexColor, isDark: boolean): HexColor[] { const scale: HexColor[] = [] const lightSteps = isDark - ? [0.182, 0.21, 0.261, 0.302, 0.341, 0.387, 0.443, 0.514, base.l, Math.max(0, base.l - 0.017), 0.8, 0.93] - : [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.548, 0.33] + ? [ + 0.182, + 0.21, + 0.261, + 0.302, + 0.341, + 0.387, + 0.443, + 0.514, + base.l, + Math.max(0, base.l - 0.017), + Math.min(0.94, Math.max(0.84, base.l + 0.02)), + 0.975, + ] + : [0.993, 0.983, 0.962, 0.936, 0.906, 0.866, 0.811, 0.74, base.l, Math.max(0, base.l - 0.036), 0.49, 0.27] const chromaMultipliers = isDark - ? [0.205, 0.275, 0.46, 0.62, 0.71, 0.79, 0.87, 0.97, 1.04, 1.03, 1, 0.58] - : [0.045, 0.128, 0.34, 0.5, 0.61, 0.69, 0.77, 0.89, 1, 1, 0.97, 0.56] + ? [0.34, 0.45, 0.64, 0.82, 0.96, 1.06, 1.14, 1.2, 1.24, 1.28, 1.34, 1.08] + : [0.12, 0.24, 0.46, 0.68, 0.84, 0.98, 1.08, 1.16, 1.22, 1.26, 1.18, 0.98] for (let i = 0; i < 12; i++) { scale.push( @@ -155,10 +168,35 @@ export function generateScale(seed: HexColor, isDark: boolean): HexColor[] { return scale } -export function generateNeutralScale(seed: HexColor, isDark: boolean): HexColor[] { +export function generateNeutralScale(seed: HexColor, isDark: boolean, ink?: HexColor): HexColor[] { + if (ink) { + const base = hexToOklch(seed) + const lift = (tone: number) => + oklchToHex({ + l: base.l + (1 - base.l) * tone, + c: base.c * Math.max(0, 1 - tone), + h: base.h, + }) + const sink = (tone: number) => + oklchToHex({ + l: base.l * (1 - tone), + c: base.c * Math.max(0, 1 - tone * 0.3), + h: base.h, + }) + const bg = isDark + ? sink(clamp(0.06 + Math.max(0, base.l - 0.18) * 0.22 + base.c * 1.4, 0.06, 0.14)) + : base.l < 0.82 + ? lift(0.86) + : lift(clamp(0.1 + base.c * 3.2 + Math.max(0, 0.95 - base.l) * 0.35, 0.1, 0.28)) + const steps = isDark + ? [0, 0.03, 0.055, 0.085, 0.125, 0.18, 0.255, 0.35, 0.5, 0.67, 0.84, 0.975] + : [0, 0.022, 0.042, 0.068, 0.102, 0.146, 0.208, 0.296, 0.432, 0.61, 0.81, 0.965] + return steps.map((step) => mixColors(bg, ink, step)) + } + const base = hexToOklch(seed) const scale: HexColor[] = [] - const neutralChroma = Math.min(base.c, 0.02) + const neutralChroma = Math.min(base.c, isDark ? 0.05 : 0.04) const lightSteps = isDark ? [0.2, 0.226, 0.256, 0.277, 0.301, 0.325, 0.364, 0.431, base.l, 0.593, 0.706, 0.946] diff --git a/packages/ui/src/theme/resolve.ts b/packages/ui/src/theme/resolve.ts index 722648dabcd..f1bab6ee843 100644 --- a/packages/ui/src/theme/resolve.ts +++ b/packages/ui/src/theme/resolve.ts @@ -1,11 +1,11 @@ import type { ColorValue, DesktopTheme, HexColor, ResolvedTheme, ThemeVariant } from "./types" -import { blend, generateNeutralScale, generateScale, hexToOklch, oklchToHex, shift, withAlpha } from "./color" +import { blend, generateNeutralScale, generateScale, hexToOklch, hexToRgb, shift, withAlpha } from "./color" export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): ResolvedTheme { const colors = getColors(variant) const { overrides = {} } = variant - const neutral = generateNeutralScale(colors.neutral, isDark) + const neutral = generateNeutralScale(colors.neutral, isDark, colors.ink) const primary = generateScale(colors.primary, isDark) const accent = generateScale(colors.accent, isDark) const success = generateScale(colors.success, isDark) @@ -39,12 +39,20 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res isDark, ) const ink = colors.ink ?? colors.neutral + const tint = hasInk ? hexToOklch(ink) : undefined + const body = tint + ? shift(ink, { + l: isDark ? Math.max(0, 0.88 - tint.l) * 0.4 : -Math.max(0, tint.l - 0.18) * 0.24, + c: isDark ? 1.04 : 1.02, + }) + : undefined const backgroundOverride = overrides["background-base"] const backgroundHex = getHex(backgroundOverride) const overlay = noInk || (Boolean(backgroundOverride) && !backgroundHex) const content = (seed: HexColor, scale: HexColor[]) => { - const value = isDark ? seed : hexToOklch(seed).l > 0.82 ? scale[10] : seed - return shift(value, { c: isDark ? 1.16 : 1.1 }) + const base = hexToOklch(seed) + const value = isDark ? (base.l > 0.84 ? shift(seed, { c: 1.18 }) : scale[10]) : scale[10] + return shift(value, { l: isDark ? 0.034 : -0.024, c: isDark ? 1.3 : 1.18 }) } const modified = () => { if (!colors.compact) return isDark ? "#ffba92" : "#FF8C00" @@ -87,9 +95,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res const compactInkBackground = colors.compact && hasInk && isDark ? { - base: neutral[2], - weak: neutral[3], - strong: neutral[1], + base: neutral[0], + weak: neutral[1], + strong: neutral[0], stronger: neutral[2], } : undefined @@ -118,6 +126,40 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res ) const neutralAlpha = noInk ? generateNeutralOverlayScale(neutral, isDark) : generateNeutralAlphaScale(neutral, isDark) + const brandb = brandl[isDark ? 9 : 8] + const brandh = brandl[isDark ? 10 : 9] + const interb = interactive[isDark ? 5 : 4] + const interh = interactive[isDark ? 6 : 5] + const interw = interactive[isDark ? 4 : 3] + const succb = success[isDark ? 5 : 4] + const succw = success[isDark ? 4 : 3] + const succs = success[10] + const warnb = (noInk && isDark ? warningl : warning)[isDark ? 5 : 4] + const warnw = (noInk && isDark ? warningl : warning)[isDark ? 4 : 3] + const warns = (noInk && isDark ? warningl : warning)[10] + const critb = error[isDark ? 5 : 4] + const critw = error[isDark ? 4 : 3] + const crits = error[10] + const infob = (noInk && isDark ? infol : info)[isDark ? 5 : 4] + const infow = (noInk && isDark ? infol : info)[isDark ? 4 : 3] + const infos = (noInk && isDark ? infol : info)[10] + const lum = (hex: HexColor) => { + const rgb = hexToRgb(hex) + const lift = (v: number) => (v <= 0.04045 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)) + return 0.2126 * lift(rgb.r) + 0.7152 * lift(rgb.g) + 0.0722 * lift(rgb.b) + } + const hit = (a: HexColor, b: HexColor) => { + const x = lum(a) + const y = lum(b) + const light = Math.max(x, y) + const dark = Math.min(x, y) + return (light + 0.05) / (dark + 0.05) + } + const on = (fill: HexColor) => { + const light = "#ffffff" as HexColor + const dark = "#000000" as HexColor + return hit(light, fill) > hit(dark, fill) ? light : dark + } const tokens: ResolvedTheme = {} @@ -141,8 +183,8 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res : (withAlpha(neutral[3], 0.09) as ColorValue) tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"] tokens["surface-raised-base"] = neutralAlpha[0] - tokens["surface-float-base"] = isDark ? neutral[0] : noInk ? shadow[0] : neutral[11] - tokens["surface-float-base-hover"] = isDark ? neutral[1] : noInk ? shadow[1] : neutral[10] + tokens["surface-float-base"] = isDark ? (hasInk ? neutral[1] : neutral[0]) : noInk ? shadow[0] : neutral[11] + tokens["surface-float-base-hover"] = isDark ? (hasInk ? neutral[2] : neutral[1]) : noInk ? shadow[1] : neutral[10] tokens["surface-raised-base-hover"] = neutralAlpha[1] tokens["surface-raised-base-active"] = neutralAlpha[2] tokens["surface-raised-strong"] = isDark ? neutralAlpha[3] : neutral[0] @@ -154,26 +196,26 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["surface-strong"] = isDark ? neutralAlpha[6] : "#ffffff" tokens["surface-raised-stronger-non-alpha"] = isDark ? neutral[2] : "#ffffff" - tokens["surface-brand-base"] = brandl[8] - tokens["surface-brand-hover"] = brandl[9] - - tokens["surface-interactive-base"] = interactive[isDark ? 4 : 3] - tokens["surface-interactive-hover"] = interactive[isDark ? 5 : 4] - tokens["surface-interactive-weak"] = interactive[isDark ? 3 : 2] - tokens["surface-interactive-weak-hover"] = noInk && isDark ? interl[4] : interactive[isDark ? 4 : 3] - - tokens["surface-success-base"] = success[isDark ? 4 : 3] - tokens["surface-success-weak"] = success[isDark ? 3 : 2] - tokens["surface-success-strong"] = success[9] - tokens["surface-warning-base"] = (noInk && isDark ? warningl : warning)[isDark ? 4 : 3] - tokens["surface-warning-weak"] = (noInk && isDark ? warningl : warning)[isDark ? 3 : 2] - tokens["surface-warning-strong"] = (noInk && isDark ? warningl : warning)[9] - tokens["surface-critical-base"] = error[isDark ? 4 : 3] - tokens["surface-critical-weak"] = error[isDark ? 3 : 2] - tokens["surface-critical-strong"] = error[9] - tokens["surface-info-base"] = (noInk && isDark ? infol : info)[isDark ? 4 : 3] - tokens["surface-info-weak"] = (noInk && isDark ? infol : info)[isDark ? 3 : 2] - tokens["surface-info-strong"] = (noInk && isDark ? infol : info)[9] + tokens["surface-brand-base"] = brandb + tokens["surface-brand-hover"] = brandh + + tokens["surface-interactive-base"] = interb + tokens["surface-interactive-hover"] = interh + tokens["surface-interactive-weak"] = interw + tokens["surface-interactive-weak-hover"] = noInk && isDark ? interl[5] : interb + + tokens["surface-success-base"] = succb + tokens["surface-success-weak"] = succw + tokens["surface-success-strong"] = succs + tokens["surface-warning-base"] = warnb + tokens["surface-warning-weak"] = warnw + tokens["surface-warning-strong"] = warns + tokens["surface-critical-base"] = critb + tokens["surface-critical-weak"] = critw + tokens["surface-critical-strong"] = crits + tokens["surface-info-base"] = infob + tokens["surface-info-weak"] = infow + tokens["surface-info-strong"] = infos tokens["surface-diff-unchanged-base"] = isDark ? neutral[0] : "#ffffff00" tokens["surface-diff-skip-base"] = isDark ? neutralAlpha[0] : neutral[1] @@ -200,16 +242,16 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["input-focus"] = interactive[0] tokens["input-disabled"] = neutral[3] - tokens["text-base"] = hasInk ? ink : noInk ? (isDark ? neutralAlpha[10] : neutral[10]) : neutral[10] + tokens["text-base"] = hasInk ? (body as HexColor) : noInk ? (isDark ? neutralAlpha[10] : neutral[10]) : neutral[10] tokens["text-weak"] = hasInk - ? shift(ink, { l: isDark ? -0.18 : 0.16, c: 0.88 }) + ? shift(body as HexColor, { l: isDark ? -0.11 : 0.11, c: 0.9 }) : noInk ? isDark ? neutralAlpha[8] : neutral[8] : neutral[8] tokens["text-weaker"] = hasInk - ? shift(ink, { l: isDark ? -0.3 : 0.26, c: isDark ? 0.74 : 0.68 }) + ? shift(body as HexColor, { l: isDark ? -0.2 : 0.21, c: isDark ? 0.78 : 0.72 }) : noInk ? isDark ? neutralAlpha[7] @@ -217,8 +259,8 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res : neutral[7] tokens["text-strong"] = hasInk ? isDark && colors.compact - ? blend("#ffffff", ink, 0.82) - : shift(ink, { l: isDark ? 0.06 : -0.09, c: 1 }) + ? blend("#ffffff", body as HexColor, 0.9) + : shift(body as HexColor, { l: isDark ? 0.055 : -0.07, c: 1.04 }) : noInk ? isDark ? neutralAlpha[11] @@ -234,29 +276,29 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["text-invert-weak"] = isDark ? neutral[8] : neutral[2] tokens["text-invert-weaker"] = isDark ? neutral[7] : neutral[3] tokens["text-invert-strong"] = isDark ? neutral[11] : neutral[0] - tokens["text-interactive-base"] = interactive[isDark ? 10 : 8] - tokens["text-on-brand-base"] = neutralAlpha[10] - tokens["text-on-interactive-base"] = isDark ? neutral[11] : neutral[0] - tokens["text-on-interactive-weak"] = neutralAlpha[10] - tokens["text-on-success-base"] = success[isDark ? 8 : 9] - tokens["text-on-critical-base"] = error[isDark ? 8 : 9] - tokens["text-on-critical-weak"] = error[7] - tokens["text-on-critical-strong"] = error[11] - tokens["text-on-warning-base"] = neutralAlpha[10] - tokens["text-on-info-base"] = neutralAlpha[10] + tokens["text-interactive-base"] = interactive[isDark ? 10 : 9] + tokens["text-on-brand-base"] = on(brandb) + tokens["text-on-interactive-base"] = on(interb) + tokens["text-on-interactive-weak"] = on(interb) + tokens["text-on-success-base"] = on(succb) + tokens["text-on-critical-base"] = on(critb) + tokens["text-on-critical-weak"] = on(critb) + tokens["text-on-critical-strong"] = on(crits) + tokens["text-on-warning-base"] = on(warnb) + tokens["text-on-info-base"] = on(infob) tokens["text-diff-add-base"] = diffAdd[10] tokens["text-diff-delete-base"] = diffDelete[isDark ? 8 : 9] tokens["text-diff-delete-strong"] = diffDelete[11] tokens["text-diff-add-strong"] = diffAdd[isDark ? 7 : 11] - tokens["text-on-info-weak"] = neutralAlpha[8] - tokens["text-on-info-strong"] = neutralAlpha[11] - tokens["text-on-warning-weak"] = neutralAlpha[8] - tokens["text-on-warning-strong"] = neutralAlpha[11] - tokens["text-on-success-weak"] = success[isDark ? 7 : 5] - tokens["text-on-success-strong"] = success[11] - tokens["text-on-brand-weak"] = neutralAlpha[8] - tokens["text-on-brand-weaker"] = neutralAlpha[7] - tokens["text-on-brand-strong"] = neutralAlpha[11] + tokens["text-on-info-weak"] = on(infob) + tokens["text-on-info-strong"] = on(infos) + tokens["text-on-warning-weak"] = on(warnb) + tokens["text-on-warning-strong"] = on(warns) + tokens["text-on-success-weak"] = on(succb) + tokens["text-on-success-strong"] = on(succs) + tokens["text-on-brand-weak"] = on(brandb) + tokens["text-on-brand-weaker"] = on(brandb) + tokens["text-on-brand-strong"] = on(brandh) tokens["button-primary-base"] = neutral[11] tokens["button-secondary-base"] = noInk ? (isDark ? neutral[1] : neutral[0]) : isDark ? neutral[2] : neutral[0] @@ -267,27 +309,27 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res if (noInk) { const tone = (alpha: number) => alphaTone((isDark ? "#ffffff" : "#000000") as HexColor, alpha) if (isDark) { - tokens["surface-base"] = tone(0.031) - tokens["surface-base-hover"] = tone(0.039) - tokens["surface-base-active"] = tone(0.059) - tokens["surface-raised-base"] = tone(0.059) - tokens["surface-raised-base-hover"] = tone(0.078) - tokens["surface-raised-base-active"] = tone(0.102) - tokens["surface-raised-strong"] = tone(0.078) - tokens["surface-raised-strong-hover"] = tone(0.129) - tokens["surface-raised-stronger"] = tone(0.129) - tokens["surface-raised-stronger-hover"] = tone(0.169) - tokens["surface-weak"] = tone(0.078) - tokens["surface-weaker"] = tone(0.102) - tokens["surface-strong"] = tone(0.169) + tokens["surface-base"] = tone(0.045) + tokens["surface-base-hover"] = tone(0.065) + tokens["surface-base-active"] = tone(0.095) + tokens["surface-raised-base"] = tone(0.085) + tokens["surface-raised-base-hover"] = tone(0.115) + tokens["surface-raised-base-active"] = tone(0.15) + tokens["surface-raised-strong"] = tone(0.115) + tokens["surface-raised-strong-hover"] = tone(0.17) + tokens["surface-raised-stronger"] = tone(0.17) + tokens["surface-raised-stronger-hover"] = tone(0.22) + tokens["surface-weak"] = tone(0.115) + tokens["surface-weaker"] = tone(0.15) + tokens["surface-strong"] = tone(0.22) tokens["surface-raised-stronger-non-alpha"] = neutral[1] tokens["surface-inset-base"] = withAlpha("#000000", 0.5) as ColorValue tokens["surface-inset-base-hover"] = tokens["surface-inset-base"] tokens["surface-inset-strong"] = withAlpha("#000000", 0.8) as ColorValue tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"] - tokens["button-secondary-hover"] = tone(0.039) - tokens["button-ghost-hover"] = tone(0.031) - tokens["button-ghost-hover2"] = tone(0.059) + tokens["button-secondary-hover"] = tone(0.065) + tokens["button-ghost-hover"] = tone(0.045) + tokens["button-ghost-hover2"] = tone(0.095) tokens["input-base"] = neutral[1] tokens["input-hover"] = neutral[1] tokens["input-selected"] = interactive[1] @@ -295,30 +337,30 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res } if (!isDark) { - tokens["surface-base"] = tone(0.031) - tokens["surface-base-hover"] = tone(0.059) - tokens["surface-base-active"] = tone(0.051) - tokens["surface-raised-base"] = tone(0.031) - tokens["surface-raised-base-hover"] = tone(0.051) - tokens["surface-raised-base-active"] = tone(0.09) + tokens["surface-base"] = tone(0.045) + tokens["surface-base-hover"] = tone(0.08) + tokens["surface-base-active"] = tone(0.105) + tokens["surface-raised-base"] = tone(0.05) + tokens["surface-raised-base-hover"] = tone(0.08) + tokens["surface-raised-base-active"] = tone(0.125) tokens["surface-raised-strong"] = neutral[0] tokens["surface-raised-strong-hover"] = "#ffffff" tokens["surface-raised-stronger"] = "#ffffff" tokens["surface-raised-stronger-hover"] = "#ffffff" - tokens["surface-weak"] = tone(0.051) - tokens["surface-weaker"] = tone(0.071) + tokens["surface-weak"] = tone(0.08) + tokens["surface-weaker"] = tone(0.105) tokens["surface-strong"] = "#ffffff" tokens["surface-raised-stronger-non-alpha"] = "#ffffff" tokens["surface-inset-strong"] = tone(0.09) tokens["surface-inset-strong-hover"] = tokens["surface-inset-strong"] tokens["button-secondary-hover"] = blend("#ffffff", background, 0.04) - tokens["button-ghost-hover"] = tone(0.031) - tokens["button-ghost-hover2"] = tone(0.051) + tokens["button-ghost-hover"] = tone(0.045) + tokens["button-ghost-hover2"] = tone(0.08) tokens["input-base"] = neutral[0] tokens["input-hover"] = neutral[1] } - tokens["surface-base-interactive-active"] = withAlpha(colors.interactive, isDark ? 0.125 : 0.09) as ColorValue + tokens["surface-base-interactive-active"] = withAlpha(colors.interactive, isDark ? 0.18 : 0.12) as ColorValue } tokens["border-base"] = hasInk ? borderTone(0.22, 0.16) : neutralAlpha[6] @@ -370,25 +412,25 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["border-focus"] = tokens["border-active"] } - tokens["border-interactive-base"] = (noInk && isDark ? interl : interactive)[6] - tokens["border-interactive-hover"] = (noInk && isDark ? interl : interactive)[7] - tokens["border-interactive-active"] = (noInk && isDark ? interl : interactive)[8] - tokens["border-interactive-selected"] = (noInk && isDark ? interl : interactive)[8] + tokens["border-interactive-base"] = (noInk && isDark ? interl : interactive)[7] + tokens["border-interactive-hover"] = (noInk && isDark ? interl : interactive)[8] + tokens["border-interactive-active"] = (noInk && isDark ? interl : interactive)[9] + tokens["border-interactive-selected"] = (noInk && isDark ? interl : interactive)[9] tokens["border-interactive-disabled"] = neutral[7] - tokens["border-interactive-focus"] = (noInk && isDark ? interl : interactive)[8] - - tokens["border-success-base"] = (noInk && isDark ? successl : success)[5] - tokens["border-success-hover"] = (noInk && isDark ? successl : success)[6] - tokens["border-success-selected"] = (noInk && isDark ? successl : success)[8] - tokens["border-warning-base"] = (noInk && isDark ? warningl : warning)[5] - tokens["border-warning-hover"] = (noInk && isDark ? warningl : warning)[6] - tokens["border-warning-selected"] = (noInk && isDark ? warningl : warning)[8] - tokens["border-critical-base"] = error[isDark ? 4 : 5] - tokens["border-critical-hover"] = error[6] - tokens["border-critical-selected"] = error[8] - tokens["border-info-base"] = (noInk && isDark ? infol : info)[5] - tokens["border-info-hover"] = (noInk && isDark ? infol : info)[6] - tokens["border-info-selected"] = (noInk && isDark ? infol : info)[8] + tokens["border-interactive-focus"] = (noInk && isDark ? interl : interactive)[9] + + tokens["border-success-base"] = (noInk && isDark ? successl : success)[6] + tokens["border-success-hover"] = (noInk && isDark ? successl : success)[7] + tokens["border-success-selected"] = (noInk && isDark ? successl : success)[9] + tokens["border-warning-base"] = (noInk && isDark ? warningl : warning)[6] + tokens["border-warning-hover"] = (noInk && isDark ? warningl : warning)[7] + tokens["border-warning-selected"] = (noInk && isDark ? warningl : warning)[9] + tokens["border-critical-base"] = error[isDark ? 5 : 6] + tokens["border-critical-hover"] = error[7] + tokens["border-critical-selected"] = error[9] + tokens["border-info-base"] = (noInk && isDark ? infol : info)[6] + tokens["border-info-hover"] = (noInk && isDark ? infol : info)[7] + tokens["border-info-selected"] = (noInk && isDark ? infol : info)[9] tokens["border-color"] = "#ffffff" tokens["icon-base"] = hasInk && !isDark ? tokens["text-weak"] : neutral[isDark ? 9 : 8] @@ -411,7 +453,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-strong-disabled"] = noInk && isDark ? neutral[6] : neutral[7] tokens["icon-strong-focus"] = isDark ? "#fdfcfc" : "#020202" tokens["icon-brand-base"] = isDark ? "#ffffff" : neutral[11] - tokens["icon-interactive-base"] = interactive[8] + tokens["icon-interactive-base"] = interactive[9] tokens["icon-success-base"] = success[isDark ? 8 : 6] tokens["icon-success-hover"] = success[isDark ? 9 : 7] tokens["icon-success-active"] = success[10] @@ -424,28 +466,28 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["icon-info-base"] = info[isDark ? 6 : 6] tokens["icon-info-hover"] = info[7] tokens["icon-info-active"] = info[10] - tokens["icon-on-brand-base"] = neutralAlpha[10] - tokens["icon-on-brand-hover"] = neutralAlpha[11] - tokens["icon-on-brand-selected"] = neutralAlpha[11] - tokens["icon-on-interactive-base"] = isDark ? neutral[11] : neutral[0] + tokens["icon-on-brand-base"] = on(brandb) + tokens["icon-on-brand-hover"] = on(brandh) + tokens["icon-on-brand-selected"] = on(brandh) + tokens["icon-on-interactive-base"] = on(interb) tokens["icon-agent-plan-base"] = info[8] tokens["icon-agent-docs-base"] = amber[8] tokens["icon-agent-ask-base"] = blue[8] tokens["icon-agent-build-base"] = interactive[isDark ? 10 : 8] - tokens["icon-on-success-base"] = withAlpha(success[8], 0.9) as ColorValue - tokens["icon-on-success-hover"] = withAlpha(success[9], 0.9) as ColorValue - tokens["icon-on-success-selected"] = withAlpha(success[10], 0.9) as ColorValue - tokens["icon-on-warning-base"] = withAlpha(amber[8], 0.9) as ColorValue - tokens["icon-on-warning-hover"] = withAlpha(amber[9], 0.9) as ColorValue - tokens["icon-on-warning-selected"] = withAlpha(amber[10], 0.9) as ColorValue - tokens["icon-on-critical-base"] = withAlpha(error[8], 0.9) as ColorValue - tokens["icon-on-critical-hover"] = withAlpha(error[9], 0.9) as ColorValue - tokens["icon-on-critical-selected"] = withAlpha(error[10], 0.9) as ColorValue - tokens["icon-on-info-base"] = info[8] - tokens["icon-on-info-hover"] = withAlpha(info[9], 0.9) as ColorValue - tokens["icon-on-info-selected"] = withAlpha(info[10], 0.9) as ColorValue + tokens["icon-on-success-base"] = on(succb) + tokens["icon-on-success-hover"] = on(succs) + tokens["icon-on-success-selected"] = on(succs) + tokens["icon-on-warning-base"] = on(warnb) + tokens["icon-on-warning-hover"] = on(warns) + tokens["icon-on-warning-selected"] = on(warns) + tokens["icon-on-critical-base"] = on(critb) + tokens["icon-on-critical-hover"] = on(crits) + tokens["icon-on-critical-selected"] = on(crits) + tokens["icon-on-info-base"] = on(infob) + tokens["icon-on-info-hover"] = on(infos) + tokens["icon-on-info-selected"] = on(infos) tokens["icon-diff-add-base"] = diffAdd[10] tokens["icon-diff-add-hover"] = diffAdd[isDark ? 9 : 11] @@ -459,7 +501,7 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["syntax-comment"] = "var(--text-weak)" tokens["syntax-regexp"] = "var(--text-base)" tokens["syntax-string"] = isDark ? "#00ceb9" : "#006656" - tokens["syntax-keyword"] = "var(--text-weak)" + tokens["syntax-keyword"] = content(colors.accent, accent) tokens["syntax-primitive"] = isDark ? "#ffba92" : "#fb4804" tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" tokens["syntax-variable"] = "var(--text-strong)" @@ -468,9 +510,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80" tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" tokens["syntax-object"] = "var(--text-strong)" - tokens["syntax-success"] = success[9] - tokens["syntax-warning"] = amber[9] - tokens["syntax-critical"] = error[9] + tokens["syntax-success"] = success[10] + tokens["syntax-warning"] = amber[10] + tokens["syntax-critical"] = error[10] tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8" tokens["syntax-diff-add"] = diffAdd[10] tokens["syntax-diff-delete"] = diffDelete[10] @@ -496,18 +538,18 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["syntax-comment"] = "var(--text-weak)" tokens["syntax-regexp"] = "var(--text-base)" tokens["syntax-string"] = content(colors.success, success) - tokens["syntax-keyword"] = "var(--text-weak)" - tokens["syntax-primitive"] = content(colors.accent, accent) + tokens["syntax-keyword"] = content(colors.accent, accent) + tokens["syntax-primitive"] = content(colors.primary, primary) tokens["syntax-operator"] = isDark ? "var(--text-weak)" : "var(--text-base)" tokens["syntax-variable"] = "var(--text-strong)" - tokens["syntax-property"] = content(colors.primary, primary) + tokens["syntax-property"] = content(colors.info, info) tokens["syntax-type"] = content(colors.warning, warning) - tokens["syntax-constant"] = content(colors.info, info) + tokens["syntax-constant"] = content(colors.accent, accent) tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" tokens["syntax-object"] = "var(--text-strong)" - tokens["syntax-success"] = success[9] - tokens["syntax-warning"] = amber[9] - tokens["syntax-critical"] = error[9] + tokens["syntax-success"] = success[10] + tokens["syntax-warning"] = amber[10] + tokens["syntax-critical"] = error[10] tokens["syntax-info"] = content(colors.info, info) tokens["syntax-diff-add"] = diffAdd[10] tokens["syntax-diff-delete"] = diffDelete[10] @@ -543,9 +585,9 @@ export function resolveThemeVariant(variant: ThemeVariant, isDark: boolean): Res tokens["syntax-constant"] = isDark ? "#93e9f6" : "#007b80" tokens["syntax-punctuation"] = isDark ? "var(--text-weak)" : "var(--text-base)" tokens["syntax-object"] = "var(--text-strong)" - tokens["syntax-success"] = success[9] - tokens["syntax-warning"] = amber[9] - tokens["syntax-critical"] = error[9] + tokens["syntax-success"] = success[10] + tokens["syntax-warning"] = amber[10] + tokens["syntax-critical"] = error[10] tokens["syntax-info"] = isDark ? "#93e9f6" : "#0092a8" tokens["syntax-diff-add"] = diffAdd[10] tokens["syntax-diff-delete"] = diffDelete[10] @@ -677,17 +719,10 @@ function generateNeutralOverlayScale(neutralScale: HexColor[], isDark: boolean): function generateNeutralAlphaScale(neutralScale: HexColor[], isDark: boolean): HexColor[] { const alphas = isDark - ? [0.024, 0.048, 0.088, 0.128, 0.17, 0.215, 0.275, 0.38, 0.46, 0.54, 0.74, 0.95] - : [0.014, 0.034, 0.066, 0.098, 0.128, 0.158, 0.208, 0.282, 0.47, 0.625, 0.515, 0.88] - - return neutralScale.map((hex, i) => { - const baseOklch = hexToOklch(hex) - const targetL = isDark ? 0.1 + alphas[i] * 0.8 : 1 - alphas[i] * 0.8 - return oklchToHex({ - ...baseOklch, - l: baseOklch.l * alphas[i] + targetL * (1 - alphas[i]), - }) - }) + ? [0.05, 0.085, 0.13, 0.18, 0.24, 0.31, 0.4, 0.52, 0.64, 0.76, 0.88, 0.98] + : [0.03, 0.06, 0.1, 0.145, 0.2, 0.265, 0.35, 0.47, 0.61, 0.74, 0.86, 0.97] + + return alphas.map((alpha) => blend(neutralScale[11], neutralScale[0], alpha)) } function getHex(value: ColorValue | undefined): HexColor | undefined { diff --git a/packages/ui/src/theme/themes/aura.json b/packages/ui/src/theme/themes/aura.json index e65eb4b0aa4..6b70aae9cab 100644 --- a/packages/ui/src/theme/themes/aura.json +++ b/packages/ui/src/theme/themes/aura.json @@ -16,7 +16,13 @@ "diffDelete": "#f5b3b3" }, "overrides": { - "syntax-keyword": "#7b5ae0" + "syntax-comment": "#8d88a3", + "syntax-keyword": "#7b5ae0", + "syntax-string": "#2b8a57", + "syntax-primitive": "#2f78b8", + "syntax-property": "#a96a22", + "syntax-type": "#2b8a57", + "syntax-constant": "#d94f4f" } }, "dark": { @@ -33,7 +39,13 @@ "diffDelete": "#ff6767" }, "overrides": { - "syntax-keyword": "#a277ff" + "syntax-comment": "#6d6a7e", + "syntax-keyword": "#a277ff", + "syntax-string": "#61ffca", + "syntax-primitive": "#82e2ff", + "syntax-property": "#ffca85", + "syntax-type": "#61ffca", + "syntax-constant": "#ff6767" } } } diff --git a/packages/ui/src/theme/themes/ayu.json b/packages/ui/src/theme/themes/ayu.json index f4594890355..e6d73f1ee52 100644 --- a/packages/ui/src/theme/themes/ayu.json +++ b/packages/ui/src/theme/themes/ayu.json @@ -16,7 +16,13 @@ "diffDelete": "#e6656a" }, "overrides": { - "syntax-keyword": "#ea9f41" + "syntax-comment": "#6e7681", + "syntax-keyword": "#c76a1a", + "syntax-string": "#6f8f00", + "syntax-primitive": "#b87500", + "syntax-property": "#2f86b7", + "syntax-type": "#227fc0", + "syntax-constant": "#a37acc" } }, "dark": { @@ -33,7 +39,13 @@ "diffDelete": "#f58572" }, "overrides": { - "syntax-keyword": "#ffad66" + "syntax-comment": "#5a6673", + "syntax-keyword": "#ff8f40", + "syntax-string": "#aad94c", + "syntax-primitive": "#ffb454", + "syntax-property": "#39bae6", + "syntax-type": "#59c2ff", + "syntax-constant": "#d2a6ff" } } } diff --git a/packages/ui/src/theme/themes/carbonfox.json b/packages/ui/src/theme/themes/carbonfox.json index 54e55cdeae1..50e6475ae30 100644 --- a/packages/ui/src/theme/themes/carbonfox.json +++ b/packages/ui/src/theme/themes/carbonfox.json @@ -17,7 +17,13 @@ "diffDelete": "#da1e28" }, "overrides": { - "syntax-keyword": "#8a3ffc" + "syntax-comment": "#6f6f6f", + "syntax-keyword": "#8a3ffc", + "syntax-string": "#198038", + "syntax-primitive": "#0f62fe", + "syntax-property": "#0043ce", + "syntax-type": "#8a5f00", + "syntax-constant": "#da1e28" } }, "dark": { @@ -35,7 +41,13 @@ "diffDelete": "#ff8389" }, "overrides": { - "syntax-keyword": "#be95ff" + "syntax-comment": "#6f6f6f", + "syntax-keyword": "#be95ff", + "syntax-string": "#42be65", + "syntax-primitive": "#33b1ff", + "syntax-property": "#78a9ff", + "syntax-type": "#f1c21b", + "syntax-constant": "#ff8389" } } } diff --git a/packages/ui/src/theme/themes/catppuccin.json b/packages/ui/src/theme/themes/catppuccin.json index 66fd37e26ba..b67dfaf3e67 100644 --- a/packages/ui/src/theme/themes/catppuccin.json +++ b/packages/ui/src/theme/themes/catppuccin.json @@ -16,8 +16,10 @@ "diffDelete": "#e78284" }, "overrides": { + "syntax-comment": "#6c7086", "syntax-keyword": "#8839ef", - "syntax-primitive": "#fe640b" + "syntax-primitive": "#1e66f5", + "syntax-constant": "#ca6702" } }, "dark": { @@ -34,8 +36,10 @@ "diffDelete": "#f38ba8" }, "overrides": { + "syntax-comment": "#6c7086", "syntax-keyword": "#cba6f7", - "syntax-primitive": "#fab387" + "syntax-primitive": "#89b4fa", + "syntax-constant": "#fab387" } } } diff --git a/packages/ui/src/theme/themes/dracula.json b/packages/ui/src/theme/themes/dracula.json index 495042ca7b0..daebb0df5c7 100644 --- a/packages/ui/src/theme/themes/dracula.json +++ b/packages/ui/src/theme/themes/dracula.json @@ -16,9 +16,12 @@ "diffDelete": "#f8a1b8" }, "overrides": { + "syntax-comment": "#7d7f97", "syntax-keyword": "#d16090", "syntax-string": "#596600", - "syntax-primitive": "#7c6bf5" + "syntax-primitive": "#2f8f57", + "syntax-property": "#1d7fc5", + "syntax-constant": "#7c6bf5" } }, "dark": { @@ -35,9 +38,12 @@ "diffDelete": "#ff6b81" }, "overrides": { + "syntax-comment": "#6272a4", "syntax-keyword": "#ff79c6", "syntax-string": "#f1fa8c", - "syntax-primitive": "#bd93f9" + "syntax-primitive": "#50fa7b", + "syntax-property": "#8be9fd", + "syntax-constant": "#bd93f9" } } } diff --git a/packages/ui/src/theme/themes/gruvbox.json b/packages/ui/src/theme/themes/gruvbox.json index f078db2d4c2..c1af64e9131 100644 --- a/packages/ui/src/theme/themes/gruvbox.json +++ b/packages/ui/src/theme/themes/gruvbox.json @@ -16,8 +16,10 @@ "diffDelete": "#9d0006" }, "overrides": { + "syntax-comment": "#928374", "syntax-keyword": "#9d0006", - "syntax-primitive": "#8f3f71" + "syntax-primitive": "#076678", + "syntax-constant": "#8f3f71" } }, "dark": { @@ -34,8 +36,10 @@ "diffDelete": "#fb4934" }, "overrides": { + "syntax-comment": "#928374", "syntax-keyword": "#fb4934", - "syntax-primitive": "#d3869b" + "syntax-primitive": "#83a598", + "syntax-constant": "#d3869b" } } } diff --git a/packages/ui/src/theme/themes/monokai.json b/packages/ui/src/theme/themes/monokai.json index 3a2656b6ea1..d680484a2f8 100644 --- a/packages/ui/src/theme/themes/monokai.json +++ b/packages/ui/src/theme/themes/monokai.json @@ -16,9 +16,12 @@ "diffDelete": "#f6a3ae" }, "overrides": { + "syntax-comment": "#8a816f", "syntax-keyword": "#d9487c", "syntax-string": "#8a6500", - "syntax-primitive": "#bf7bff" + "syntax-primitive": "#3c8d2f", + "syntax-property": "#1f88c8", + "syntax-constant": "#9b5fe0" } }, "dark": { @@ -35,9 +38,12 @@ "diffDelete": "#f4477c" }, "overrides": { + "syntax-comment": "#75715e", "syntax-keyword": "#f92672", "syntax-string": "#e6db74", - "syntax-primitive": "#ae81ff" + "syntax-primitive": "#a6e22e", + "syntax-property": "#66d9ef", + "syntax-constant": "#ae81ff" } } } diff --git a/packages/ui/src/theme/themes/nightowl.json b/packages/ui/src/theme/themes/nightowl.json index d6b4d4dad21..863bd5a7604 100644 --- a/packages/ui/src/theme/themes/nightowl.json +++ b/packages/ui/src/theme/themes/nightowl.json @@ -16,7 +16,10 @@ "diffDelete": "#de3d3b" }, "overrides": { - "syntax-keyword": "#994cc3" + "syntax-comment": "#7a8181", + "syntax-keyword": "#994cc3", + "syntax-primitive": "#4876d6", + "syntax-constant": "#c96765" } }, "dark": { @@ -36,7 +39,8 @@ "syntax-comment": "#637777", "syntax-keyword": "#c792ea", "syntax-string": "#ecc48d", - "syntax-primitive": "#f78c6c" + "syntax-primitive": "#82aaff", + "syntax-constant": "#f78c6c" } } } diff --git a/packages/ui/src/theme/themes/nord.json b/packages/ui/src/theme/themes/nord.json index 05ec4672ec5..136011ae5d0 100644 --- a/packages/ui/src/theme/themes/nord.json +++ b/packages/ui/src/theme/themes/nord.json @@ -16,9 +16,11 @@ "diffDelete": "#bf616a" }, "overrides": { + "syntax-comment": "#6b7282", "syntax-keyword": "#5e81ac", - "syntax-string": "#a3be8c", - "syntax-primitive": "#b48ead" + "syntax-string": "#6f8758", + "syntax-primitive": "#5e81ac", + "syntax-constant": "#8d6886" } }, "dark": { @@ -35,8 +37,10 @@ "diffDelete": "#bf616a" }, "overrides": { + "syntax-comment": "#616e88", "syntax-keyword": "#81a1c1", - "syntax-primitive": "#b48ead" + "syntax-primitive": "#88c0d0", + "syntax-constant": "#b48ead" } } } diff --git a/packages/ui/src/theme/themes/oc-2.json b/packages/ui/src/theme/themes/oc-2.json index dc413f0610b..90bfcb39d87 100644 --- a/packages/ui/src/theme/themes/oc-2.json +++ b/packages/ui/src/theme/themes/oc-2.json @@ -15,6 +15,16 @@ "diffDelete": "#fc533a" }, "overrides": { + "syntax-comment": "#7a7a7a", + "syntax-keyword": "#a753ae", + "syntax-string": "#00ceb9", + "syntax-primitive": "#034cff", + "syntax-property": "#a753ae", + "syntax-type": "#8a6f00", + "syntax-constant": "#007b80", + "syntax-critical": "#ff8c00", + "syntax-diff-delete": "#ff8c00", + "syntax-diff-unknown": "#a753ae", "surface-critical-base": "#FFF2F0" } }, @@ -31,6 +41,16 @@ "diffDelete": "#fc533a" }, "overrides": { + "syntax-comment": "#8f8f8f", + "syntax-keyword": "#edb2f1", + "syntax-string": "#00ceb9", + "syntax-primitive": "#8cb0ff", + "syntax-property": "#fab283", + "syntax-type": "#fcd53a", + "syntax-constant": "#93e9f6", + "syntax-critical": "#fab283", + "syntax-diff-delete": "#fab283", + "syntax-diff-unknown": "#edb2f1", "surface-critical-base": "#1F0603" } } diff --git a/packages/ui/src/theme/themes/onedarkpro.json b/packages/ui/src/theme/themes/onedarkpro.json index be17dedff3d..44b0f3d27fd 100644 --- a/packages/ui/src/theme/themes/onedarkpro.json +++ b/packages/ui/src/theme/themes/onedarkpro.json @@ -16,8 +16,10 @@ "diffDelete": "#f7c1c5" }, "overrides": { + "syntax-comment": "#6a717d", "syntax-keyword": "#a626a4", - "syntax-primitive": "#986801" + "syntax-primitive": "#4078f2", + "syntax-constant": "#986801" } }, "dark": { @@ -34,8 +36,10 @@ "diffDelete": "#b2555f" }, "overrides": { + "syntax-comment": "#5c6370", "syntax-keyword": "#c678dd", - "syntax-primitive": "#d19a66" + "syntax-primitive": "#61afef", + "syntax-constant": "#d19a66" } } } diff --git a/packages/ui/src/theme/themes/shadesofpurple.json b/packages/ui/src/theme/themes/shadesofpurple.json index 03af35c2a3d..99c299191ed 100644 --- a/packages/ui/src/theme/themes/shadesofpurple.json +++ b/packages/ui/src/theme/themes/shadesofpurple.json @@ -16,7 +16,13 @@ "diffDelete": "#ffc3ef" }, "overrides": { - "syntax-keyword": "#ff6bd5" + "syntax-comment": "#8e4be3", + "syntax-keyword": "#c45f00", + "syntax-string": "#2f8b32", + "syntax-primitive": "#a13bd6", + "syntax-property": "#008fb8", + "syntax-type": "#9d7a00", + "syntax-constant": "#e04d7a" } }, "dark": { @@ -33,7 +39,13 @@ "diffDelete": "#d85aa0" }, "overrides": { - "syntax-keyword": "#ff7ac6" + "syntax-comment": "#b362ff", + "syntax-keyword": "#ff9d00", + "syntax-string": "#a5ff90", + "syntax-primitive": "#fb94ff", + "syntax-property": "#9effff", + "syntax-type": "#fad000", + "syntax-constant": "#ff628c" } } } diff --git a/packages/ui/src/theme/themes/solarized.json b/packages/ui/src/theme/themes/solarized.json index 24a4daf4583..98fb4d09802 100644 --- a/packages/ui/src/theme/themes/solarized.json +++ b/packages/ui/src/theme/themes/solarized.json @@ -16,8 +16,12 @@ "diffDelete": "#f2a1a1" }, "overrides": { - "syntax-keyword": "#859900", - "syntax-string": "#2aa198" + "syntax-comment": "#657b83", + "syntax-keyword": "#728600", + "syntax-string": "#1f8f88", + "syntax-primitive": "#268bd2", + "syntax-property": "#268bd2", + "syntax-constant": "#d33682" } }, "dark": { @@ -34,8 +38,12 @@ "diffDelete": "#c34b4b" }, "overrides": { + "syntax-comment": "#586e75", "syntax-keyword": "#859900", - "syntax-string": "#2aa198" + "syntax-string": "#2aa198", + "syntax-primitive": "#268bd2", + "syntax-property": "#268bd2", + "syntax-constant": "#d33682" } } } diff --git a/packages/ui/src/theme/themes/tokyonight.json b/packages/ui/src/theme/themes/tokyonight.json index d29c3599425..2193b3f69c6 100644 --- a/packages/ui/src/theme/themes/tokyonight.json +++ b/packages/ui/src/theme/themes/tokyonight.json @@ -16,7 +16,11 @@ "diffDelete": "#d05f7c" }, "overrides": { - "syntax-keyword": "#9854f1" + "syntax-comment": "#6b6f7a", + "syntax-keyword": "#9854f1", + "syntax-primitive": "#1f6fd4", + "syntax-property": "#007197", + "syntax-constant": "#b15c00" } }, "dark": { @@ -33,7 +37,11 @@ "diffDelete": "#c34043" }, "overrides": { - "syntax-keyword": "#bb9af7" + "syntax-comment": "#565f89", + "syntax-keyword": "#bb9af7", + "syntax-primitive": "#7aa2f7", + "syntax-property": "#7dcfff", + "syntax-constant": "#ff9e64" } } } diff --git a/packages/ui/src/theme/themes/vesper.json b/packages/ui/src/theme/themes/vesper.json index 8cc658232f8..1283b61e503 100644 --- a/packages/ui/src/theme/themes/vesper.json +++ b/packages/ui/src/theme/themes/vesper.json @@ -16,7 +16,13 @@ "diffDelete": "#FF8080" }, "overrides": { - "syntax-keyword": "#b30000" + "syntax-comment": "#7a7a7a", + "syntax-keyword": "#6e6e6e", + "syntax-string": "#117e69", + "syntax-primitive": "#8d541c", + "syntax-property": "#101010", + "syntax-type": "#8d541c", + "syntax-constant": "#8d541c" } }, "dark": { @@ -33,8 +39,13 @@ "diffDelete": "#FF8080" }, "overrides": { - "syntax-keyword": "#ff8080", - "syntax-primitive": "#ffc799" + "syntax-comment": "#8b8b8b", + "syntax-keyword": "#a0a0a0", + "syntax-string": "#99ffe4", + "syntax-primitive": "#ffc799", + "syntax-property": "#ffffff", + "syntax-type": "#ffc799", + "syntax-constant": "#ffc799" } } } From 0e077f748352df6d44c811829baff3c26b3436ac Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:31:52 -0500 Subject: [PATCH 008/145] feat: session load perf (#17186) --- packages/app/src/context/global-sync.tsx | 2 + .../global-sync/session-prefetch.test.ts | 63 ++++++ .../context/global-sync/session-prefetch.ts | 85 +++++++ packages/app/src/context/sync.tsx | 115 +++++++--- packages/app/src/pages/layout.tsx | 210 +++++++++++------- .../app/src/pages/layout/sidebar-items.tsx | 53 ++++- .../app/src/pages/layout/sidebar-project.tsx | 12 +- .../src/pages/layout/sidebar-workspace.tsx | 3 + packages/app/src/pages/session.tsx | 71 +++++- 9 files changed, 474 insertions(+), 140 deletions(-) create mode 100644 packages/app/src/context/global-sync/session-prefetch.test.ts create mode 100644 packages/app/src/context/global-sync/session-prefetch.ts diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 645bd678b7d..1b6cdf530a7 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -29,6 +29,7 @@ import { bootstrapDirectory, bootstrapGlobal } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent, cleanupDroppedSessionCaches } from "./global-sync/event-reducer" import { createRefreshQueue } from "./global-sync/queue" +import { clearSessionPrefetchDirectory } from "./global-sync/session-prefetch" import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" @@ -161,6 +162,7 @@ function createGlobalSync() { queue.clear(directory) sessionMeta.delete(directory) sdkCache.delete(directory) + clearSessionPrefetchDirectory(directory) }, }) diff --git a/packages/app/src/context/global-sync/session-prefetch.test.ts b/packages/app/src/context/global-sync/session-prefetch.test.ts new file mode 100644 index 00000000000..f039b02ca8a --- /dev/null +++ b/packages/app/src/context/global-sync/session-prefetch.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { + clearSessionPrefetch, + clearSessionPrefetchDirectory, + getSessionPrefetch, + runSessionPrefetch, + setSessionPrefetch, +} from "./session-prefetch" + +describe("session prefetch", () => { + test("stores and clears message metadata by directory", () => { + clearSessionPrefetch("/tmp/a", ["ses_1"]) + clearSessionPrefetch("/tmp/b", ["ses_1"]) + + setSessionPrefetch({ + directory: "/tmp/a", + sessionID: "ses_1", + limit: 200, + complete: false, + at: 123, + }) + + expect(getSessionPrefetch("/tmp/a", "ses_1")).toEqual({ limit: 200, complete: false, at: 123 }) + expect(getSessionPrefetch("/tmp/b", "ses_1")).toBeUndefined() + + clearSessionPrefetch("/tmp/a", ["ses_1"]) + + expect(getSessionPrefetch("/tmp/a", "ses_1")).toBeUndefined() + }) + + test("dedupes inflight work", async () => { + clearSessionPrefetch("/tmp/c", ["ses_2"]) + + let calls = 0 + const run = () => + runSessionPrefetch({ + directory: "/tmp/c", + sessionID: "ses_2", + task: async () => { + calls += 1 + return { limit: 100, complete: true, at: 456 } + }, + }) + + const [a, b] = await Promise.all([run(), run()]) + + expect(calls).toBe(1) + expect(a).toEqual({ limit: 100, complete: true, at: 456 }) + expect(b).toEqual({ limit: 100, complete: true, at: 456 }) + }) + + test("clears a whole directory", () => { + setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_1", limit: 10, complete: true, at: 1 }) + setSessionPrefetch({ directory: "/tmp/d", sessionID: "ses_2", limit: 20, complete: false, at: 2 }) + setSessionPrefetch({ directory: "/tmp/e", sessionID: "ses_1", limit: 30, complete: true, at: 3 }) + + clearSessionPrefetchDirectory("/tmp/d") + + expect(getSessionPrefetch("/tmp/d", "ses_1")).toBeUndefined() + expect(getSessionPrefetch("/tmp/d", "ses_2")).toBeUndefined() + expect(getSessionPrefetch("/tmp/e", "ses_1")).toEqual({ limit: 30, complete: true, at: 3 }) + }) +}) diff --git a/packages/app/src/context/global-sync/session-prefetch.ts b/packages/app/src/context/global-sync/session-prefetch.ts new file mode 100644 index 00000000000..10877b0639f --- /dev/null +++ b/packages/app/src/context/global-sync/session-prefetch.ts @@ -0,0 +1,85 @@ +const key = (directory: string, sessionID: string) => `${directory}\n${sessionID}` + +export const SESSION_PREFETCH_TTL = 15_000 + +type Meta = { + limit: number + complete: boolean + at: number +} + +const cache = new Map() +const inflight = new Map>() +const rev = new Map() + +const version = (id: string) => rev.get(id) ?? 0 + +export function getSessionPrefetch(directory: string, sessionID: string) { + return cache.get(key(directory, sessionID)) +} + +export function getSessionPrefetchPromise(directory: string, sessionID: string) { + return inflight.get(key(directory, sessionID)) +} + +export function clearSessionPrefetchInflight() { + inflight.clear() +} + +export function isSessionPrefetchCurrent(directory: string, sessionID: string, value: number) { + return version(key(directory, sessionID)) === value +} + +export function runSessionPrefetch(input: { + directory: string + sessionID: string + task: (value: number) => Promise +}) { + const id = key(input.directory, input.sessionID) + const pending = inflight.get(id) + if (pending) return pending + + const value = version(id) + + const promise = input.task(value).finally(() => { + if (inflight.get(id) === promise) inflight.delete(id) + }) + + inflight.set(id, promise) + return promise +} + +export function setSessionPrefetch(input: { + directory: string + sessionID: string + limit: number + complete: boolean + at?: number +}) { + cache.set(key(input.directory, input.sessionID), { + limit: input.limit, + complete: input.complete, + at: input.at ?? Date.now(), + }) +} + +export function clearSessionPrefetch(directory: string, sessionIDs: Iterable) { + for (const sessionID of sessionIDs) { + if (!sessionID) continue + const id = key(directory, sessionID) + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} + +export function clearSessionPrefetchDirectory(directory: string) { + const prefix = `${directory}\n` + const keys = new Set([...cache.keys(), ...inflight.keys()]) + for (const id of keys) { + if (!id.startsWith(prefix)) continue + rev.set(id, version(id) + 1) + cache.delete(id) + inflight.delete(id) + } +} diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 5623a2c7cd8..db7b0638829 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -3,6 +3,12 @@ import { createStore, produce, reconcile } from "solid-js/store" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" import { createSimpleContext } from "@opencode-ai/ui/context" +import { + clearSessionPrefetch, + getSessionPrefetch, + getSessionPrefetchPromise, + setSessionPrefetch, +} from "./global-sync/session-prefetch" import { useGlobalSync } from "./global-sync" import { useSDK } from "./sdk" import type { Message, Part } from "@opencode-ai/sdk/v2/client" @@ -160,6 +166,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const evict = (directory: string, setStore: Setter, sessionIDs: string[]) => { if (sessionIDs.length === 0) return + clearSessionPrefetch(directory, sessionIDs) for (const sessionID of sessionIDs) { globalSync.todo.set(sessionID, undefined) } @@ -217,6 +224,12 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ } setMeta("limit", key, input.limit) setMeta("complete", key, next.complete) + setSessionPrefetch({ + directory: input.directory, + sessionID: input.sessionID, + limit: input.limit, + complete: next.complete, + }) }) }) .finally(() => { @@ -280,54 +293,82 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ parts: input.parts, }) }, - async sync(sessionID: string) { + async sync(sessionID: string, opts?: { force?: boolean }) { const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) const key = keyFor(directory, sessionID) - const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found touch(directory, setStore, sessionID) - if (store.message[sessionID] !== undefined && hasSession && meta.limit[key] !== undefined) return - - const limit = meta.limit[key] ?? messagePageSize - - const sessionReq = hasSession - ? Promise.resolve() - : retry(() => client.session.get({ sessionID })).then((session) => { - if (!tracked(directory, sessionID)) return - const data = session.data - if (!data) return - setStore( - "session", - produce((draft) => { - const match = Binary.search(draft, sessionID, (s) => s.id) - if (match.found) { - draft[match.index] = data - return - } - draft.splice(match.index, 0, data) - }), - ) - }) - - const messagesReq = loadMessages({ - directory, - client, - setStore, - sessionID, - limit, - }) + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) + }) + } - return runInflight(inflight, key, () => Promise.all([sessionReq, messagesReq]).then(() => {})) + return runInflight(inflight, key, async () => { + const pending = getSessionPrefetchPromise(directory, sessionID) + if (pending) { + await pending + const seeded = getSessionPrefetch(directory, sessionID) + if (seeded && store.message[sessionID] !== undefined && meta.limit[key] === undefined) { + batch(() => { + setMeta("limit", key, seeded.limit) + setMeta("complete", key, seeded.complete) + setMeta("loading", key, false) + }) + } + } + + const hasSession = Binary.search(store.session, sessionID, (s) => s.id).found + const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined + if (cached && hasSession && !opts?.force) return + + const limit = meta.limit[key] ?? messagePageSize + const sessionReq = + hasSession && !opts?.force + ? Promise.resolve() + : retry(() => client.session.get({ sessionID })).then((session) => { + if (!tracked(directory, sessionID)) return + const data = session.data + if (!data) return + setStore( + "session", + produce((draft) => { + const match = Binary.search(draft, sessionID, (s) => s.id) + if (match.found) { + draft[match.index] = data + return + } + draft.splice(match.index, 0, data) + }), + ) + }) + + const messagesReq = + cached && !opts?.force + ? Promise.resolve() + : loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) + + await Promise.all([sessionReq, messagesReq]) + }) }, - async diff(sessionID: string) { + async diff(sessionID: string, opts?: { force?: boolean }) { const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) - if (store.session_diff[sessionID] !== undefined) return + if (store.session_diff[sessionID] !== undefined && !opts?.force) return const key = keyFor(directory, sessionID) return runInflight(inflightDiff, key, () => @@ -337,7 +378,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ }), ) }, - async todo(sessionID: string) { + async todo(sessionID: string, opts?: { force?: boolean }) { const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) @@ -348,7 +389,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ if (cached === undefined) { globalSync.todo.set(sessionID, existing) } - return + if (!opts?.force) return } if (cached !== undefined) { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index eb302810119..da857a60312 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -35,6 +35,15 @@ import { showToast, Toast, toaster } from "@opencode-ai/ui/toast" import { useGlobalSDK } from "@/context/global-sdk" import { clearWorkspaceTerminals } from "@/context/terminal" import { dropSessionCaches, pickSessionCacheEvictions } from "@/context/global-sync/session-cache" +import { + clearSessionPrefetchInflight, + clearSessionPrefetch, + getSessionPrefetch, + isSessionPrefetchCurrent, + runSessionPrefetch, + SESSION_PREFETCH_TTL, + setSessionPrefetch, +} from "@/context/global-sync/session-prefetch" import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" @@ -662,8 +671,9 @@ export default function Layout(props: ParentProps) { } const prefetchChunk = 200 - const prefetchConcurrency = 1 - const prefetchPendingLimit = 6 + const prefetchConcurrency = 2 + const prefetchPendingLimit = 10 + const span = 4 const prefetchToken = { value: 0 } const prefetchQueues = new Map() @@ -688,14 +698,30 @@ export default function Layout(props: ParentProps) { }) } + createEffect(() => { + const active = new Set(visibleSessionDirs()) + for (const directory of [...prefetchedByDir.keys()]) { + if (active.has(directory)) continue + prefetchedByDir.delete(directory) + } + }) + createEffect(() => { params.dir globalSDK.url prefetchToken.value += 1 - for (const q of prefetchQueues.values()) { + clearSessionPrefetchInflight() + prefetchQueues.clear() + }) + + createEffect(() => { + const visible = new Set(visibleSessionDirs()) + for (const [directory, q] of prefetchQueues) { + if (visible.has(directory)) continue q.pending.length = 0 q.pendingSet.clear() + if (q.running === 0) prefetchQueues.delete(directory) } }) @@ -731,36 +757,67 @@ export default function Layout(props: ParentProps) { async function prefetchMessages(directory: string, sessionID: string, token: number) { const [store, setStore] = globalSync.child(directory, { bootstrap: false }) - return retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) - .then((messages) => { - if (prefetchToken.value !== token) return - if (!lruFor(directory).has(sessionID)) return - - const items = (messages.data ?? []).filter((x) => !!x?.info?.id) - const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) - const sorted = mergeByID([], next) - - const current = store.message[sessionID] ?? [] - const merged = mergeByID( - current.filter((item): item is Message => !!item?.id), - sorted, - ) - - batch(() => { - setStore("message", sessionID, reconcile(merged, { key: "id" })) - - for (const message of items) { - const currentParts = store.part[message.info.id] ?? [] - const mergedParts = mergeByID( - currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id), - message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id), + return runSessionPrefetch({ + directory, + sessionID, + task: (rev) => + retry(() => globalSDK.client.session.messages({ directory, sessionID, limit: prefetchChunk })) + .then((messages) => { + if (prefetchToken.value !== token) return + if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return + + const items = (messages.data ?? []).filter((x) => !!x?.info?.id) + const next = items.map((x) => x.info).filter((m): m is Message => !!m?.id) + const sorted = mergeByID([], next) + const stale = markPrefetched(directory, sessionID) + const meta = { + limit: prefetchChunk, + complete: sorted.length < prefetchChunk, + at: Date.now(), + } + + if (stale.length > 0) { + clearSessionPrefetch(directory, stale) + for (const id of stale) { + globalSync.todo.set(id, undefined) + } + } + + const current = store.message[sessionID] ?? [] + const merged = mergeByID( + current.filter((item): item is Message => !!item?.id), + sorted, ) - setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) - } - }) - }) - .catch(() => undefined) + if (!isSessionPrefetchCurrent(directory, sessionID, rev)) return + + batch(() => { + if (stale.length > 0) { + setStore( + produce((draft) => { + dropSessionCaches(draft, stale) + }), + ) + } + + setStore("message", sessionID, reconcile(merged, { key: "id" })) + setSessionPrefetch({ directory, sessionID, ...meta }) + + for (const message of items) { + const currentParts = store.part[message.info.id] ?? [] + const mergedParts = mergeByID( + currentParts.filter((item): item is (typeof currentParts)[number] & { id: string } => !!item?.id), + message.parts.filter((item): item is (typeof message.parts)[number] & { id: string } => !!item?.id), + ) + + setStore("part", message.info.id, reconcile(mergedParts, { key: "id" })) + } + }) + + return meta + }) + .catch(() => undefined), + }) } const pumpPrefetch = (directory: string) => { @@ -788,28 +845,29 @@ export default function Layout(props: ParentProps) { if (!directory) return const [store] = globalSync.child(directory, { bootstrap: false }) - const cached = untrack(() => store.message[session.id] !== undefined) + const cached = untrack(() => { + if (store.message[session.id] === undefined) return false + const info = getSessionPrefetch(directory, session.id) + if (!info) return false + return Date.now() - info.at < SESSION_PREFETCH_TTL + }) if (cached) return const q = queueFor(directory) if (q.inflight.has(session.id)) return - if (q.pendingSet.has(session.id)) return + if (q.pendingSet.has(session.id)) { + if (priority !== "high") return + const index = q.pending.indexOf(session.id) + if (index > 0) { + q.pending.splice(index, 1) + q.pending.unshift(session.id) + } + return + } const lru = lruFor(directory) const known = lru.has(session.id) if (!known && lru.size >= PREFETCH_MAX_SESSIONS_PER_DIR && priority !== "high") return - const stale = markPrefetched(directory, session.id) - if (stale.length > 0) { - const [, setStore] = globalSync.child(directory, { bootstrap: false }) - for (const id of stale) { - globalSync.todo.set(id, undefined) - } - setStore( - produce((draft) => { - dropSessionCaches(draft, stale) - }), - ) - } if (priority === "high") q.pending.unshift(session.id) if (priority !== "high") q.pending.push(session.id) @@ -824,27 +882,29 @@ export default function Layout(props: ParentProps) { pumpPrefetch(directory) } - createEffect(() => { - const sessions = currentSessions() - const id = params.id - - if (!id) { - const first = sessions[0] - if (first) prefetchSession(first) + const warm = (sessions: Session[], index: number) => { + for (let offset = 1; offset <= span; offset++) { + const next = sessions[index + offset] + if (next) prefetchSession(next, offset === 1 ? "high" : "low") - const second = sessions[1] - if (second) prefetchSession(second) - return + const prev = sessions[index - offset] + if (prev) prefetchSession(prev, offset === 1 ? "high" : "low") } + } - const index = sessions.findIndex((s) => s.id === id) + createEffect(() => { + const sessions = currentSessions() + if (sessions.length === 0) return + + const index = params.id ? sessions.findIndex((s) => s.id === params.id) : 0 if (index === -1) return - const next = sessions[index + 1] - if (next) prefetchSession(next) + if (!params.id) { + const first = sessions[index] + if (first) prefetchSession(first, "high") + } - const prev = sessions[index - 1] - if (prev) prefetchSession(prev) + warm(sessions, index) }) function navigateSessionByOffset(offset: number) { @@ -863,18 +923,8 @@ export default function Layout(props: ParentProps) { const session = sessions[targetIndex] if (!session) return - const next = sessions[(targetIndex + 1) % sessions.length] - const prev = sessions[(targetIndex - 1 + sessions.length) % sessions.length] - - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } + prefetchSession(session, "high") + warm(sessions, targetIndex) navigateToSession(session) } @@ -896,19 +946,7 @@ export default function Layout(props: ParentProps) { if (notification.session.unseenCount(session.id) === 0) continue prefetchSession(session, "high") - - const next = sessions[(index + 1) % sessions.length] - const prev = sessions[(index - 1 + sessions.length) % sessions.length] - - if (offset > 0) { - if (next) prefetchSession(next, "high") - if (prev) prefetchSession(prev) - } - - if (offset < 0) { - if (prev) prefetchSession(prev, "high") - if (next) prefetchSession(next) - } + warm(sessions, index) navigateToSession(session) return @@ -1842,6 +1880,7 @@ export default function Layout(props: ParentProps) { const workspaceSidebarCtx: WorkspaceSidebarContext = { currentDir, + navList: currentSessions, sidebarExpanded, sidebarHovering, nav: () => state.nav, @@ -1887,6 +1926,7 @@ export default function Layout(props: ParentProps) { workspaceIds, workspaceLabel, sessionProps: { + navList: currentSessions, sidebarExpanded, sidebarHovering, nav: () => state.nav, diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 8dc03755e4a..b6c8fedb155 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -10,6 +10,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { A, useNavigate, useParams } from "@solidjs/router" import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { getSessionPrefetch, SESSION_PREFETCH_TTL } from "@/context/global-sync/session-prefetch" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -67,6 +68,8 @@ export const ProjectIcon = (props: { project: LocalProject; class?: string; noti export type SessionItemProps = { session: Session + list: Session[] + navList?: Accessor slug: string mobile?: boolean dense?: boolean @@ -95,18 +98,18 @@ const SessionRow = (props: { setHoverSession: (id: string | undefined) => void clearHoverProjectSoon: () => void sidebarOpened: Accessor - prefetchSession: (session: Session, priority?: "high" | "low") => void - scheduleHoverPrefetch: () => void + warmHover: () => void + warmPress: () => void + warmFocus: () => void cancelHoverPrefetch: () => void }): JSX.Element => ( props.prefetchSession(props.session, "high")} + onFocus={props.warmFocus} onClick={() => { props.setHoverSession(undefined) if (props.sidebarOpened()) return @@ -225,11 +228,37 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { const hoverMessages = createMemo(() => sessionStore.message[props.session.id]?.filter((message): message is UserMessage => message.role === "user"), ) - const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) + const hoverReady = createMemo(() => { + if (sessionStore.message[props.session.id] === undefined) return false + if (props.session.id === params.id) return true + const info = getSessionPrefetch(props.session.directory, props.session.id) + if (!info) return false + return Date.now() - info.at < SESSION_PREFETCH_TTL + }) const hoverAllowed = createMemo(() => !props.mobile && props.sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) + const warm = (span: number, priority: "high" | "low") => { + const nav = props.navList?.() + const list = nav?.some((item) => item.id === props.session.id && item.directory === props.session.directory) + ? nav + : props.list + + props.prefetchSession(props.session, priority) + + const idx = list.findIndex((item) => item.id === props.session.id && item.directory === props.session.directory) + if (idx === -1) return + + for (let step = 1; step <= span; step++) { + const next = list[idx + step] + if (next) props.prefetchSession(next, step === 1 ? "high" : priority) + + const prev = list[idx - step] + if (prev) props.prefetchSession(prev, step === 1 ? "high" : priority) + } + } + const hoverPrefetch = { current: undefined as ReturnType | undefined, } @@ -239,11 +268,12 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { hoverPrefetch.current = undefined } const scheduleHoverPrefetch = () => { + warm(1, "high") if (hoverPrefetch.current !== undefined) return hoverPrefetch.current = setTimeout(() => { hoverPrefetch.current = undefined - props.prefetchSession(props.session) - }, 200) + warm(2, "low") + }, 80) } onCleanup(cancelHoverPrefetch) @@ -267,8 +297,9 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { setHoverSession={props.setHoverSession} clearHoverProjectSoon={props.clearHoverProjectSoon} sidebarOpened={layout.sidebar.opened} - prefetchSession={props.prefetchSession} - scheduleHoverPrefetch={scheduleHoverPrefetch} + warmHover={scheduleHoverPrefetch} + warmPress={() => warm(2, "high")} + warmFocus={() => warm(2, "high")} cancelHoverPrefetch={cancelHoverPrefetch} /> ) diff --git a/packages/app/src/pages/layout/sidebar-project.tsx b/packages/app/src/pages/layout/sidebar-project.tsx index 551090fd5a6..a26bc183118 100644 --- a/packages/app/src/pages/layout/sidebar-project.tsx +++ b/packages/app/src/pages/layout/sidebar-project.tsx @@ -30,7 +30,7 @@ export type ProjectSidebarContext = { workspacesEnabled: (project: LocalProject) => boolean workspaceIds: (project: LocalProject) => string[] workspaceLabel: (directory: string, branch?: string, projectId?: string) => string - sessionProps: Omit + sessionProps: Omit setHoverSession: (id: string | undefined) => void } @@ -204,11 +204,12 @@ const ProjectPreviewPanel = (props: { + {(session) => ( {props.label(directory)} - + {(session) => ( globalSync.child(props.project.worktree, { bootstrap: false })[0]) - const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow()).slice(0, 2)) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), props.sortNow())) const projectChildren = createMemo(() => childMapByParent(projectStore().session)) const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - return sortedRootSessions(data, props.sortNow()).slice(0, 2) + return sortedRootSessions(data, props.sortNow()) } const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) diff --git a/packages/app/src/pages/layout/sidebar-workspace.tsx b/packages/app/src/pages/layout/sidebar-workspace.tsx index 5eb5e71cd7d..48c63e5478d 100644 --- a/packages/app/src/pages/layout/sidebar-workspace.tsx +++ b/packages/app/src/pages/layout/sidebar-workspace.tsx @@ -32,6 +32,7 @@ type InlineEditorComponent = (props: { export type WorkspaceSidebarContext = { currentDir: Accessor + navList: Accessor sidebarExpanded: Accessor sidebarHovering: Accessor nav: Accessor @@ -265,6 +266,8 @@ const WorkspaceSessionList = (props: { {(session) => ( { if (!prev) return if (next.dir === prev.dir && next.id === prev.id) return - if (prev.id) sync.session.evict(prev.id, prev.dir) if (!next.id) resetSessionModel(local) }, { defer: true }, @@ -464,6 +464,10 @@ export default function Page() { }, sessionKey()) let reviewFrame: number | undefined + let refreshFrame: number | undefined + let refreshTimer: number | undefined + let diffFrame: number | undefined + let diffTimer: number | undefined createComputed((prev) => { const open = desktopReviewOpen() @@ -623,10 +627,36 @@ export default function Page() { createEffect( on([() => sdk.directory, () => params.id] as const, ([, id]) => { + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + refreshFrame = undefined + refreshTimer = undefined if (!id) return + + const cached = untrack(() => sync.data.message[id] !== undefined) + const stale = !cached + ? false + : (() => { + const info = getSessionPrefetch(sdk.directory, id) + if (!info) return true + return Date.now() - info.at > SESSION_PREFETCH_TTL + })() + const todos = untrack(() => sync.data.todo[id] !== undefined || globalSync.data.session_todo[id] !== undefined) + untrack(() => { void sync.session.sync(id) - void sync.session.todo(id) + }) + + refreshFrame = requestAnimationFrame(() => { + refreshFrame = undefined + refreshTimer = window.setTimeout(() => { + refreshTimer = undefined + if (params.id !== id) return + untrack(() => { + if (stale) void sync.session.sync(id, { force: true }) + void sync.session.todo(id, todos ? { force: true } : undefined) + }) + }, 0) }) }), ) @@ -1064,6 +1094,39 @@ export default function Page() { void sync.session.diff(id) }) + createEffect( + on( + () => + [ + sessionKey(), + isDesktop() + ? desktopFileTreeOpen() || (desktopReviewOpen() && activeTab() === "review") + : store.mobileTab === "changes", + ] as const, + ([key, wants]) => { + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) + diffFrame = undefined + diffTimer = undefined + if (!wants) return + + const id = params.id + if (!id) return + if (!untrack(() => sync.data.session_diff[id] !== undefined)) return + + diffFrame = requestAnimationFrame(() => { + diffFrame = undefined + diffTimer = window.setTimeout(() => { + diffTimer = undefined + if (sessionKey() !== key) return + void sync.session.diff(id, { force: true }) + }, 0) + }) + }, + { defer: true }, + ), + ) + let treeDir: string | undefined createEffect(() => { const dir = sdk.directory @@ -1326,6 +1389,10 @@ export default function Page() { onCleanup(() => { document.removeEventListener("keydown", handleKeyDown) if (reviewFrame !== undefined) cancelAnimationFrame(reviewFrame) + if (refreshFrame !== undefined) cancelAnimationFrame(refreshFrame) + if (refreshTimer !== undefined) window.clearTimeout(refreshTimer) + if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) + if (diffTimer !== undefined) window.clearTimeout(diffTimer) if (scrollStateFrame !== undefined) cancelAnimationFrame(scrollStateFrame) }) From dce7eceb2855bc36a41bc49d9c56d5dcc92a8eb2 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 12 Mar 2026 11:32:05 -0500 Subject: [PATCH 009/145] chore: cleanup (#17197) --- .../components/dialog-custom-provider-form.ts | 159 ++++ .../components/dialog-custom-provider.test.ts | 82 ++ .../src/components/dialog-custom-provider.tsx | 268 ++----- .../app/src/components/dialog-select-file.tsx | 10 +- packages/app/src/components/prompt-input.tsx | 9 +- .../src/components/session-context-usage.tsx | 10 +- .../components/session/session-new-view.tsx | 1 - packages/app/src/context/highlights.tsx | 15 +- packages/app/src/context/layout.tsx | 55 +- packages/app/src/hooks/use-providers.ts | 34 +- packages/app/src/pages/session.tsx | 126 ++- .../composer/session-composer-region.tsx | 31 +- .../session/composer/session-todo-dock.tsx | 46 +- packages/app/src/pages/session/file-tabs.tsx | 12 +- .../app/src/pages/session/helpers.test.ts | 73 +- packages/app/src/pages/session/helpers.ts | 74 +- packages/app/src/pages/session/review-tab.tsx | 8 - .../src/pages/session/session-mobile-tabs.tsx | 41 - .../src/pages/session/session-side-panel.tsx | 45 +- .../app/src/pages/session/terminal-panel.tsx | 29 +- .../pages/session/use-session-commands.tsx | 756 +++++++++--------- 21 files changed, 1072 insertions(+), 812 deletions(-) create mode 100644 packages/app/src/components/dialog-custom-provider-form.ts create mode 100644 packages/app/src/components/dialog-custom-provider.test.ts delete mode 100644 packages/app/src/pages/session/session-mobile-tabs.tsx diff --git a/packages/app/src/components/dialog-custom-provider-form.ts b/packages/app/src/components/dialog-custom-provider-form.ts new file mode 100644 index 00000000000..92d235c3bcc --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider-form.ts @@ -0,0 +1,159 @@ +const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ +const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" + +type Translator = (key: string, vars?: Record) => string + +export type ModelErr = { + id?: string + name?: string +} + +export type HeaderErr = { + key?: string + value?: string +} + +export type ModelRow = { + row: string + id: string + name: string + err: ModelErr +} + +export type HeaderRow = { + row: string + key: string + value: string + err: HeaderErr +} + +export type FormState = { + providerID: string + name: string + baseURL: string + apiKey: string + models: ModelRow[] + headers: HeaderRow[] + saving: boolean + err: { + providerID?: string + name?: string + baseURL?: string + } +} + +type ValidateArgs = { + form: FormState + t: Translator + disabledProviders: string[] + existingProviderIDs: Set +} + +export function validateCustomProvider(input: ValidateArgs) { + const providerID = input.form.providerID.trim() + const name = input.form.name.trim() + const baseURL = input.form.baseURL.trim() + const apiKey = input.form.apiKey.trim() + + const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() + const key = apiKey && !env ? apiKey : undefined + + const idError = !providerID + ? input.t("provider.custom.error.providerID.required") + : !PROVIDER_ID.test(providerID) + ? input.t("provider.custom.error.providerID.format") + : undefined + + const nameError = !name ? input.t("provider.custom.error.name.required") : undefined + const urlError = !baseURL + ? input.t("provider.custom.error.baseURL.required") + : !/^https?:\/\//.test(baseURL) + ? input.t("provider.custom.error.baseURL.format") + : undefined + + const disabled = input.disabledProviders.includes(providerID) + const existsError = idError + ? undefined + : input.existingProviderIDs.has(providerID) && !disabled + ? input.t("provider.custom.error.providerID.exists") + : undefined + + const seenModels = new Set() + const models = input.form.models.map((m) => { + const id = m.id.trim() + const idError = !id + ? input.t("provider.custom.error.required") + : seenModels.has(id) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenModels.add(id) + return undefined + })() + const nameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined + return { id: idError, name: nameError } + }) + const modelsValid = models.every((m) => !m.id && !m.name) + const modelConfig = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) + + const seenHeaders = new Set() + const headers = input.form.headers.map((h) => { + const key = h.key.trim() + const value = h.value.trim() + + if (!key && !value) return {} + const keyError = !key + ? input.t("provider.custom.error.required") + : seenHeaders.has(key.toLowerCase()) + ? input.t("provider.custom.error.duplicate") + : (() => { + seenHeaders.add(key.toLowerCase()) + return undefined + })() + const valueError = !value ? input.t("provider.custom.error.required") : undefined + return { key: keyError, value: valueError } + }) + const headersValid = headers.every((h) => !h.key && !h.value) + const headerConfig = Object.fromEntries( + input.form.headers + .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) + .filter((h) => !!h.key && !!h.value) + .map((h) => [h.key, h.value]), + ) + + const err = { + providerID: idError ?? existsError, + name: nameError, + baseURL: urlError, + } + + const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid + if (!ok) return { err, models, headers } + + return { + err, + models, + headers, + result: { + providerID, + name, + key, + config: { + npm: OPENAI_COMPATIBLE, + name, + ...(env ? { env: [env] } : {}), + options: { + baseURL, + ...(Object.keys(headerConfig).length ? { headers: headerConfig } : {}), + }, + models: modelConfig, + }, + }, + } +} + +let row = 0 + +const nextRow = () => `row-${row++}` + +export const modelRow = (): ModelRow => ({ row: nextRow(), id: "", name: "", err: {} }) +export const headerRow = (): HeaderRow => ({ row: nextRow(), key: "", value: "", err: {} }) diff --git a/packages/app/src/components/dialog-custom-provider.test.ts b/packages/app/src/components/dialog-custom-provider.test.ts new file mode 100644 index 00000000000..8cfd78ebeb3 --- /dev/null +++ b/packages/app/src/components/dialog-custom-provider.test.ts @@ -0,0 +1,82 @@ +import { describe, expect, test } from "bun:test" +import { validateCustomProvider } from "./dialog-custom-provider-form" + +const t = (key: string) => key + +describe("validateCustomProvider", () => { + test("builds trimmed config payload", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: " Custom Provider ", + baseURL: "https://api.example.com ", + apiKey: " {env: CUSTOM_PROVIDER_KEY} ", + models: [{ row: "m0", id: " model-a ", name: " Model A ", err: {} }], + headers: [ + { row: "h0", key: " X-Test ", value: " enabled ", err: {} }, + { row: "h1", key: "", value: "", err: {} }, + ], + saving: false, + err: {}, + }, + t, + disabledProviders: [], + existingProviderIDs: new Set(), + }) + + expect(result.result).toEqual({ + providerID: "custom-provider", + name: "Custom Provider", + key: undefined, + config: { + npm: "@ai-sdk/openai-compatible", + name: "Custom Provider", + env: ["CUSTOM_PROVIDER_KEY"], + options: { + baseURL: "https://api.example.com", + headers: { + "X-Test": "enabled", + }, + }, + models: { + "model-a": { name: "Model A" }, + }, + }, + }) + }) + + test("flags duplicate rows and allows reconnecting disabled providers", () => { + const result = validateCustomProvider({ + form: { + providerID: "custom-provider", + name: "Provider", + baseURL: "https://api.example.com", + apiKey: "secret", + models: [ + { row: "m0", id: "model-a", name: "Model A", err: {} }, + { row: "m1", id: "model-a", name: "Model A 2", err: {} }, + ], + headers: [ + { row: "h0", key: "Authorization", value: "one", err: {} }, + { row: "h1", key: "authorization", value: "two", err: {} }, + ], + saving: false, + err: {}, + }, + t, + disabledProviders: ["custom-provider"], + existingProviderIDs: new Set(["custom-provider"]), + }) + + expect(result.result).toBeUndefined() + expect(result.err.providerID).toBeUndefined() + expect(result.models[1]).toEqual({ + id: "provider.custom.error.duplicate", + name: undefined, + }) + expect(result.headers[1]).toEqual({ + key: "provider.custom.error.duplicate", + value: undefined, + }) + }) +}) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 017b85a2c99..4d220a0b191 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -5,158 +5,15 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { For } from "solid-js" -import { createStore } from "solid-js/store" +import { batch, For } from "solid-js" +import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" +import { type FormState, headerRow, modelRow, validateCustomProvider } from "./dialog-custom-provider-form" import { DialogSelectProvider } from "./dialog-select-provider" -const PROVIDER_ID = /^[a-z0-9][a-z0-9-_]*$/ -const OPENAI_COMPATIBLE = "@ai-sdk/openai-compatible" - -type Translator = ReturnType["t"] - -type ModelRow = { - id: string - name: string -} - -type HeaderRow = { - key: string - value: string -} - -type FormState = { - providerID: string - name: string - baseURL: string - apiKey: string - models: ModelRow[] - headers: HeaderRow[] - saving: boolean -} - -type FormErrors = { - providerID: string | undefined - name: string | undefined - baseURL: string | undefined - models: Array<{ id?: string; name?: string }> - headers: Array<{ key?: string; value?: string }> -} - -type ValidateArgs = { - form: FormState - t: Translator - disabledProviders: string[] - existingProviderIDs: Set -} - -function validateCustomProvider(input: ValidateArgs) { - const providerID = input.form.providerID.trim() - const name = input.form.name.trim() - const baseURL = input.form.baseURL.trim() - const apiKey = input.form.apiKey.trim() - - const env = apiKey.match(/^\{env:([^}]+)\}$/)?.[1]?.trim() - const key = apiKey && !env ? apiKey : undefined - - const idError = !providerID - ? input.t("provider.custom.error.providerID.required") - : !PROVIDER_ID.test(providerID) - ? input.t("provider.custom.error.providerID.format") - : undefined - - const nameError = !name ? input.t("provider.custom.error.name.required") : undefined - const urlError = !baseURL - ? input.t("provider.custom.error.baseURL.required") - : !/^https?:\/\//.test(baseURL) - ? input.t("provider.custom.error.baseURL.format") - : undefined - - const disabled = input.disabledProviders.includes(providerID) - const existsError = idError - ? undefined - : input.existingProviderIDs.has(providerID) && !disabled - ? input.t("provider.custom.error.providerID.exists") - : undefined - - const seenModels = new Set() - const modelErrors = input.form.models.map((m) => { - const id = m.id.trim() - const modelIdError = !id - ? input.t("provider.custom.error.required") - : seenModels.has(id) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenModels.add(id) - return undefined - })() - const modelNameError = !m.name.trim() ? input.t("provider.custom.error.required") : undefined - return { id: modelIdError, name: modelNameError } - }) - const modelsValid = modelErrors.every((m) => !m.id && !m.name) - const models = Object.fromEntries(input.form.models.map((m) => [m.id.trim(), { name: m.name.trim() }])) - - const seenHeaders = new Set() - const headerErrors = input.form.headers.map((h) => { - const key = h.key.trim() - const value = h.value.trim() - - if (!key && !value) return {} - const keyError = !key - ? input.t("provider.custom.error.required") - : seenHeaders.has(key.toLowerCase()) - ? input.t("provider.custom.error.duplicate") - : (() => { - seenHeaders.add(key.toLowerCase()) - return undefined - })() - const valueError = !value ? input.t("provider.custom.error.required") : undefined - return { key: keyError, value: valueError } - }) - const headersValid = headerErrors.every((h) => !h.key && !h.value) - const headers = Object.fromEntries( - input.form.headers - .map((h) => ({ key: h.key.trim(), value: h.value.trim() })) - .filter((h) => !!h.key && !!h.value) - .map((h) => [h.key, h.value]), - ) - - const errors: FormErrors = { - providerID: idError ?? existsError, - name: nameError, - baseURL: urlError, - models: modelErrors, - headers: headerErrors, - } - - const ok = !idError && !existsError && !nameError && !urlError && modelsValid && headersValid - if (!ok) return { errors } - - const options = { - baseURL, - ...(Object.keys(headers).length ? { headers } : {}), - } - - return { - errors, - result: { - providerID, - name, - key, - config: { - npm: OPENAI_COMPATIBLE, - name, - ...(env ? { env: [env] } : {}), - options, - models, - }, - }, - } -} - type Props = { back?: "providers" | "close" } @@ -172,17 +29,10 @@ export function DialogCustomProvider(props: Props) { name: "", baseURL: "", apiKey: "", - models: [{ id: "", name: "" }], - headers: [{ key: "", value: "" }], + models: [modelRow()], + headers: [headerRow()], saving: false, - }) - - const [errors, setErrors] = createStore({ - providerID: undefined, - name: undefined, - baseURL: undefined, - models: [{}], - headers: [{}], + err: {}, }) const goBack = () => { @@ -194,25 +44,61 @@ export function DialogCustomProvider(props: Props) { } const addModel = () => { - setForm("models", (v) => [...v, { id: "", name: "" }]) - setErrors("models", (v) => [...v, {}]) + setForm( + "models", + produce((rows) => { + rows.push(modelRow()) + }), + ) } const removeModel = (index: number) => { if (form.models.length <= 1) return - setForm("models", (v) => v.filter((_, i) => i !== index)) - setErrors("models", (v) => v.filter((_, i) => i !== index)) + setForm( + "models", + produce((rows) => { + rows.splice(index, 1) + }), + ) } const addHeader = () => { - setForm("headers", (v) => [...v, { key: "", value: "" }]) - setErrors("headers", (v) => [...v, {}]) + setForm( + "headers", + produce((rows) => { + rows.push(headerRow()) + }), + ) } const removeHeader = (index: number) => { if (form.headers.length <= 1) return - setForm("headers", (v) => v.filter((_, i) => i !== index)) - setErrors("headers", (v) => v.filter((_, i) => i !== index)) + setForm( + "headers", + produce((rows) => { + rows.splice(index, 1) + }), + ) + } + + const setField = (key: "providerID" | "name" | "baseURL" | "apiKey", value: string) => { + setForm(key, value) + if (key === "apiKey") return + setForm("err", key, undefined) + } + + const setModel = (index: number, key: "id" | "name", value: string) => { + batch(() => { + setForm("models", index, key, value) + setForm("models", index, "err", key, undefined) + }) + } + + const setHeader = (index: number, key: "key" | "value", value: string) => { + batch(() => { + setForm("headers", index, key, value) + setForm("headers", index, "err", key, undefined) + }) } const validate = () => { @@ -222,7 +108,11 @@ export function DialogCustomProvider(props: Props) { disabledProviders: globalSync.data.config.disabled_providers ?? [], existingProviderIDs: new Set(globalSync.data.provider.all.map((p) => p.id)), }) - setErrors(output.errors) + batch(() => { + setForm("err", output.err) + output.models.forEach((err, index) => setForm("models", index, "err", err)) + output.headers.forEach((err, index) => setForm("headers", index, "err", err)) + }) return output.result } @@ -305,32 +195,32 @@ export function DialogCustomProvider(props: Props) { placeholder={language.t("provider.custom.field.providerID.placeholder")} description={language.t("provider.custom.field.providerID.description")} value={form.providerID} - onChange={(v) => setForm("providerID", v)} - validationState={errors.providerID ? "invalid" : undefined} - error={errors.providerID} + onChange={(v) => setField("providerID", v)} + validationState={form.err.providerID ? "invalid" : undefined} + error={form.err.providerID} /> setForm("name", v)} - validationState={errors.name ? "invalid" : undefined} - error={errors.name} + onChange={(v) => setField("name", v)} + validationState={form.err.name ? "invalid" : undefined} + error={form.err.name} /> setForm("baseURL", v)} - validationState={errors.baseURL ? "invalid" : undefined} - error={errors.baseURL} + onChange={(v) => setField("baseURL", v)} + validationState={form.err.baseURL ? "invalid" : undefined} + error={form.err.baseURL} /> setForm("apiKey", v)} + onChange={(v) => setField("apiKey", v)} /> @@ -338,16 +228,16 @@ export function DialogCustomProvider(props: Props) { {(m, i) => ( -
+
setForm("models", i(), "id", v)} - validationState={errors.models[i()]?.id ? "invalid" : undefined} - error={errors.models[i()]?.id} + onChange={(v) => setModel(i(), "id", v)} + validationState={m.err.id ? "invalid" : undefined} + error={m.err.id} />
@@ -356,9 +246,9 @@ export function DialogCustomProvider(props: Props) { hideLabel placeholder={language.t("provider.custom.models.name.placeholder")} value={m.name} - onChange={(v) => setForm("models", i(), "name", v)} - validationState={errors.models[i()]?.name ? "invalid" : undefined} - error={errors.models[i()]?.name} + onChange={(v) => setModel(i(), "name", v)} + validationState={m.err.name ? "invalid" : undefined} + error={m.err.name} />
{language.t("provider.custom.headers.label")} {(h, i) => ( -
+
setForm("headers", i(), "key", v)} - validationState={errors.headers[i()]?.key ? "invalid" : undefined} - error={errors.headers[i()]?.key} + onChange={(v) => setHeader(i(), "key", v)} + validationState={h.err.key ? "invalid" : undefined} + error={h.err.key} />
@@ -400,9 +290,9 @@ export function DialogCustomProvider(props: Props) { hideLabel placeholder={language.t("provider.custom.headers.value.placeholder")} value={h.value} - onChange={(v) => setForm("headers", i(), "value", v)} - validationState={errors.headers[i()]?.value ? "invalid" : undefined} - error={errors.headers[i()]?.value} + onChange={(v) => setHeader(i(), "value", v)} + validationState={h.err.value ? "invalid" : undefined} + error={h.err.value} />
ReturnType["tabs"]> language: ReturnType }) { + const tabState = createSessionTabs({ + tabs: props.tabs, + pathFromTab: props.file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? props.file.tab(tab) : tab), + }) const recent = createMemo(() => { - const all = props.tabs().all() - const active = props.tabs().active() + const all = tabState.openedTabs() + const active = tabState.activeFileTab() const order = active ? [active, ...all.filter((item) => item !== active)] : all const seen = new Set() const category = props.language.t("palette.group.files") diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f1a33e75f34..e129b499ae1 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -37,6 +37,7 @@ import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { @@ -154,6 +155,12 @@ export const PromptInput: Component = (props) => { requestAnimationFrame(scrollCursorIntoView) } + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: files.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? files.tab(tab) : tab), + }).activeFileTab + const commentInReview = (path: string) => { const sessionID = params.id if (!sessionID) return false @@ -205,7 +212,7 @@ export const PromptInput: Component = (props) => { const recent = createMemo(() => { const all = tabs().all() - const active = tabs().active() + const active = activeFileTab() const order = active ? [active, ...all.filter((x) => x !== active)] : all const seen = new Set() const paths: string[] = [] diff --git a/packages/app/src/components/session-context-usage.tsx b/packages/app/src/components/session-context-usage.tsx index 99e6c13a3dc..7379833f8b8 100644 --- a/packages/app/src/components/session-context-usage.tsx +++ b/packages/app/src/components/session-context-usage.tsx @@ -3,11 +3,13 @@ import { Tooltip, type TooltipProps } from "@opencode-ai/ui/tooltip" import { ProgressCircle } from "@opencode-ai/ui/progress-circle" import { Button } from "@opencode-ai/ui/button" +import { useFile } from "@/context/file" import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { useLanguage } from "@/context/language" import { getSessionContextMetrics } from "@/components/session/session-context-metrics" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" interface SessionContextUsageProps { variant?: "button" | "indicator" @@ -27,11 +29,17 @@ function openSessionContext(args: { export function SessionContextUsage(props: SessionContextUsageProps) { const sync = useSync() + const file = useFile() const layout = useLayout() const language = useLanguage() const { params, tabs, view } = useSessionLayout() const variant = createMemo(() => props.variant ?? "button") + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const usd = createMemo( @@ -51,7 +59,7 @@ export function SessionContextUsage(props: SessionContextUsageProps) { const openContext = () => { if (!params.id) return - if (tabs().active() === "context") { + if (tabState.activeTab() === "context") { tabs().close("context") return } diff --git a/packages/app/src/components/session/session-new-view.tsx b/packages/app/src/components/session/session-new-view.tsx index 52251dbb207..e4ef3639362 100644 --- a/packages/app/src/components/session/session-new-view.tsx +++ b/packages/app/src/components/session/session-new-view.tsx @@ -13,7 +13,6 @@ const ROOT_CLASS = "size-full flex flex-col" interface NewSessionViewProps { worktree: string - onWorktreeChange: (value: string) => void } export function NewSessionView(props: NewSessionViewProps) { diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index 476209e4173..058f7cc4b6c 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -1,4 +1,4 @@ -import { createEffect, createSignal, onCleanup } from "solid-js" +import { createEffect, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -146,8 +146,10 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple const settings = useSettings() const [store, setStore, _, ready] = persisted("highlights.v1", createStore({ version: undefined })) - const [from, setFrom] = createSignal(undefined) - const [to, setTo] = createSignal(undefined) + const [range, setRange] = createStore({ + from: undefined as string | undefined, + to: undefined as string | undefined, + }) const state = { started: false } let timer: ReturnType | undefined @@ -214,15 +216,14 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple if (previous === platform.version) return - setFrom(previous) - setTo(platform.version) + setRange({ from: previous, to: platform.version }) start(previous) }) return { ready, - from, - to, + from: () => range.from, + to: () => range.to, get last() { return store.version }, diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 5199e5a26be..78928118d72 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -793,20 +793,67 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( }, }, review: { - open: createMemo(() => s().reviewOpen), + open: createMemo(() => s().reviewOpen ?? []), setOpen(open: string[]) { const session = key() + const next = Array.from(new Set(open)) const current = store.sessionView[session] if (!current) { setStore("sessionView", session, { scroll: {}, - reviewOpen: open, + reviewOpen: next, }) return } - if (same(current.reviewOpen, open)) return - setStore("sessionView", session, "reviewOpen", open) + if (same(current.reviewOpen, next)) return + setStore("sessionView", session, "reviewOpen", next) + }, + openPath(path: string) { + const session = key() + const current = store.sessionView[session] + if (!current) { + setStore("sessionView", session, { + scroll: {}, + reviewOpen: [path], + }) + return + } + + if (!current.reviewOpen) { + setStore("sessionView", session, "reviewOpen", [path]) + return + } + + if (current.reviewOpen.includes(path)) return + setStore("sessionView", session, "reviewOpen", current.reviewOpen.length, path) + }, + closePath(path: string) { + const session = key() + const current = store.sessionView[session]?.reviewOpen + if (!current) return + + const index = current.indexOf(path) + if (index === -1) return + setStore( + "sessionView", + session, + "reviewOpen", + produce((draft) => { + if (!draft) return + draft.splice(index, 1) + }), + ) + }, + togglePath(path: string) { + const session = key() + const current = store.sessionView[session]?.reviewOpen + if (!current || !current.includes(path)) { + this.openPath(path) + return + } + + this.closePath(path) }, }, } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 9ef5272ef54..a25f8b4b252 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -18,25 +18,27 @@ const popularProviderSet = new Set(popularProviders) export function useProviders() { const globalSync = useGlobalSync() const params = useParams() - const currentDirectory = createMemo(() => decode64(params.dir) ?? "") - const providers = createMemo(() => { - if (currentDirectory()) { - const [projectStore] = globalSync.child(currentDirectory()) + const dir = createMemo(() => decode64(params.dir) ?? "") + const providers = () => { + if (dir()) { + const [projectStore] = globalSync.child(dir()) return projectStore.provider } return globalSync.data.provider - }) - const connectedIDs = createMemo(() => new Set(providers().connected)) - const connected = createMemo(() => providers().all.filter((p) => connectedIDs().has(p.id))) - const paid = createMemo(() => - connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)), - ) - const popular = createMemo(() => providers().all.filter((p) => popularProviderSet.has(p.id))) + } return { - all: createMemo(() => providers().all), - default: createMemo(() => providers().default), - popular, - connected, - paid, + all: () => providers().all, + default: () => providers().default, + popular: () => providers().all.filter((p) => popularProviderSet.has(p.id)), + connected: () => { + const connected = new Set(providers().connected) + return providers().all.filter((p) => connected.has(p.id)) + }, + paid: () => { + const connected = new Set(providers().connected) + return providers().all.filter( + (p) => connected.has(p.id) && (p.id !== "opencode" || Object.values(p.models).some((m) => m.cost?.input)), + ) + }, } } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2454acf4dd8..7642ac165e4 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -19,6 +19,7 @@ import { selectionFromLines, useFile, type FileSelection, type SelectedLineRange import { createStore } from "solid-js/store" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { Select } from "@opencode-ai/ui/select" +import { Tabs } from "@opencode-ai/ui/tabs" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { previewSelectedLines } from "@opencode-ai/ui/pierre/selection-bridge" import { Button } from "@opencode-ai/ui/button" @@ -36,12 +37,11 @@ import { useSDK } from "@/context/sdk" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { createSessionComposerState, SessionComposerRegion } from "@/pages/session/composer" -import { createOpenReviewFile, createSizing, focusTerminalById } from "@/pages/session/helpers" +import { createOpenReviewFile, createSessionTabs, createSizing, focusTerminalById } from "@/pages/session/helpers" import { MessageTimeline } from "@/pages/session/message-timeline" import { type DiffStyle, SessionReviewTab, type SessionReviewTabProps } from "@/pages/session/review-tab" import { useSessionLayout } from "@/pages/session/session-layout" import { resetSessionModel, syncSessionModel } from "@/pages/session/session-model-helpers" -import { SessionMobileTabs } from "@/pages/session/session-mobile-tabs" import { SessionSidePanel } from "@/pages/session/session-side-panel" import { TerminalPanel } from "@/pages/session/terminal-panel" import { useSessionCommands } from "@/pages/session/use-session-commands" @@ -373,18 +373,22 @@ export default function Page() { if (!view().reviewPanel.opened()) view().reviewPanel.open() } - createEffect(() => { - const active = tabs().active() - if (!active) return - - const path = file.pathFromTab(active) - if (path) file.load(path) - }) - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) const reviewCount = createMemo(() => Math.max(info()?.summary?.files ?? 0, diffs().length)) const hasReview = createMemo(() => reviewCount() > 0) + const reviewTab = createMemo(() => isDesktop()) + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: reviewTab, + hasReview, + }) + const contextOpen = tabState.contextOpen + const openedTabs = tabState.openedTabs + const activeTab = tabState.activeTab + const activeFileTab = tabState.activeFileTab const revertMessageID = createMemo(() => info()?.revert?.messageID) const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) const messagesReady = createMemo(() => { @@ -421,6 +425,14 @@ export default function Page() { ) const lastUserMessage = createMemo(() => visibleUserMessages().at(-1)) + createEffect(() => { + const tab = activeFileTab() + if (!tab) return + + const path = file.pathFromTab(tab) + if (path) file.load(path) + }) + createEffect( on( () => lastUserMessage()?.id, @@ -806,15 +818,7 @@ export default function Page() { } } - const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) - const openedTabs = createMemo(() => - tabs() - .all() - .filter((tab) => tab !== "context" && tab !== "review"), - ) - const mobileChanges = createMemo(() => !isDesktop() && store.mobileTab === "changes") - const reviewTab = createMemo(() => isDesktop()) const fileTreeTab = () => layout.fileTree.tab() const setFileTreeTab = (value: "changes" | "all") => layout.fileTree.setTab(value) @@ -850,6 +854,7 @@ export default function Page() { navigateMessageByOffset, setActiveMessage, focusInput, + review: reviewTab, }) const openReviewFile = createOpenReviewFile({ @@ -964,11 +969,10 @@ export default function Page() { createEffect( on( - () => tabs().active(), + activeFileTab, (active) => { if (!active) return if (fileTreeTab() !== "changes") return - if (!file.pathFromTab(active)) return showAllFiles() }, { defer: true }, @@ -1011,8 +1015,7 @@ export default function Page() { const focusReviewDiff = (path: string) => { openReviewPanel() - const current = view().review.open() ?? [] - if (!current.includes(path)) view().review.setOpen([...current, path]) + view().review.openPath(path) setTree({ activeDiff: path, pendingDiff: path }) } @@ -1057,29 +1060,6 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) - const activeTab = createMemo(() => { - const active = tabs().active() - if (active === "context") return "context" - if (active === "review" && reviewTab()) return "review" - if (active && file.pathFromTab(active)) return normalizeTab(active) - - const first = openedTabs()[0] - if (first) return first - if (contextOpen()) return "context" - if (reviewTab() && hasReview()) return "review" - return "empty" - }) - - createEffect(() => { - if (!layout.ready()) return - if (tabs().active()) return - if (openedTabs().length === 0 && !contextOpen() && !(reviewTab() && hasReview())) return - - const next = activeTab() - if (next === "empty") return - tabs().setActive(next) - }) - createEffect(() => { const id = params.id if (!id) return @@ -1146,9 +1126,9 @@ export default function Page() { () => { void file.tree.list("") - const active = tabs().active() - if (!active) return - const path = file.pathFromTab(active) + const tab = activeFileTab() + if (!tab) return + const path = file.pathFromTab(tab) if (!path) return void file.load(path, { force: true }) }, @@ -1400,14 +1380,30 @@ export default function Page() {
- setStore("mobileTab", "session")} - onChanges={() => setStore("mobileTab", "changes")} - /> + + + + setStore("mobileTab", "session")} + > + {language.t("session.tab.session")} + + setStore("mobileTab", "changes")} + > + {hasReview() + ? language.t("session.review.filesChanged", { count: reviewCount() }) + : language.t("session.review.change.other")} + + + + {/* Session panel */}
- { - if (value === "create") { - setStore("newSessionWorktree", value) - return - } - - setStore("newSessionWorktree", "main") - - const target = value === "main" ? sync.project?.worktree : value - if (!target) return - if (target === sdk.directory) return - layout.projects.open(target) - navigate(`/${base64Encode(target)}/session`) - }} - /> +
diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 964bf18dd4d..6d60d81b5a2 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -1,4 +1,5 @@ -import { Show, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { Show, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { useSpring } from "@opencode-ai/ui/motion-spring" import { PromptInput } from "@/components/prompt-input" import { useLanguage } from "@/context/language" @@ -50,7 +51,11 @@ export function SessionComposerRegion(props: { setSessionHandoff(sessionKey(), { prompt: previewPrompt() }) }) - const [ready, setReady] = createSignal(false) + const [store, setStore] = createStore({ + ready: false, + height: 320, + body: undefined as HTMLDivElement | undefined, + }) let timer: number | undefined let frame: number | undefined @@ -67,17 +72,17 @@ export function SessionComposerRegion(props: { createEffect(() => { sessionKey() - const active = props.ready + const ready = props.ready const delay = 140 clear() - setReady(false) - if (!active) return + setStore("ready", false) + if (!ready) return frame = requestAnimationFrame(() => { frame = undefined timer = window.setTimeout(() => { - setReady(true) + setStore("ready", true) timer = undefined }, delay) }) @@ -85,21 +90,19 @@ export function SessionComposerRegion(props: { onCleanup(clear) - const open = createMemo(() => ready() && props.state.dock() && !props.state.closing()) + const open = createMemo(() => store.ready && props.state.dock() && !props.state.closing()) const progress = useSpring(() => (open() ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const value = createMemo(() => Math.max(0, Math.min(1, progress()))) - const [height, setHeight] = createSignal(320) - const dock = createMemo(() => (ready() && props.state.dock()) || value() > 0.001) + const dock = createMemo(() => (store.ready && props.state.dock()) || value() > 0.001) const rolled = createMemo(() => (props.revert?.items.length ? props.revert : undefined)) const lift = createMemo(() => (rolled() ? 18 : 36 * value())) - const full = createMemo(() => Math.max(78, height())) - const [contentRef, setContentRef] = createSignal() + const full = createMemo(() => Math.max(78, store.height)) createEffect(() => { - const el = contentRef() + const el = store.body if (!el) return const update = () => { - setHeight(el.getBoundingClientRect().height) + setStore("height", el.getBoundingClientRect().height) } update() const observer = new ResizeObserver(update) @@ -174,7 +177,7 @@ export function SessionComposerRegion(props: { "max-height": `${full() * value()}px`, }} > -
+
setStore("body", el)}> setCollapsed((value) => !value) + const [store, setStore] = createStore({ + collapsed: false, + height: 320, + }) + + const toggle = () => setStore("collapsed", (value) => !value) const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) @@ -56,22 +61,21 @@ export function SessionTodoDock(props: { ) const preview = createMemo(() => active()?.content ?? "") - const collapse = useSpring(() => (collapsed() ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) + const collapse = useSpring(() => (store.collapsed ? 1 : 0), { visualDuration: 0.3, bounce: 0 }) const dock = createMemo(() => Math.max(0, Math.min(1, props.dockProgress))) const shut = createMemo(() => 1 - dock()) const value = createMemo(() => Math.max(0, Math.min(1, collapse()))) const hide = createMemo(() => Math.max(value(), shut())) const off = createMemo(() => hide() > 0.98) const turn = createMemo(() => Math.max(0, Math.min(1, value()))) - const [height, setHeight] = createSignal(320) - const full = createMemo(() => Math.max(78, height())) + const full = createMemo(() => Math.max(78, store.height)) let contentRef: HTMLDivElement | undefined createEffect(() => { const el = contentRef if (!el) return const update = () => { - setHeight(el.getBoundingClientRect().height) + setStore("height", el.getBoundingClientRect().height) } update() const observer = new ResizeObserver(update) @@ -127,7 +131,7 @@ export function SessionTodoDock(props: { >
0.1, }} @@ -169,7 +173,7 @@ export function SessionTodoDock(props: { opacity: `${Math.max(0, Math.min(1, 1 - hide()))}`, }} > - +
@@ -177,8 +181,10 @@ export function SessionTodoDock(props: { } function TodoList(props: { todos: Todo[]; open: boolean }) { - const [stuck, setStuck] = createSignal(false) - const [scrolling, setScrolling] = createSignal(false) + const [store, setStore] = createStore({ + stuck: false, + scrolling: false, + }) let scrollRef!: HTMLDivElement let timer: number | undefined @@ -186,7 +192,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { const ensure = () => { if (!props.open) return - if (scrolling()) return + if (store.scrolling) return if (!scrollRef || scrollRef.offsetParent === null) return const el = scrollRef.querySelector("[data-in-progress]") @@ -207,7 +213,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { scrollRef.scrollTop = bottom - (scrollRef.clientHeight - bottomFade) } - setStuck(scrollRef.scrollTop > 0) + setStore("stuck", scrollRef.scrollTop > 0) } createEffect( @@ -229,11 +235,11 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { ref={scrollRef} style={{ "overflow-anchor": "none" }} onScroll={(e) => { - setStuck(e.currentTarget.scrollTop > 0) - setScrolling(true) + setStore("stuck", e.currentTarget.scrollTop > 0) + setStore("scrolling", true) if (timer) window.clearTimeout(timer) timer = window.setTimeout(() => { - setScrolling(false) + setStore("scrolling", false) if (inProgress() < 0) return requestAnimationFrame(ensure) }, 250) @@ -278,7 +284,7 @@ function TodoList(props: { todos: Todo[]; open: boolean }) { class="pointer-events-none absolute top-0 left-0 right-0 h-4 transition-opacity duration-150" style={{ background: "linear-gradient(to bottom, var(--background-base), transparent)", - opacity: stuck() ? 1 : 0, + opacity: store.stuck ? 1 : 0, }} />
diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index 4b322368fca..a3379905d84 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -17,6 +17,7 @@ import { useLanguage } from "@/context/language" import { usePrompt } from "@/context/prompt" import { getSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" +import { createSessionTabs } from "@/pages/session/helpers" function FileCommentMenu(props: { moreLabel: string @@ -58,6 +59,11 @@ export function FileTabContent(props: { tab: string }) { const prompt = usePrompt() const fileComponent = useFileComponent() const { sessionKey, tabs, view } = useSessionLayout() + const activeFileTab = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab: (tab) => (tab.startsWith("file://") ? file.tab(tab) : tab), + }).activeFileTab let scroll: HTMLDivElement | undefined let scrollFrame: number | undefined @@ -228,7 +234,7 @@ export function FileTabContent(props: { tab: string }) { if (typeof window === "undefined") return const onKeyDown = (event: KeyboardEvent) => { - if (tabs().active() !== props.tab) return + if (activeFileTab() !== props.tab) return if (!(event.metaKey || event.ctrlKey) || event.altKey || event.shiftKey) return if (event.key.toLowerCase() !== "f") return @@ -256,7 +262,7 @@ export function FileTabContent(props: { tab: string }) { const p = path() if (!focus || !p) return if (focus.file !== p) return - if (tabs().active() !== props.tab) return + if (activeFileTab() !== props.tab) return const target = fileComments().find((comment) => comment.id === focus.id) if (!target) return @@ -376,7 +382,7 @@ export function FileTabContent(props: { tab: string }) { createEffect(() => { const loaded = !!state()?.loaded const ready = file.ready() - const active = tabs().active() === props.tab + const active = activeFileTab() === props.tab const restore = (loaded && !prev.loaded) || (ready && !prev.ready) || (active && loaded && !prev.active) prev = { loaded, ready, active } if (!restore) return diff --git a/packages/app/src/pages/session/helpers.test.ts b/packages/app/src/pages/session/helpers.test.ts index 9c77c34af4e..047946fc1ef 100644 --- a/packages/app/src/pages/session/helpers.test.ts +++ b/packages/app/src/pages/session/helpers.test.ts @@ -1,5 +1,13 @@ import { describe, expect, test } from "bun:test" -import { createOpenReviewFile, createOpenSessionFileTab, focusTerminalById, getTabReorderIndex } from "./helpers" +import { createMemo, createRoot } from "solid-js" +import { createStore } from "solid-js/store" +import { + createOpenReviewFile, + createOpenSessionFileTab, + createSessionTabs, + focusTerminalById, + getTabReorderIndex, +} from "./helpers" describe("createOpenReviewFile", () => { test("opens and loads selected review file", () => { @@ -87,3 +95,66 @@ describe("getTabReorderIndex", () => { expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() }) }) + +describe("createSessionTabs", () => { + test("normalizes the effective file tab", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["file://src/a.ts", "context"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: (tab) => (tab.startsWith("file://") ? tab.slice("file://".length) : undefined), + normalizeTab: (tab) => (tab.startsWith("file://") ? `norm:${tab.slice("file://".length)}` : tab), + }) + + expect(result.activeTab()).toBe("norm:src/a.ts") + expect(result.activeFileTab()).toBe("norm:src/a.ts") + expect(result.closableTab()).toBe("norm:src/a.ts") + dispose() + }) + }) + + test("prefers context and review fallbacks when no file tab is active", () => { + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: ["context"], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("context") + expect(result.closableTab()).toBe("context") + dispose() + }) + + createRoot((dispose) => { + const [state] = createStore({ + active: undefined as string | undefined, + all: [], + }) + const tabs = createMemo(() => ({ active: () => state.active, all: () => state.all })) + const result = createSessionTabs({ + tabs, + pathFromTab: () => undefined, + normalizeTab: (tab) => tab, + review: () => true, + hasReview: () => true, + }) + + expect(result.activeTab()).toBe("review") + expect(result.activeFileTab()).toBeUndefined() + expect(result.closableTab()).toBeUndefined() + dispose() + }) + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index 2da5ce6b82d..c3571f3ffce 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -1,5 +1,77 @@ -import { batch, onCleanup, onMount } from "solid-js" +import { batch, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" +import { same } from "@/utils/same" + +const emptyTabs: string[] = [] + +type Tabs = { + active: Accessor + all: Accessor +} + +type TabsInput = { + tabs: Accessor + pathFromTab: (tab: string) => string | undefined + normalizeTab: (tab: string) => string + review?: Accessor + hasReview?: Accessor +} + +export const getSessionKey = (dir: string | undefined, id: string | undefined) => `${dir ?? ""}${id ? `/${id}` : ""}` + +export const createSessionTabs = (input: TabsInput) => { + const review = input.review ?? (() => false) + const hasReview = input.hasReview ?? (() => false) + const contextOpen = createMemo(() => input.tabs().active() === "context" || input.tabs().all().includes("context")) + const openedTabs = createMemo( + () => { + const seen = new Set() + return input + .tabs() + .all() + .flatMap((tab) => { + if (tab === "context" || tab === "review") return [] + const value = input.pathFromTab(tab) ? input.normalizeTab(tab) : tab + if (seen.has(value)) return [] + seen.add(value) + return [value] + }) + }, + emptyTabs, + { equals: same }, + ) + const activeTab = createMemo(() => { + const active = input.tabs().active() + if (active === "context") return active + if (active === "review" && review()) return active + if (active && input.pathFromTab(active)) return input.normalizeTab(active) + + const first = openedTabs()[0] + if (first) return first + if (contextOpen()) return "context" + if (review() && hasReview()) return "review" + return "empty" + }) + const activeFileTab = createMemo(() => { + const active = activeTab() + if (!openedTabs().includes(active)) return + return active + }) + const closableTab = createMemo(() => { + const active = activeTab() + if (active === "context") return active + if (!openedTabs().includes(active)) return + return active + }) + + return { + contextOpen, + openedTabs, + activeTab, + activeFileTab, + closableTab, + } +} export const focusTerminalById = (id: string) => { const wrapper = document.getElementById(`terminal-wrapper-${id}`) diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 142ee7ad929..c073e621472 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -37,14 +37,6 @@ export interface SessionReviewTabProps { } } -export function StickyAddButton(props: { children: JSX.Element }) { - return ( -
- {props.children} -
- ) -} - export function SessionReviewTab(props: SessionReviewTabProps) { let scroll: HTMLDivElement | undefined let restoreFrame: number | undefined diff --git a/packages/app/src/pages/session/session-mobile-tabs.tsx b/packages/app/src/pages/session/session-mobile-tabs.tsx deleted file mode 100644 index f97199b4947..00000000000 --- a/packages/app/src/pages/session/session-mobile-tabs.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { Show } from "solid-js" -import { Tabs } from "@opencode-ai/ui/tabs" -import { useLanguage } from "@/context/language" - -export function SessionMobileTabs(props: { - open: boolean - mobileTab: "session" | "changes" - hasReview: boolean - reviewCount: number - onSession: () => void - onChanges: () => void -}) { - const language = useLanguage() - - return ( - - - - - {language.t("session.tab.session")} - - - {props.hasReview - ? language.t("session.review.filesChanged", { count: props.reviewCount }) - : language.t("session.review.change.other")} - - - - - ) -} diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 2c499d9f419..3b8b0c96bfe 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -22,8 +22,7 @@ import { useLayout } from "@/context/layout" import { useSync } from "@/context/sync" import { createFileTabListSync } from "@/pages/session/file-tab-scroll" import { FileTabContent } from "@/pages/session/file-tabs" -import { createOpenSessionFileTab, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" -import { StickyAddButton } from "@/pages/session/review-tab" +import { createOpenSessionFileTab, createSessionTabs, getTabReorderIndex, type Sizing } from "@/pages/session/helpers" import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" @@ -132,31 +131,17 @@ export function SessionSidePanel(props: { setActive: tabs().setActive, }) - const contextOpen = createMemo(() => tabs().active() === "context" || tabs().all().includes("context")) - const openedTabs = createMemo(() => - tabs() - .all() - .filter((tab) => tab !== "context" && tab !== "review"), - ) - - const activeTab = createMemo(() => { - const active = tabs().active() - if (active === "context") return "context" - if (active === "review" && reviewTab()) return "review" - if (active && file.pathFromTab(active)) return normalizeTab(active) - - const first = openedTabs()[0] - if (first) return first - if (contextOpen()) return "context" - if (reviewTab() && hasReview()) return "review" - return "empty" - }) - - const activeFileTab = createMemo(() => { - const active = activeTab() - if (!openedTabs().includes(active)) return - return active + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: reviewTab, + hasReview, }) + const contextOpen = tabState.contextOpen + const openedTabs = tabState.openedTabs + const activeTab = tabState.activeTab + const activeFileTab = tabState.activeFileTab const fileTreeTab = () => layout.fileTree.tab() @@ -297,7 +282,7 @@ export function SessionSidePanel(props: { {(tab) => } - +
- +
@@ -354,10 +339,10 @@ export function SessionSidePanel(props: { {(tab) => { - const path = createMemo(() => file.pathFromTab(tab)) + const path = file.pathFromTab(tab) return (
- {(p) => } + {(p) => }
) }} diff --git a/packages/app/src/pages/session/terminal-panel.tsx b/packages/app/src/pages/session/terminal-panel.tsx index c4951865684..e78ebecfc41 100644 --- a/packages/app/src/pages/session/terminal-panel.tsx +++ b/packages/app/src/pages/session/terminal-panel.tsx @@ -1,4 +1,4 @@ -import { For, Show, createEffect, createMemo, on, onCleanup } from "solid-js" +import { For, Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { Tabs } from "@opencode-ai/ui/tabs" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" @@ -13,7 +13,7 @@ import { Terminal } from "@/components/terminal" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useTerminal, type LocalPTY } from "@/context/terminal" +import { useTerminal } from "@/context/terminal" import { terminalTabLabel } from "@/pages/session/terminal-label" import { createSizing, focusTerminalById } from "@/pages/session/helpers" import { getTerminalHandoff, setTerminalHandoff } from "@/pages/session/handoff" @@ -41,7 +41,7 @@ export function TerminalPanel() { const max = () => store.view * 0.6 const pane = () => Math.min(height(), max()) - createEffect(() => { + onMount(() => { if (typeof window === "undefined") return const sync = () => setStore("view", window.visualViewport?.height ?? window.innerHeight) @@ -144,9 +144,8 @@ export function TerminalPanel() { return getTerminalHandoff(dir) ?? [] }) - const all = createMemo(() => terminal.all()) + const all = terminal.all const ids = createMemo(() => all().map((pty) => pty.id)) - const byId = createMemo(() => new Map(all().map((pty) => [pty.id, { ...pty }]))) const handleTerminalDragStart = (event: unknown) => { const id = getDraggableId(event) @@ -159,8 +158,8 @@ export function TerminalPanel() { if (!draggable || !droppable) return const terminals = terminal.all() - const fromIndex = terminals.findIndex((t: LocalPTY) => t.id === draggable.id.toString()) - const toIndex = terminals.findIndex((t: LocalPTY) => t.id === droppable.id.toString()) + const fromIndex = terminals.findIndex((t) => t.id === draggable.id.toString()) + const toIndex = terminals.findIndex((t) => t.id === droppable.id.toString()) if (fromIndex !== -1 && toIndex !== -1 && fromIndex !== toIndex) { terminal.move(draggable.id.toString(), toIndex) } @@ -253,13 +252,7 @@ export function TerminalPanel() { > - - {(id) => ( - - {(pty) => } - - )} - + {(pty) => }
{(id) => ( - + pty.id === id)}> {(pty) => (
- - {(draggedId) => ( - + + {(id) => ( + pty.id === id)}> {(t) => (
{terminalTabLabel({ diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 6799504ca6a..f5a4c05764b 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -1,4 +1,3 @@ -import { createMemo } from "solid-js" import { useNavigate } from "@solidjs/router" import { useCommand, type CommandOption } from "@/context/command" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -18,6 +17,7 @@ import { DialogSelectMcp } from "@/components/dialog-select-mcp" import { DialogFork } from "@/components/dialog-fork" import { showToast } from "@opencode-ai/ui/toast" import { findLast } from "@opencode-ai/util/array" +import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" @@ -26,6 +26,7 @@ export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void setActiveMessage: (message: UserMessage | undefined) => void focusInput: () => void + review?: () => boolean } const withCategory = (category: string) => { @@ -50,17 +51,43 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const navigate = useNavigate() const { params, tabs, view } = useSessionLayout() - const info = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) + const info = () => { + const id = params.id + if (!id) return + return sync.session.get(id) + } + const hasReview = () => { + const id = params.id + if (!id) return false + return Math.max(info()?.summary?.files ?? 0, (sync.data.session_diff[id] ?? []).length) > 0 + } + const normalizeTab = (tab: string) => { + if (!tab.startsWith("file://")) return tab + return file.tab(tab) + } + const tabState = createSessionTabs({ + tabs, + pathFromTab: file.pathFromTab, + normalizeTab, + review: actions.review, + hasReview, + }) + const activeFileTab = tabState.activeFileTab + const closableTab = tabState.closableTab const idle = { type: "idle" as const } - const status = createMemo(() => sync.data.session_status[params.id ?? ""] ?? idle) - const messages = createMemo(() => (params.id ? (sync.data.message[params.id] ?? []) : [])) - const userMessages = createMemo(() => messages().filter((m) => m.role === "user") as UserMessage[]) - const visibleUserMessages = createMemo(() => { + const status = () => sync.data.session_status[params.id ?? ""] ?? idle + const messages = () => { + const id = params.id + if (!id) return [] + return sync.data.message[id] ?? [] + } + const userMessages = () => messages().filter((m) => m.role === "user") as UserMessage[] + const visibleUserMessages = () => { const revert = info()?.revert?.messageID if (!revert) return userMessages() return userMessages().filter((m) => m.id < revert) - }) + } const showAllFiles = () => { if (layout.fileTree.tab() !== "changes") return @@ -79,9 +106,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { } const canAddSelectionContext = () => { - const active = tabs().active() - if (!active) return false - const path = file.pathFromTab(active) + const tab = activeFileTab() + if (!tab) return false + const path = file.pathFromTab(tab) if (!path) return false return file.selectedLines(path) != null } @@ -100,404 +127,369 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const agentCommand = withCategory(language.t("command.category.agent")) const permissionsCommand = withCategory(language.t("command.category.permissions")) - const sessionCommands = createMemo(() => [ - sessionCommand({ - id: "session.new", - title: language.t("command.session.new"), - keybind: "mod+shift+s", - slash: "new", - onSelect: () => navigate(`/${params.dir}/session`), - }), - ]) - - const fileCommands = createMemo(() => [ - fileCommand({ - id: "file.open", - title: language.t("command.file.open"), - description: language.t("palette.search.placeholder"), - keybind: "mod+p", - slash: "open", - onSelect: () => dialog.show(() => ), - }), - fileCommand({ - id: "tab.close", - title: language.t("command.tab.close"), - keybind: "mod+w", - disabled: !tabs().active(), - onSelect: () => { - const active = tabs().active() - if (!active) return - tabs().close(active) - }, - }), - ]) - - const contextCommands = createMemo(() => [ - contextCommand({ - id: "context.addSelection", - title: language.t("command.context.addSelection"), - description: language.t("command.context.addSelection.description"), - keybind: "mod+shift+l", - disabled: !canAddSelectionContext(), - onSelect: () => { - const active = tabs().active() - if (!active) return - const path = file.pathFromTab(active) - if (!path) return - - const range = file.selectedLines(path) as SelectedLineRange | null | undefined - if (!range) { - showToast({ - title: language.t("toast.context.noLineSelection.title"), - description: language.t("toast.context.noLineSelection.description"), - }) - return - } - - addSelectionToContext(path, selectionFromLines(range)) - }, - }), - ]) - - const viewCommands = createMemo(() => [ - viewCommand({ - id: "terminal.toggle", - title: language.t("command.terminal.toggle"), - keybind: "ctrl+`", - slash: "terminal", - onSelect: () => view().terminal.toggle(), - }), - viewCommand({ - id: "review.toggle", - title: language.t("command.review.toggle"), - keybind: "mod+shift+r", - onSelect: () => view().reviewPanel.toggle(), - }), - viewCommand({ - id: "fileTree.toggle", - title: language.t("command.fileTree.toggle"), - keybind: "mod+\\", - onSelect: () => layout.fileTree.toggle(), - }), - viewCommand({ - id: "input.focus", - title: language.t("command.input.focus"), - keybind: "ctrl+l", - onSelect: () => focusInput(), - }), - terminalCommand({ - id: "terminal.new", - title: language.t("command.terminal.new"), - description: language.t("command.terminal.new.description"), - keybind: "ctrl+alt+t", - onSelect: () => { - if (terminal.all().length > 0) terminal.new() - view().terminal.open() - }, - }), - ]) - - const messageCommands = createMemo(() => [ - sessionCommand({ - id: "message.previous", - title: language.t("command.message.previous"), - description: language.t("command.message.previous.description"), - keybind: "mod+arrowup", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(-1), - }), - sessionCommand({ - id: "message.next", - title: language.t("command.message.next"), - description: language.t("command.message.next.description"), - keybind: "mod+arrowdown", - disabled: !params.id, - onSelect: () => navigateMessageByOffset(1), - }), - ]) - - const agentCommands = createMemo(() => [ - modelCommand({ - id: "model.choose", - title: language.t("command.model.choose"), - description: language.t("command.model.choose.description"), - keybind: "mod+'", - slash: "model", - onSelect: () => dialog.show(() => ), - }), - mcpCommand({ - id: "mcp.toggle", - title: language.t("command.mcp.toggle"), - description: language.t("command.mcp.toggle.description"), - keybind: "mod+;", - slash: "mcp", - onSelect: () => dialog.show(() => ), - }), - agentCommand({ - id: "agent.cycle", - title: language.t("command.agent.cycle"), - description: language.t("command.agent.cycle.description"), - keybind: "mod+.", - slash: "agent", - onSelect: () => local.agent.move(1), - }), - agentCommand({ - id: "agent.cycle.reverse", - title: language.t("command.agent.cycle.reverse"), - description: language.t("command.agent.cycle.reverse.description"), - keybind: "shift+mod+.", - onSelect: () => local.agent.move(-1), - }), - modelCommand({ - id: "model.variant.cycle", - title: language.t("command.model.variant.cycle"), - description: language.t("command.model.variant.cycle.description"), - keybind: "shift+mod+d", - onSelect: () => { - local.model.variant.cycle() - }, - }), - ]) - const isAutoAcceptActive = () => { const sessionID = params.id if (sessionID) return permission.isAutoAccepting(sessionID, sdk.directory) return permission.isAutoAcceptingDirectory(sdk.directory) } + command.register("session", () => { + const share = + sync.data.config.share === "disabled" + ? [] + : [ + sessionCommand({ + id: "session.share", + title: info()?.share?.url + ? language.t("session.share.copy.copyLink") + : language.t("command.session.share"), + description: info()?.share?.url + ? language.t("toast.session.share.success.description") + : language.t("command.session.share.description"), + slash: "share", + disabled: !params.id, + onSelect: async () => { + if (!params.id) return - const permissionCommands = createMemo(() => [ - permissionsCommand({ - id: "permissions.autoaccept", - title: isAutoAcceptActive() - ? language.t("command.permissions.autoaccept.disable") - : language.t("command.permissions.autoaccept.enable"), - keybind: "mod+shift+a", - disabled: false, - onSelect: () => { - const sessionID = params.id - if (sessionID) { - permission.toggleAutoAccept(sessionID, sdk.directory) - } else { - permission.toggleAutoAcceptDirectory(sdk.directory) - } - const active = sessionID - ? permission.isAutoAccepting(sessionID, sdk.directory) - : permission.isAutoAcceptingDirectory(sdk.directory) - showToast({ - title: active - ? language.t("toast.permissions.autoaccept.on.title") - : language.t("toast.permissions.autoaccept.off.title"), - description: active - ? language.t("toast.permissions.autoaccept.on.description") - : language.t("toast.permissions.autoaccept.off.description"), - }) - }, - }), - ]) + const write = (value: string) => { + const body = typeof document === "undefined" ? undefined : document.body + if (body) { + const textarea = document.createElement("textarea") + textarea.value = value + textarea.setAttribute("readonly", "") + textarea.style.position = "fixed" + textarea.style.opacity = "0" + textarea.style.pointerEvents = "none" + body.appendChild(textarea) + textarea.select() + const copied = document.execCommand("copy") + body.removeChild(textarea) + if (copied) return Promise.resolve(true) + } - const sessionActionCommands = createMemo(() => [ - sessionCommand({ - id: "session.undo", - title: language.t("command.session.undo"), - description: language.t("command.session.undo.description"), - slash: "undo", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) - } - const revert = info()?.revert?.messageID - const message = findLast(userMessages(), (x) => !revert || x.id < revert) - if (!message) return - await sdk.client.session.revert({ sessionID, messageID: message.id }) - const parts = sync.data.part[message.id] - if (parts) { - const restored = extractPromptFromParts(parts, { directory: sdk.directory }) - prompt.set(restored) - } - const priorMessage = findLast(userMessages(), (x) => x.id < message.id) - setActiveMessage(priorMessage) - }, - }), - sessionCommand({ - id: "session.redo", - title: language.t("command.session.redo"), - description: language.t("command.session.redo.description"), - slash: "redo", - disabled: !params.id || !info()?.revert?.messageID, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const revertMessageID = info()?.revert?.messageID - if (!revertMessageID) return - const nextMessage = userMessages().find((x) => x.id > revertMessageID) - if (!nextMessage) { - await sdk.client.session.unrevert({ sessionID }) - prompt.reset() - const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) - setActiveMessage(lastMsg) - return - } - await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) - const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) - setActiveMessage(priorMsg) - }, - }), - sessionCommand({ - id: "session.compact", - title: language.t("command.session.compact"), - description: language.t("command.session.compact.description"), - slash: "compact", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: async () => { - const sessionID = params.id - if (!sessionID) return - const model = local.model.current() - if (!model) { - showToast({ - title: language.t("toast.model.none.title"), - description: language.t("toast.model.none.description"), - }) - return - } - await sdk.client.session.summarize({ - sessionID, - modelID: model.id, - providerID: model.provider.id, - }) - }, - }), - sessionCommand({ - id: "session.fork", - title: language.t("command.session.fork"), - description: language.t("command.session.fork.description"), - slash: "fork", - disabled: !params.id || visibleUserMessages().length === 0, - onSelect: () => dialog.show(() => ), - }), - ]) + const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard + if (!clipboard?.writeText) return Promise.resolve(false) + return clipboard.writeText(value).then( + () => true, + () => false, + ) + } - const shareCommands = createMemo(() => { - if (sync.data.config.share === "disabled") return [] - return [ - sessionCommand({ - id: "session.share", - title: info()?.share?.url ? language.t("session.share.copy.copyLink") : language.t("command.session.share"), - description: info()?.share?.url - ? language.t("toast.session.share.success.description") - : language.t("command.session.share.description"), - slash: "share", - disabled: !params.id, - onSelect: async () => { - if (!params.id) return + const copy = async (url: string, existing: boolean) => { + const ok = await write(url) + if (!ok) { + showToast({ + title: language.t("toast.session.share.copyFailed.title"), + variant: "error", + }) + return + } - const write = (value: string) => { - const body = typeof document === "undefined" ? undefined : document.body - if (body) { - const textarea = document.createElement("textarea") - textarea.value = value - textarea.setAttribute("readonly", "") - textarea.style.position = "fixed" - textarea.style.opacity = "0" - textarea.style.pointerEvents = "none" - body.appendChild(textarea) - textarea.select() - const copied = document.execCommand("copy") - body.removeChild(textarea) - if (copied) return Promise.resolve(true) - } + showToast({ + title: existing + ? language.t("session.share.copy.copied") + : language.t("toast.session.share.success.title"), + description: language.t("toast.session.share.success.description"), + variant: "success", + }) + } - const clipboard = typeof navigator === "undefined" ? undefined : navigator.clipboard - if (!clipboard?.writeText) return Promise.resolve(false) - return clipboard.writeText(value).then( - () => true, - () => false, - ) - } + const existing = info()?.share?.url + if (existing) { + await copy(existing, true) + return + } - const copy = async (url: string, existing: boolean) => { - const ok = await write(url) - if (!ok) { - showToast({ - title: language.t("toast.session.share.copyFailed.title"), - variant: "error", - }) - return - } + const url = await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => res.data?.share?.url) + .catch(() => undefined) + if (!url) { + showToast({ + title: language.t("toast.session.share.failed.title"), + description: language.t("toast.session.share.failed.description"), + variant: "error", + }) + return + } + await copy(url, false) + }, + }), + sessionCommand({ + id: "session.unshare", + title: language.t("command.session.unshare"), + description: language.t("command.session.unshare.description"), + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: language.t("toast.session.unshare.success.title"), + description: language.t("toast.session.unshare.success.description"), + variant: "success", + }), + ) + .catch(() => + showToast({ + title: language.t("toast.session.unshare.failed.title"), + description: language.t("toast.session.unshare.failed.description"), + variant: "error", + }), + ) + }, + }), + ] + + return [ + sessionCommand({ + id: "session.new", + title: language.t("command.session.new"), + keybind: "mod+shift+s", + slash: "new", + onSelect: () => navigate(`/${params.dir}/session`), + }), + fileCommand({ + id: "file.open", + title: language.t("command.file.open"), + description: language.t("palette.search.placeholder"), + keybind: "mod+p", + slash: "open", + onSelect: () => dialog.show(() => ), + }), + fileCommand({ + id: "tab.close", + title: language.t("command.tab.close"), + keybind: "mod+w", + disabled: !closableTab(), + onSelect: () => { + const tab = closableTab() + if (!tab) return + tabs().close(tab) + }, + }), + contextCommand({ + id: "context.addSelection", + title: language.t("command.context.addSelection"), + description: language.t("command.context.addSelection.description"), + keybind: "mod+shift+l", + disabled: !canAddSelectionContext(), + onSelect: () => { + const tab = activeFileTab() + if (!tab) return + const path = file.pathFromTab(tab) + if (!path) return + + const range = file.selectedLines(path) as SelectedLineRange | null | undefined + if (!range) { showToast({ - title: existing - ? language.t("session.share.copy.copied") - : language.t("toast.session.share.success.title"), - description: language.t("toast.session.share.success.description"), - variant: "success", + title: language.t("toast.context.noLineSelection.title"), + description: language.t("toast.context.noLineSelection.description"), }) + return } - const existing = info()?.share?.url - if (existing) { - await copy(existing, true) + addSelectionToContext(path, selectionFromLines(range)) + }, + }), + viewCommand({ + id: "terminal.toggle", + title: language.t("command.terminal.toggle"), + keybind: "ctrl+`", + slash: "terminal", + onSelect: () => view().terminal.toggle(), + }), + viewCommand({ + id: "review.toggle", + title: language.t("command.review.toggle"), + keybind: "mod+shift+r", + onSelect: () => view().reviewPanel.toggle(), + }), + viewCommand({ + id: "fileTree.toggle", + title: language.t("command.fileTree.toggle"), + keybind: "mod+\\", + onSelect: () => layout.fileTree.toggle(), + }), + viewCommand({ + id: "input.focus", + title: language.t("command.input.focus"), + keybind: "ctrl+l", + onSelect: focusInput, + }), + terminalCommand({ + id: "terminal.new", + title: language.t("command.terminal.new"), + description: language.t("command.terminal.new.description"), + keybind: "ctrl+alt+t", + onSelect: () => { + if (terminal.all().length > 0) terminal.new() + view().terminal.open() + }, + }), + sessionCommand({ + id: "message.previous", + title: language.t("command.message.previous"), + description: language.t("command.message.previous.description"), + keybind: "mod+arrowup", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(-1), + }), + sessionCommand({ + id: "message.next", + title: language.t("command.message.next"), + description: language.t("command.message.next.description"), + keybind: "mod+arrowdown", + disabled: !params.id, + onSelect: () => navigateMessageByOffset(1), + }), + modelCommand({ + id: "model.choose", + title: language.t("command.model.choose"), + description: language.t("command.model.choose.description"), + keybind: "mod+'", + slash: "model", + onSelect: () => dialog.show(() => ), + }), + mcpCommand({ + id: "mcp.toggle", + title: language.t("command.mcp.toggle"), + description: language.t("command.mcp.toggle.description"), + keybind: "mod+;", + slash: "mcp", + onSelect: () => dialog.show(() => ), + }), + agentCommand({ + id: "agent.cycle", + title: language.t("command.agent.cycle"), + description: language.t("command.agent.cycle.description"), + keybind: "mod+.", + slash: "agent", + onSelect: () => local.agent.move(1), + }), + agentCommand({ + id: "agent.cycle.reverse", + title: language.t("command.agent.cycle.reverse"), + description: language.t("command.agent.cycle.reverse.description"), + keybind: "shift+mod+.", + onSelect: () => local.agent.move(-1), + }), + modelCommand({ + id: "model.variant.cycle", + title: language.t("command.model.variant.cycle"), + description: language.t("command.model.variant.cycle.description"), + keybind: "shift+mod+d", + onSelect: () => local.model.variant.cycle(), + }), + permissionsCommand({ + id: "permissions.autoaccept", + title: isAutoAcceptActive() + ? language.t("command.permissions.autoaccept.disable") + : language.t("command.permissions.autoaccept.enable"), + keybind: "mod+shift+a", + disabled: false, + onSelect: () => { + const sessionID = params.id + if (sessionID) permission.toggleAutoAccept(sessionID, sdk.directory) + else permission.toggleAutoAcceptDirectory(sdk.directory) + + const active = sessionID + ? permission.isAutoAccepting(sessionID, sdk.directory) + : permission.isAutoAcceptingDirectory(sdk.directory) + showToast({ + title: active + ? language.t("toast.permissions.autoaccept.on.title") + : language.t("toast.permissions.autoaccept.off.title"), + description: active + ? language.t("toast.permissions.autoaccept.on.description") + : language.t("toast.permissions.autoaccept.off.description"), + }) + }, + }), + sessionCommand({ + id: "session.undo", + title: language.t("command.session.undo"), + description: language.t("command.session.undo.description"), + slash: "undo", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + if (status().type !== "idle") { + await sdk.client.session.abort({ sessionID }).catch(() => {}) + } + const revert = info()?.revert?.messageID + const message = findLast(userMessages(), (x) => !revert || x.id < revert) + if (!message) return + await sdk.client.session.revert({ sessionID, messageID: message.id }) + const parts = sync.data.part[message.id] + if (parts) { + const restored = extractPromptFromParts(parts, { directory: sdk.directory }) + prompt.set(restored) + } + const priorMessage = findLast(userMessages(), (x) => x.id < message.id) + setActiveMessage(priorMessage) + }, + }), + sessionCommand({ + id: "session.redo", + title: language.t("command.session.redo"), + description: language.t("command.session.redo.description"), + slash: "redo", + disabled: !params.id || !info()?.revert?.messageID, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const revertMessageID = info()?.revert?.messageID + if (!revertMessageID) return + const nextMessage = userMessages().find((x) => x.id > revertMessageID) + if (!nextMessage) { + await sdk.client.session.unrevert({ sessionID }) + prompt.reset() + const lastMsg = findLast(userMessages(), (x) => x.id >= revertMessageID) + setActiveMessage(lastMsg) return } - - const url = await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => res.data?.share?.url) - .catch(() => undefined) - if (!url) { + await sdk.client.session.revert({ sessionID, messageID: nextMessage.id }) + const priorMsg = findLast(userMessages(), (x) => x.id < nextMessage.id) + setActiveMessage(priorMsg) + }, + }), + sessionCommand({ + id: "session.compact", + title: language.t("command.session.compact"), + description: language.t("command.session.compact.description"), + slash: "compact", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: async () => { + const sessionID = params.id + if (!sessionID) return + const model = local.model.current() + if (!model) { showToast({ - title: language.t("toast.session.share.failed.title"), - description: language.t("toast.session.share.failed.description"), - variant: "error", + title: language.t("toast.model.none.title"), + description: language.t("toast.model.none.description"), }) return } - - await copy(url, false) + await sdk.client.session.summarize({ + sessionID, + modelID: model.id, + providerID: model.provider.id, + }) }, }), sessionCommand({ - id: "session.unshare", - title: language.t("command.session.unshare"), - description: language.t("command.session.unshare.description"), - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: language.t("toast.session.unshare.success.title"), - description: language.t("toast.session.unshare.success.description"), - variant: "success", - }), - ) - .catch(() => - showToast({ - title: language.t("toast.session.unshare.failed.title"), - description: language.t("toast.session.unshare.failed.description"), - variant: "error", - }), - ) - }, + id: "session.fork", + title: language.t("command.session.fork"), + description: language.t("command.session.fork.description"), + slash: "fork", + disabled: !params.id || visibleUserMessages().length === 0, + onSelect: () => dialog.show(() => ), }), + ...share, ] }) - - command.register("session", () => - [ - sessionCommands(), - fileCommands(), - contextCommands(), - viewCommands(), - messageCommands(), - agentCommands(), - permissionCommands(), - sessionActionCommands(), - shareCommands(), - ].flatMap((x) => x), - ) } From b66222baf7a09af692e8de06179c1c3e51715269 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 12 Mar 2026 14:17:34 -0400 Subject: [PATCH 010/145] zen: fix nemotron issue --- packages/console/core/src/model.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 223839bf13d..804987ebc5e 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -36,6 +36,7 @@ export namespace ZenData { weight: z.number().optional(), disabled: z.boolean().optional(), storeModel: z.string().optional(), + payloadModifier: z.record(z.string(), z.any()).optional(), }), ), }) From 184732fc2097166921dd46fbb9a8ce433a96b237 Mon Sep 17 00:00:00 2001 From: David Hill <1879069+iamdavidhill@users.noreply.github.com> Date: Thu, 12 Mar 2026 18:26:50 +0000 Subject: [PATCH 011/145] fix(app): titlebar cleanup (#17206) --- packages/app/src/components/prompt-input.tsx | 31 -- .../app/src/components/server/server-row.tsx | 14 +- .../src/components/session/session-header.tsx | 283 +------------- .../app/src/components/status-popover.tsx | 12 +- packages/app/src/components/titlebar.tsx | 23 +- .../src/pages/session/message-timeline.tsx | 202 +++++++++- .../src/assets/icons/app/android-studio.svg | 370 +++++++++++++++++- .../ui/src/assets/icons/app/antigravity.svg | 98 ++++- packages/ui/src/assets/icons/app/cursor.svg | 17 +- .../ui/src/assets/icons/app/file-explorer.svg | 21 +- packages/ui/src/assets/icons/app/finder.png | Bin 279917 -> 2127 bytes packages/ui/src/assets/icons/app/ghostty.svg | 14 +- packages/ui/src/assets/icons/app/iterm2.svg | 26 +- .../ui/src/assets/icons/app/powershell.svg | 15 +- .../ui/src/assets/icons/app/sublimetext.svg | 18 +- packages/ui/src/assets/icons/app/terminal.png | Bin 43815 -> 969 bytes packages/ui/src/assets/icons/app/textmate.png | Bin 104374 -> 2900 bytes packages/ui/src/assets/icons/app/vscode.svg | 40 +- packages/ui/src/assets/icons/app/warp.png | Bin 782232 -> 2470 bytes packages/ui/src/assets/icons/app/xcode.png | Bin 866979 -> 3470 bytes packages/ui/src/assets/icons/app/zed-dark.svg | 28 +- packages/ui/src/assets/icons/app/zed.svg | 28 +- packages/ui/src/components/button.css | 12 + packages/ui/src/components/dropdown-menu.css | 10 + packages/ui/src/components/icon.tsx | 22 +- packages/ui/src/components/message-part.css | 3 +- packages/ui/src/styles/tailwind/utilities.css | 5 + 27 files changed, 910 insertions(+), 382 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index e129b499ae1..af9c7530f1a 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -26,7 +26,6 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { IconButton } from "@opencode-ai/ui/icon-button" import { Select } from "@opencode-ai/ui/select" -import { RadioGroup } from "@opencode-ai/ui/radio-group" import { useDialog } from "@opencode-ai/ui/context/dialog" import { ModelSelectorPopover } from "@/components/dialog-select-model" import { DialogSelectModelUnpaid } from "@/components/dialog-select-model-unpaid" @@ -1488,36 +1487,6 @@ export const PromptInput: Component = (props) => {
-
- mode} - label={(mode) => ( - - - - )} - onSelect={(mode) => mode && setMode(mode)} - fill - pad="none" - class="w-[68px]" - /> -
diff --git a/packages/app/src/components/server/server-row.tsx b/packages/app/src/components/server/server-row.tsx index 5bb290ec303..8a4b7be4dce 100644 --- a/packages/app/src/components/server/server-row.tsx +++ b/packages/app/src/components/server/server-row.tsx @@ -65,22 +65,26 @@ export function ServerRow(props: ServerRowProps) { return (
-
-
- +
+
+ {name()} - + v{props.status?.version} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 9476f8b9ba0..8cb704bf1df 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -4,9 +4,7 @@ import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" import { Keybind } from "@opencode-ai/ui/keybind" -import { Popover } from "@opencode-ai/ui/popover" import { Spinner } from "@opencode-ai/ui/spinner" -import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { getFilename } from "@opencode-ai/util/path" @@ -14,12 +12,10 @@ import { createEffect, createMemo, For, onCleanup, Show } from "solid-js" import { createStore } from "solid-js/store" import { Portal } from "solid-js/web" import { useCommand } from "@/context/command" -import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useServer } from "@/context/server" -import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { focusTerminalById } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" @@ -112,12 +108,6 @@ const LINUX_APPS = [ }, ] as const -type OpenOption = (typeof MAC_APPS)[number] | (typeof WINDOWS_APPS)[number] | (typeof LINUX_APPS)[number] -type OpenIcon = OpenApp | "file-explorer" -const OPEN_ICON_BASE = new Set(["finder", "vscode", "cursor", "zed"]) - -const openIconSize = (id: OpenIcon) => (OPEN_ICON_BASE.has(id) ? "size-4" : "size-[19px]") - const detectOS = (platform: ReturnType): OS => { if (platform.platform === "desktop" && platform.os) return platform.os if (typeof navigator !== "object") return "unknown" @@ -136,98 +126,10 @@ const showRequestError = (language: ReturnType, err: unknown }) } -function useSessionShare(args: { - globalSDK: ReturnType - currentSession: () => - | { - share?: { - url?: string - } - } - | undefined - sessionID: () => string | undefined - projectDirectory: () => string - platform: ReturnType -}) { - const [state, setState] = createStore({ - share: false, - unshare: false, - copied: false, - timer: undefined as number | undefined, - }) - const shareUrl = createMemo(() => args.currentSession()?.share?.url) - - createEffect(() => { - const url = shareUrl() - if (url) return - if (state.timer) window.clearTimeout(state.timer) - setState({ copied: false, timer: undefined }) - }) - - onCleanup(() => { - if (state.timer) window.clearTimeout(state.timer) - }) - - const shareSession = () => { - const sessionID = args.sessionID() - if (!sessionID || state.share) return - setState("share", true) - args.globalSDK.client.session - .share({ sessionID, directory: args.projectDirectory() }) - .catch((error) => { - console.error("Failed to share session", error) - }) - .finally(() => { - setState("share", false) - }) - } - - const unshareSession = () => { - const sessionID = args.sessionID() - if (!sessionID || state.unshare) return - setState("unshare", true) - args.globalSDK.client.session - .unshare({ sessionID, directory: args.projectDirectory() }) - .catch((error) => { - console.error("Failed to unshare session", error) - }) - .finally(() => { - setState("unshare", false) - }) - } - - const copyLink = (onError: (error: unknown) => void) => { - const url = shareUrl() - if (!url) return - navigator.clipboard - .writeText(url) - .then(() => { - if (state.timer) window.clearTimeout(state.timer) - setState("copied", true) - const timer = window.setTimeout(() => { - setState("copied", false) - setState("timer", undefined) - }, 3000) - setState("timer", timer) - }) - .catch(onError) - } - - const viewShare = () => { - const url = shareUrl() - if (!url) return - args.platform.openLink(url) - } - - return { state, shareUrl, shareSession, unshareSession, copyLink, viewShare } -} - export function SessionHeader() { - const globalSDK = useGlobalSDK() const layout = useLayout() const command = useCommand() const server = useServer() - const sync = useSync() const platform = usePlatform() const language = useLanguage() const terminal = useTerminal() @@ -245,10 +147,6 @@ export function SessionHeader() { return getFilename(projectDirectory()) }) const hotkey = createMemo(() => command.keybind("file.open")) - - const currentSession = createMemo(() => (params.id ? sync.session.get(params.id) : undefined)) - const shareEnabled = createMemo(() => sync.data.config.share !== "disabled") - const showShare = createMemo(() => shareEnabled() && !!params.id) const os = createMemo(() => detectOS(platform)) const [exists, setExists] = createStore>>({ @@ -356,14 +254,6 @@ export function SessionHeader() { .catch((err: unknown) => showRequestError(language, err)) } - const share = useSessionShare({ - globalSDK, - currentSession, - sessionID: () => params.id, - projectDirectory, - platform, - }) - const centerMount = createMemo(() => document.getElementById("opencode-titlebar-center")) const rightMount = createMemo(() => document.getElementById("opencode-titlebar-right")) @@ -391,7 +281,9 @@ export function SessionHeader() { {(keybind) => ( - {keybind()} + + {keybind()} + )} @@ -402,7 +294,6 @@ export function SessionHeader() { {(mount) => (
- @@ -810,8 +827,8 @@ export const Playground = { {HEADINGS.map((h, i) => ( {[0, 1, 2, 3].map((value) => ( - ))} @@ -307,7 +328,7 @@ export const Playground = { max="1" step="0.01" value={dockOpenDuration()} - onInput={(event) => setDockOpenDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockOpenDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -324,7 +345,7 @@ export const Playground = { max="1" step="0.01" value={dockOpenBounce()} - onInput={(event) => setDockOpenBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockOpenBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -345,7 +366,7 @@ export const Playground = { max="1" step="0.01" value={dockCloseDuration()} - onInput={(event) => setDockCloseDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockCloseDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -362,7 +383,7 @@ export const Playground = { max="1" step="0.01" value={dockCloseBounce()} - onInput={(event) => setDockCloseBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("dockCloseBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -383,7 +404,7 @@ export const Playground = { max="1" step="0.01" value={drawerExpandDuration()} - onInput={(event) => setDrawerExpandDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerExpandDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -400,7 +421,7 @@ export const Playground = { max="1" step="0.01" value={drawerExpandBounce()} - onInput={(event) => setDrawerExpandBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerExpandBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -421,7 +442,7 @@ export const Playground = { max="1" step="0.01" value={drawerCollapseDuration()} - onInput={(event) => setDrawerCollapseDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerCollapseDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -438,7 +459,7 @@ export const Playground = { max="1" step="0.01" value={drawerCollapseBounce()} - onInput={(event) => setDrawerCollapseBounce(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("drawerCollapseBounce", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -459,7 +480,7 @@ export const Playground = { max="1400" step="10" value={subtitleDuration()} - onInput={(event) => setSubtitleDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("subtitleDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -473,7 +494,7 @@ export const Playground = { setSubtitleAuto(event.currentTarget.checked)} + onInput={(event) => setCfg("subtitleAuto", event.currentTarget.checked)} /> {subtitleAuto() ? "on" : "off"} @@ -489,7 +510,7 @@ export const Playground = { max="40" step="1" value={subtitleTravel()} - onInput={(event) => setSubtitleTravel(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("subtitleTravel", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {subtitleTravel()}px @@ -504,7 +525,7 @@ export const Playground = { max="40" step="1" value={subtitleEdge()} - onInput={(event) => setSubtitleEdge(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("subtitleEdge", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {subtitleEdge()}% @@ -523,7 +544,7 @@ export const Playground = { max="1400" step="10" value={countDuration()} - onInput={(event) => setCountDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> @@ -540,7 +561,7 @@ export const Playground = { max="40" step="1" value={countMask()} - onInput={(event) => setCountMask(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countMask", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {countMask()}% @@ -555,7 +576,7 @@ export const Playground = { max="14" step="1" value={countMaskHeight()} - onInput={(event) => setCountMaskHeight(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countMaskHeight", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> {countMaskHeight()}px @@ -570,7 +591,7 @@ export const Playground = { max="1200" step="10" value={countWidthDuration()} - onInput={(event) => setCountWidthDuration(event.currentTarget.valueAsNumber)} + onInput={(event) => setCfg("countWidthDuration", event.currentTarget.valueAsNumber)} style={{ flex: 1 }} /> diff --git a/packages/ui/src/components/tool-count-summary.stories.tsx b/packages/ui/src/components/tool-count-summary.stories.tsx index 4be3a02bbec..cf160b188bf 100644 --- a/packages/ui/src/components/tool-count-summary.stories.tsx +++ b/packages/ui/src/components/tool-count-summary.stories.tsx @@ -1,5 +1,6 @@ // @ts-nocheck -import { createSignal, onCleanup } from "solid-js" +import { onCleanup } from "solid-js" +import { createStore } from "solid-js/store" import { AnimatedCountList, type CountItem } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" @@ -57,11 +58,18 @@ const smallBtn = (active?: boolean) => export const Playground = { render: () => { - const [reads, setReads] = createSignal(0) - const [searches, setSearches] = createSignal(0) - const [lists, setLists] = createSignal(0) - const [active, setActive] = createSignal(false) - const [reducedMotion, setReducedMotion] = createSignal(false) + const [state, setState] = createStore({ + reads: 0, + searches: 0, + lists: 0, + active: false, + reducedMotion: false, + }) + const reads = () => state.reads + const searches = () => state.searches + const lists = () => state.lists + const active = () => state.active + const reducedMotion = () => state.reducedMotion let timeouts: ReturnType[] = [] @@ -74,10 +82,10 @@ export const Playground = { const startSim = () => { clearAll() - setReads(0) - setSearches(0) - setLists(0) - setActive(true) + setState("reads", 0) + setState("searches", 0) + setState("lists", 0) + setState("active", true) const steps = rand(3, 10) let elapsed = 0 @@ -86,27 +94,27 @@ export const Playground = { elapsed += delay const t = setTimeout(() => { const pick = rand(0, 2) - if (pick === 0) setReads((n) => n + 1) - else if (pick === 1) setSearches((n) => n + 1) - else setLists((n) => n + 1) + if (pick === 0) setState("reads", (value) => value + 1) + else if (pick === 1) setState("searches", (value) => value + 1) + else setState("lists", (value) => value + 1) }, elapsed) timeouts.push(t) } - const end = setTimeout(() => setActive(false), elapsed + 100) + const end = setTimeout(() => setState("active", false), elapsed + 100) timeouts.push(end) } const stopSim = () => { clearAll() - setActive(false) + setState("active", false) } const reset = () => { stopSim() - setReads(0) - setSearches(0) - setLists(0) + setState("reads", 0) + setState("searches", 0) + setState("lists", 0) } const items = (): CountItem[] => [ @@ -164,19 +172,19 @@ export const Playground = { -
- - -
diff --git a/packages/ui/src/components/tool-error-card.tsx b/packages/ui/src/components/tool-error-card.tsx index ba39ae586af..0c99924de98 100644 --- a/packages/ui/src/components/tool-error-card.tsx +++ b/packages/ui/src/components/tool-error-card.tsx @@ -1,4 +1,5 @@ -import { type ComponentProps, createMemo, createSignal, Show, splitProps } from "solid-js" +import { type ComponentProps, createMemo, Show, splitProps } from "solid-js" +import { createStore } from "solid-js/store" import { Card, CardDescription } from "./card" import { Collapsible } from "./collapsible" import { Icon } from "./icon" @@ -16,8 +17,12 @@ export interface ToolErrorCardProps extends Omit, "c export function ToolErrorCard(props: ToolErrorCardProps) { const i18n = useI18n() - const [open, setOpen] = createSignal(props.defaultOpen ?? false) - const [copied, setCopied] = createSignal(false) + const [state, setState] = createStore({ + open: props.defaultOpen ?? false, + copied: false, + }) + const open = () => state.open + const copied = () => state.copied const [split, rest] = splitProps(props, ["tool", "error", "defaultOpen", "subtitle", "href"]) const name = createMemo(() => { const map: Record = { @@ -65,13 +70,18 @@ export function ToolErrorCard(props: ToolErrorCardProps) { const text = cleaned() if (!text) return await navigator.clipboard.writeText(text) - setCopied(true) - setTimeout(() => setCopied(false), 2000) + setState("copied", true) + setTimeout(() => setState("copied", false), 2000) } return ( - + setState("open", value)} + >
diff --git a/packages/ui/src/components/tool-status-title.tsx b/packages/ui/src/components/tool-status-title.tsx index 68440b6c637..2a58e0e5bb6 100644 --- a/packages/ui/src/components/tool-status-title.tsx +++ b/packages/ui/src/components/tool-status-title.tsx @@ -1,4 +1,5 @@ -import { Show, createEffect, createMemo, createSignal, on, onCleanup, onMount } from "solid-js" +import { Show, createEffect, createMemo, on, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { TextShimmer } from "./text-shimmer" function common(active: string, done: string) { @@ -35,8 +36,12 @@ export function ToolStatusTitle(props: { const activeTail = createMemo(() => (suffix() ? split().active : props.activeText)) const doneTail = createMemo(() => (suffix() ? split().done : props.doneText)) - const [width, setWidth] = createSignal("auto") - const [ready, setReady] = createSignal(false) + const [state, setState] = createStore({ + width: "auto", + ready: false, + }) + const width = () => state.width + const ready = () => state.ready let activeRef: HTMLSpanElement | undefined let doneRef: HTMLSpanElement | undefined let frame: number | undefined @@ -45,7 +50,7 @@ export function ToolStatusTitle(props: { const measure = () => { const target = props.active ? activeRef : doneRef const px = contentWidth(target) - if (px > 0) setWidth(`${px}px`) + if (px > 0) setState("width", `${px}px`) } const schedule = () => { @@ -62,13 +67,13 @@ export function ToolStatusTitle(props: { const finish = () => { if (typeof requestAnimationFrame !== "function") { - setReady(true) + setState("ready", true) return } if (readyFrame !== undefined) cancelAnimationFrame(readyFrame) readyFrame = requestAnimationFrame(() => { readyFrame = undefined - setReady(true) + setState("ready", true) }) } diff --git a/packages/ui/src/pierre/file-find.ts b/packages/ui/src/pierre/file-find.ts index ee608152d5e..692ab31670f 100644 --- a/packages/ui/src/pierre/file-find.ts +++ b/packages/ui/src/pierre/file-find.ts @@ -1,4 +1,5 @@ -import { createEffect, createSignal, onCleanup, onMount } from "solid-js" +import { createEffect, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" export type FindHost = { element: () => HTMLElement | undefined @@ -107,11 +108,18 @@ export function createFileFind(opts: CreateFileFindOptions) { let mode: "highlights" | "overlay" = "overlay" let hits: Range[] = [] - const [open, setOpen] = createSignal(false) - const [query, setQuery] = createSignal("") - const [index, setIndex] = createSignal(0) - const [count, setCount] = createSignal(0) - const [pos, setPos] = createSignal({ top: 8, right: 8 }) + const [state, setState] = createStore({ + open: false, + query: "", + index: 0, + count: 0, + pos: { top: 8, right: 8 }, + }) + const open = () => state.open + const query = () => state.query + const index = () => state.index + const count = () => state.count + const pos = () => state.pos const clearOverlayScroll = () => { for (const el of overlayScroll) el.removeEventListener("scroll", scheduleOverlay) @@ -200,8 +208,8 @@ export function createFileFind(opts: CreateFileFindOptions) { clearOverlay() clearOverlayScroll() hits = [] - setCount(0) - setIndex(0) + setState("count", 0) + setState("index", 0) } const positionBar = () => { @@ -214,7 +222,7 @@ export function createFileFind(opts: CreateFileFindOptions) { const title = parseFloat(getComputedStyle(root).getPropertyValue("--session-title-height")) const header = Number.isNaN(title) ? 0 : title - setPos({ + setState("pos", { top: Math.round(rect.top) + header - 4, right: Math.round(window.innerWidth - rect.right) + 8, }) @@ -318,8 +326,8 @@ export function createFileFind(opts: CreateFileFindOptions) { const currentIndex = total ? Math.min(desired, total - 1) : 0 hits = ranges - setCount(total) - setIndex(currentIndex) + setState("count", total) + setState("index", currentIndex) const active = ranges[currentIndex] if (mode === "highlights") { @@ -342,8 +350,8 @@ export function createFileFind(opts: CreateFileFindOptions) { } const close = () => { - setOpen(false) - setQuery("") + setState("open", false) + setState("query", "") clearFind() if (current === host) current = undefined } @@ -352,7 +360,7 @@ export function createFileFind(opts: CreateFileFindOptions) { if (current && current !== host) current.close() current = host target = host - if (!open()) setOpen(true) + if (!open()) setState("open", true) requestAnimationFrame(() => { apply({ scroll: true }) input?.focus() @@ -366,7 +374,7 @@ export function createFileFind(opts: CreateFileFindOptions) { if (total <= 0) return const currentIndex = (index() + dir + total) % total - setIndex(currentIndex) + setState("index", currentIndex) const active = hits[currentIndex] if (!active) return @@ -449,8 +457,8 @@ export function createFileFind(opts: CreateFileFindOptions) { input = el }, setQuery: (value: string) => { - setQuery(value) - setIndex(0) + setState("query", value) + setState("index", 0) apply({ reset: true, scroll: true }) }, focus, From 05cb3c87ca387be41aceb5ccad978c6848a56f70 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 13 Mar 2026 06:48:38 -0500 Subject: [PATCH 048/145] chore(app): i18n sync (#17283) --- packages/app/src/app.tsx | 15 +++- packages/app/src/components/debug-bar.tsx | 70 +++++++++-------- .../app/src/components/dialog-select-file.tsx | 2 +- .../src/components/dialog-select-server.tsx | 4 +- .../app/src/components/server/server-row.tsx | 4 +- .../src/components/session/session-header.tsx | 54 ++++++------- .../session/session-sortable-terminal-tab.tsx | 7 +- .../app/src/components/settings-keybinds.tsx | 2 +- packages/app/src/components/terminal.tsx | 2 +- packages/app/src/context/command.tsx | 61 ++++++++++++--- packages/app/src/context/file.tsx | 6 +- packages/app/src/context/global-sdk.tsx | 6 +- packages/app/src/context/global-sync.tsx | 1 + .../app/src/context/global-sync/bootstrap.ts | 2 +- .../context/global-sync/child-store.test.ts | 1 + .../src/context/global-sync/child-store.ts | 9 ++- packages/app/src/context/terminal-title.ts | 51 ++++++++++++ packages/app/src/context/terminal.tsx | 11 +-- packages/app/src/i18n/ar.ts | 73 ++++++++++++++++++ packages/app/src/i18n/br.ts | 75 ++++++++++++++++++ packages/app/src/i18n/bs.ts | 75 ++++++++++++++++++ packages/app/src/i18n/da.ts | 75 ++++++++++++++++++ packages/app/src/i18n/de.ts | 76 ++++++++++++++++++ packages/app/src/i18n/en.ts | 76 ++++++++++++++++++ packages/app/src/i18n/es.ts | 75 ++++++++++++++++++ packages/app/src/i18n/fr.ts | 77 +++++++++++++++++++ packages/app/src/i18n/ja.ts | 74 ++++++++++++++++++ packages/app/src/i18n/ko.ts | 74 ++++++++++++++++++ packages/app/src/i18n/no.ts | 75 ++++++++++++++++++ packages/app/src/i18n/pl.ts | 76 ++++++++++++++++++ packages/app/src/i18n/ru.ts | 75 ++++++++++++++++++ packages/app/src/i18n/th.ts | 75 ++++++++++++++++++ packages/app/src/i18n/tr.ts | 74 ++++++++++++++++++ packages/app/src/i18n/zh.ts | 73 ++++++++++++++++++ packages/app/src/i18n/zht.ts | 73 ++++++++++++++++++ packages/app/src/pages/error.tsx | 32 ++++---- packages/app/src/pages/layout.tsx | 2 +- packages/app/src/pages/session.tsx | 8 +- .../composer/session-composer-region.tsx | 1 - .../composer/session-question-dock.tsx | 2 +- .../session/composer/session-todo-dock.tsx | 28 +++++-- .../app/src/pages/session/terminal-label.ts | 6 +- packages/ui/src/components/basic-tool.tsx | 5 +- packages/ui/src/components/file-search.tsx | 11 ++- .../components/line-comment-annotations.tsx | 8 +- packages/ui/src/components/message-part.tsx | 20 ++--- .../ui/src/components/tool-error-card.tsx | 16 ++-- packages/ui/src/i18n/ar.ts | 12 +++ packages/ui/src/i18n/br.ts | 12 +++ packages/ui/src/i18n/bs.ts | 12 +++ packages/ui/src/i18n/da.ts | 12 +++ packages/ui/src/i18n/de.ts | 12 +++ packages/ui/src/i18n/en.ts | 13 ++++ packages/ui/src/i18n/es.ts | 12 +++ packages/ui/src/i18n/fr.ts | 12 +++ packages/ui/src/i18n/ja.ts | 12 +++ packages/ui/src/i18n/ko.ts | 12 +++ packages/ui/src/i18n/no.ts | 12 +++ packages/ui/src/i18n/pl.ts | 12 +++ packages/ui/src/i18n/ru.ts | 12 +++ packages/ui/src/i18n/th.ts | 12 +++ packages/ui/src/i18n/tr.ts | 12 +++ packages/ui/src/i18n/zh.ts | 12 +++ packages/ui/src/i18n/zht.ts | 12 +++ packages/ui/src/pierre/selection-bridge.ts | 9 ++- 65 files changed, 1776 insertions(+), 156 deletions(-) create mode 100644 packages/app/src/context/terminal-title.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index c6fca36d59a..e370862212b 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -12,6 +12,7 @@ import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { type Duration, Effect } from "effect" import { type Component, + createMemo, createResource, createSignal, ErrorBoundary, @@ -67,7 +68,7 @@ const SessionIndexRoute = () => function UiI18nBridge(props: ParentProps) { const language = useLanguage() - return {props.children} + return {props.children} } declare global { @@ -218,8 +219,12 @@ function ConnectionGate(props: ParentProps<{ disableHealthCheck?: boolean }>) { } function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key: ServerConnection.Key) => void }) { + const language = useLanguage() const server = useServer() const others = () => server.list.filter((s) => ServerConnection.key(s) !== server.key) + const name = createMemo(() => server.name || server.key) + const serverToken = "\u0000server\u0000" + const unreachable = createMemo(() => language.t("app.server.unreachable", { server: serverToken }).split(serverToken)) const timer = setInterval(() => props.onRetry?.(), 1000) onCleanup(() => clearInterval(timer)) @@ -229,13 +234,15 @@ function ConnectionError(props: { onRetry?: () => void; onServerSelected?: (key:

- Could not reach {server.name || server.key} + {unreachable()[0]} + {name()} + {unreachable()[1]}

-

Retrying automatically...

+

{language.t("app.server.retrying")}

0}>
- Other servers + {language.t("app.server.otherServers")}
{(conn) => { diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 6fde71f3b2e..cbb24f77bc1 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -2,6 +2,7 @@ import { useIsRouting, useLocation } from "@solidjs/router" import { batch, createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { Tooltip } from "@opencode-ai/ui/tooltip" +import { useLanguage } from "@/context/language" type Mem = Performance & { memory?: { @@ -27,17 +28,17 @@ type Obs = PerformanceObserverInit & { const span = 5000 const ms = (n?: number, d = 0) => { - if (n === undefined || Number.isNaN(n)) return "n/a" + if (n === undefined || Number.isNaN(n)) return return `${n.toFixed(d)}ms` } const time = (n?: number) => { - if (n === undefined || Number.isNaN(n)) return "n/a" + if (n === undefined || Number.isNaN(n)) return return `${Math.round(n)}` } const mb = (n?: number) => { - if (n === undefined || Number.isNaN(n)) return "n/a" + if (n === undefined || Number.isNaN(n)) return const v = n / 1024 / 1024 return `${v >= 1024 ? v.toFixed(0) : v.toFixed(1)}MB` } @@ -74,6 +75,7 @@ function Cell(props: { bad?: boolean; dim?: boolean; label: string; tip: string; } export function DebugBar() { + const language = useLanguage() const location = useLocation() const routing = useIsRouting() const [state, setState] = createStore({ @@ -98,14 +100,15 @@ export function DebugBar() { }, }) + const na = () => language.t("debugBar.na") const heap = () => (state.heap.limit ? (state.heap.used ?? 0) / state.heap.limit : undefined) const heapv = () => { const value = heap() - if (value === undefined) return "n/a" + if (value === undefined) return na() return `${Math.round(value * 100)}%` } - const longv = () => (state.long.count === undefined ? "n/a" : `${time(state.long.block)}/${state.long.count}`) - const navv = () => (state.nav.pending ? "..." : time(state.nav.dur)) + const longv = () => (state.long.count === undefined ? na() : `${time(state.long.block) ?? na()}/${state.long.count}`) + const navv = () => (state.nav.pending ? "..." : (time(state.nav.dur) ?? na())) let prev = "" let start = 0 @@ -359,7 +362,7 @@ export function DebugBar() { return (
diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 655aba0b023..eb039c14d61 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -149,7 +149,7 @@ function ServerForm(props: ServerFormProps) { {conn().http.username} ) : ( - no username + {language.t("server.row.noUsername")} )} {conn().http.password && ••••••••} diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 8cb704bf1df..ae9d2800ed4 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -46,63 +46,63 @@ type OS = "macos" | "windows" | "linux" | "unknown" const MAC_APPS = [ { id: "vscode", - label: "VS Code", + label: "session.header.open.app.vscode", icon: "vscode", openWith: "Visual Studio Code", }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "Cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "Zed" }, - { id: "textmate", label: "TextMate", icon: "textmate", openWith: "TextMate" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "Cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "Zed" }, + { id: "textmate", label: "session.header.open.app.textmate", icon: "textmate", openWith: "TextMate" }, { id: "antigravity", - label: "Antigravity", + label: "session.header.open.app.antigravity", icon: "antigravity", openWith: "Antigravity", }, - { id: "terminal", label: "Terminal", icon: "terminal", openWith: "Terminal" }, - { id: "iterm2", label: "iTerm2", icon: "iterm2", openWith: "iTerm" }, - { id: "ghostty", label: "Ghostty", icon: "ghostty", openWith: "Ghostty" }, - { id: "warp", label: "Warp", icon: "warp", openWith: "Warp" }, - { id: "xcode", label: "Xcode", icon: "xcode", openWith: "Xcode" }, + { id: "terminal", label: "session.header.open.app.terminal", icon: "terminal", openWith: "Terminal" }, + { id: "iterm2", label: "session.header.open.app.iterm2", icon: "iterm2", openWith: "iTerm" }, + { id: "ghostty", label: "session.header.open.app.ghostty", icon: "ghostty", openWith: "Ghostty" }, + { id: "warp", label: "session.header.open.app.warp", icon: "warp", openWith: "Warp" }, + { id: "xcode", label: "session.header.open.app.xcode", icon: "xcode", openWith: "Xcode" }, { id: "android-studio", - label: "Android Studio", + label: "session.header.open.app.androidStudio", icon: "android-studio", openWith: "Android Studio", }, { id: "sublime-text", - label: "Sublime Text", + label: "session.header.open.app.sublimeText", icon: "sublime-text", openWith: "Sublime Text", }, ] as const const WINDOWS_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, { id: "powershell", - label: "PowerShell", + label: "session.header.open.app.powershell", icon: "powershell", openWith: "powershell", }, { id: "sublime-text", - label: "Sublime Text", + label: "session.header.open.app.sublimeText", icon: "sublime-text", openWith: "Sublime Text", }, ] as const const LINUX_APPS = [ - { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, - { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, - { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, + { id: "vscode", label: "session.header.open.app.vscode", icon: "vscode", openWith: "code" }, + { id: "cursor", label: "session.header.open.app.cursor", icon: "cursor", openWith: "cursor" }, + { id: "zed", label: "session.header.open.app.zed", icon: "zed", openWith: "zed" }, { id: "sublime-text", - label: "Sublime Text", + label: "session.header.open.app.sublimeText", icon: "sublime-text", openWith: "Sublime Text", }, @@ -160,9 +160,9 @@ export function SessionHeader() { }) const fileManager = createMemo(() => { - if (os() === "macos") return { label: "Finder", icon: "finder" as const } - if (os() === "windows") return { label: "File Explorer", icon: "file-explorer" as const } - return { label: "File Manager", icon: "finder" as const } + if (os() === "macos") return { label: "session.header.open.finder", icon: "finder" as const } + if (os() === "windows") return { label: "session.header.open.fileExplorer", icon: "file-explorer" as const } + return { label: "session.header.open.fileManager", icon: "finder" as const } }) createEffect(() => { @@ -187,8 +187,10 @@ export function SessionHeader() { const options = createMemo(() => { return [ - { id: "finder", label: fileManager().label, icon: fileManager().icon }, - ...apps().filter((app) => exists[app.id]), + { id: "finder", label: language.t(fileManager().label), icon: fileManager().icon }, + ...apps() + .filter((app) => exists[app.id]) + .map((app) => ({ ...app, label: language.t(app.label) })), ] as const }) diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index 4f49911c127..89895874250 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -6,6 +6,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" import { DropdownMenu } from "@opencode-ai/ui/dropdown-menu" import { Icon } from "@opencode-ai/ui/icon" +import { isDefaultTitle as isDefaultTerminalTitle } from "@/context/terminal-title" import { useTerminal, type LocalPTY } from "@/context/terminal" import { useLanguage } from "@/context/language" import { focusTerminalById } from "@/pages/session/helpers" @@ -27,11 +28,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const isDefaultTitle = () => { const number = props.terminal.titleNumber if (!Number.isFinite(number) || number <= 0) return false - const match = props.terminal.title.match(/^Terminal (\d+)$/) - if (!match) return false - const parsed = Number(match[1]) - if (!Number.isFinite(parsed) || parsed <= 0) return false - return parsed === number + return isDefaultTerminalTitle(props.terminal.title, number) } const label = () => { diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 1e42447895d..7e2a48110cb 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -239,7 +239,7 @@ function useKeyCapture(input: { showToast({ title: input.language.t("settings.shortcuts.conflict.title"), description: input.language.t("settings.shortcuts.conflict.description", { - keybind: formatKeybind(next), + keybind: formatKeybind(next, input.language.t), titles: [...conflicts.values()].join(", "), }), }) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 84090329388..ff455ebe205 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -519,7 +519,7 @@ export const Terminal = (props: TerminalProps) => { if (event.code !== 1000) { if (once.value) return once.value = true - local.onConnectError?.(new Error(`WebSocket closed abnormally: ${event.code}`)) + local.onConnectError?.(new Error(language.t("terminal.connectionLost.abnormalClose", { code: event.code }))) } } socket.addEventListener("close", handleClose) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 03bd6318dab..2c6799d12be 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -2,6 +2,7 @@ import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "sol import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { dict as en } from "@/i18n/en" import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { Persist, persisted } from "@/utils/persist" @@ -13,6 +14,27 @@ const DEFAULT_PALETTE_KEYBIND = "mod+shift+p" const SUGGESTED_PREFIX = "suggested." const EDITABLE_KEYBIND_IDS = new Set(["terminal.toggle", "terminal.new", "file.attach"]) +type KeyLabel = + | "common.key.ctrl" + | "common.key.alt" + | "common.key.shift" + | "common.key.meta" + | "common.key.space" + | "common.key.backspace" + | "common.key.enter" + | "common.key.tab" + | "common.key.delete" + | "common.key.home" + | "common.key.end" + | "common.key.pageUp" + | "common.key.pageDown" + | "common.key.insert" + | "common.key.esc" + +function keyText(key: KeyLabel, t?: (key: KeyLabel) => string) { + return t ? t(key) : en[key] +} + function actionId(id: string) { if (!id.startsWith(SUGGESTED_PREFIX)) return id return id.slice(SUGGESTED_PREFIX.length) @@ -145,7 +167,7 @@ export function matchKeybind(keybinds: Keybind[], event: KeyboardEvent): boolean return false } -export function formatKeybind(config: string): string { +export function formatKeybind(config: string, t?: (key: KeyLabel) => string): string { if (!config || config === "none") return "" const keybinds = parseKeybind(config) @@ -154,10 +176,10 @@ export function formatKeybind(config: string): string { const kb = keybinds[0] const parts: string[] = [] - if (kb.ctrl) parts.push(IS_MAC ? "⌃" : "Ctrl") - if (kb.alt) parts.push(IS_MAC ? "⌥" : "Alt") - if (kb.shift) parts.push(IS_MAC ? "⇧" : "Shift") - if (kb.meta) parts.push(IS_MAC ? "⌘" : "Meta") + if (kb.ctrl) parts.push(IS_MAC ? "⌃" : keyText("common.key.ctrl", t)) + if (kb.alt) parts.push(IS_MAC ? "⌥" : keyText("common.key.alt", t)) + if (kb.shift) parts.push(IS_MAC ? "⇧" : keyText("common.key.shift", t)) + if (kb.meta) parts.push(IS_MAC ? "⌘" : keyText("common.key.meta", t)) if (kb.key) { const keys: Record = { @@ -167,10 +189,29 @@ export function formatKeybind(config: string): string { arrowright: "→", comma: ",", plus: "+", - space: "Space", + } + const named: Record = { + backspace: "common.key.backspace", + delete: "common.key.delete", + end: "common.key.end", + enter: "common.key.enter", + esc: "common.key.esc", + escape: "common.key.esc", + home: "common.key.home", + insert: "common.key.insert", + pagedown: "common.key.pageDown", + pageup: "common.key.pageUp", + space: "common.key.space", + tab: "common.key.tab", } const key = kb.key.toLowerCase() - const displayKey = keys[key] ?? (key.length === 1 ? key.toUpperCase() : key.charAt(0).toUpperCase() + key.slice(1)) + const displayKey = + keys[key] ?? + (named[key] + ? keyText(named[key], t) + : key.length === 1 + ? key.toUpperCase() + : key.charAt(0).toUpperCase() + key.slice(1)) parts.push(displayKey) } @@ -364,17 +405,17 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }, keybind(id: string) { if (id === PALETTE_ID) { - return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND) + return formatKeybind(settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND, language.t) } const base = actionId(id) const option = options().find((x) => actionId(x.id) === base) - if (option?.keybind) return formatKeybind(option.keybind) + if (option?.keybind) return formatKeybind(option.keybind, language.t) const meta = catalog[base] const config = bind(base, meta?.keybind) if (!config) return "" - return formatKeybind(config) + return formatKeybind(config, language.t) }, show: showPalette, keybinds(enabled: boolean) { diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 99c6d2e4219..f8fec7142d8 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -43,10 +43,10 @@ export { touchFileContent, } -function errorMessage(error: unknown) { +function errorMessage(error: unknown, fallback: string) { if (error instanceof Error && error.message) return error.message if (typeof error === "string" && error) return error - return "Unknown error" + return fallback } export const { use: useFile, provider: FileProvider } = createSimpleContext({ @@ -184,7 +184,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ }) .catch((e) => { if (scope() !== directory) return - setLoadError(file, errorMessage(e)) + setLoadError(file, errorMessage(e, language.t("error.chain.unknown"))) }) .finally(() => { inflight.delete(key) diff --git a/packages/app/src/context/global-sdk.tsx b/packages/app/src/context/global-sdk.tsx index c1a87b95b89..60e9fd6d542 100644 --- a/packages/app/src/context/global-sdk.tsx +++ b/packages/app/src/context/global-sdk.tsx @@ -4,6 +4,7 @@ import { createGlobalEmitter } from "@solid-primitives/event-bus" import { batch, onCleanup } from "solid-js" import z from "zod" import { createSdkForServer } from "@/utils/server" +import { useLanguage } from "./language" import { usePlatform } from "./platform" import { useServer } from "./server" @@ -14,6 +15,7 @@ const abortError = z.object({ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleContext({ name: "GlobalSDK", init: () => { + const language = useLanguage() const server = useServer() const platform = usePlatform() const abort = new AbortController() @@ -30,7 +32,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo })() const currentServer = server.current - if (!currentServer) throw new Error("No server available") + if (!currentServer) throw new Error(language.t("error.globalSDK.noServerAvailable")) const eventSdk = createSdkForServer({ signal: abort.signal, @@ -218,7 +220,7 @@ export const { use: useGlobalSDK, provider: GlobalSDKProvider } = createSimpleCo event: emitter, createClient(opts: Omit[0], "server" | "fetch">) { const s = server.current - if (!s) throw new Error("Server not available") + if (!s) throw new Error(language.t("error.globalSDK.serverNotAvailable")) return createSdkForServer({ server: s.http, fetch: platform.fetch, diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 1b6cdf530a7..c8409886928 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -164,6 +164,7 @@ function createGlobalSync() { sdkCache.delete(directory) clearSessionPrefetchDirectory(directory) }, + translate: language.t, }) const sdkFor = (directory: string) => { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 8b1a3c48c5f..13494b7ade0 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -139,7 +139,7 @@ export async function bootstrapDirectory(input: { const project = getFilename(input.directory) showToast({ variant: "error", - title: `Failed to reload ${project}`, + title: input.translate("toast.project.reloadFailed.title", { project }), description: formatServerError(err, input.translate), }) input.setStore("status", "partial") diff --git a/packages/app/src/context/global-sync/child-store.test.ts b/packages/app/src/context/global-sync/child-store.test.ts index cec76ff87ec..eee763f16de 100644 --- a/packages/app/src/context/global-sync/child-store.test.ts +++ b/packages/app/src/context/global-sync/child-store.test.ts @@ -21,6 +21,7 @@ describe("createChildStoreManager", () => { isLoadingSessions: () => false, onBootstrap() {}, onDispose() {}, + translate: (key) => key, }) Array.from({ length: 30 }, (_, index) => `/pinned-${index}`).forEach((directory) => { diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index e2ada244fb3..d5904c60964 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -21,6 +21,7 @@ export function createChildStoreManager(input: { isLoadingSessions: (directory: string) => boolean onBootstrap: (directory: string) => void onDispose: (directory: string) => void + translate: (key: string, vars?: Record) => string }) { const children: Record, SetStoreFunction]> = {} const vcsCache = new Map() @@ -129,7 +130,7 @@ export function createChildStoreManager(input: { createStore({ value: undefined as VcsInfo | undefined }), ), ) - if (!vcs) throw new Error("Failed to create persisted cache") + if (!vcs) throw new Error(input.translate("error.childStore.persistedCacheCreateFailed")) const vcsStore = vcs[0] vcsCache.set(directory, { store: vcsStore, setStore: vcs[1], ready: vcs[3] }) @@ -139,7 +140,7 @@ export function createChildStoreManager(input: { createStore({ value: undefined as ProjectMeta | undefined }), ), ) - if (!meta) throw new Error("Failed to create persisted project metadata") + if (!meta) throw new Error(input.translate("error.childStore.persistedProjectMetadataCreateFailed")) metaCache.set(directory, { store: meta[0], setStore: meta[1], ready: meta[3] }) const icon = runWithOwner(input.owner, () => @@ -148,7 +149,7 @@ export function createChildStoreManager(input: { createStore({ value: undefined as string | undefined }), ), ) - if (!icon) throw new Error("Failed to create persisted project icon") + if (!icon) throw new Error(input.translate("error.childStore.persistedProjectIconCreateFailed")) iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) const init = () => @@ -211,7 +212,7 @@ export function createChildStoreManager(input: { } mark(directory) const childStore = children[directory] - if (!childStore) throw new Error("Failed to create store") + if (!childStore) throw new Error(input.translate("error.childStore.storeCreateFailed")) return childStore } diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts new file mode 100644 index 00000000000..3e8fa9af251 --- /dev/null +++ b/packages/app/src/context/terminal-title.ts @@ -0,0 +1,51 @@ +import { dict as ar } from "@/i18n/ar" +import { dict as br } from "@/i18n/br" +import { dict as bs } from "@/i18n/bs" +import { dict as da } from "@/i18n/da" +import { dict as de } from "@/i18n/de" +import { dict as en } from "@/i18n/en" +import { dict as es } from "@/i18n/es" +import { dict as fr } from "@/i18n/fr" +import { dict as ja } from "@/i18n/ja" +import { dict as ko } from "@/i18n/ko" +import { dict as no } from "@/i18n/no" +import { dict as pl } from "@/i18n/pl" +import { dict as ru } from "@/i18n/ru" +import { dict as th } from "@/i18n/th" +import { dict as tr } from "@/i18n/tr" +import { dict as zh } from "@/i18n/zh" +import { dict as zht } from "@/i18n/zht" + +const numbered = Array.from( + new Set([ + en["terminal.title.numbered"], + ar["terminal.title.numbered"], + br["terminal.title.numbered"], + bs["terminal.title.numbered"], + da["terminal.title.numbered"], + de["terminal.title.numbered"], + es["terminal.title.numbered"], + fr["terminal.title.numbered"], + ja["terminal.title.numbered"], + ko["terminal.title.numbered"], + no["terminal.title.numbered"], + pl["terminal.title.numbered"], + ru["terminal.title.numbered"], + th["terminal.title.numbered"], + tr["terminal.title.numbered"], + zh["terminal.title.numbered"], + zht["terminal.title.numbered"], + ]), +) + +export function defaultTitle(number: number) { + return en["terminal.title.numbered"].replace("{{number}}", String(number)) +} + +export function isDefaultTitle(title: string, number: number) { + return numbered.some((text) => title === text.replace("{{number}}", String(number))) +} + +export function titleNumber(title: string, max: number) { + return Array.from({ length: max }, (_, idx) => idx + 1).find((number) => isDefaultTitle(title, number)) +} diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index a2807375ff6..e65c1678846 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -4,6 +4,7 @@ import { batch, createEffect, createMemo, createRoot, on, onCleanup } from "soli import { useParams } from "@solidjs/router" import { useSDK } from "./sdk" import type { Platform } from "./platform" +import { defaultTitle, titleNumber } from "./terminal-title" import { Persist, persisted, removePersisted } from "@/utils/persist" export type LocalPTY = { @@ -33,11 +34,7 @@ function num(value: unknown) { } function numberFromTitle(title: string) { - const match = title.match(/^Terminal (\d+)$/) - if (!match) return - const value = Number(match[1]) - if (!Number.isFinite(value) || value <= 0) return - return value + return titleNumber(title, MAX_TERMINAL_SESSIONS) } function pty(value: unknown): LocalPTY | undefined { @@ -202,13 +199,13 @@ function createWorkspaceTerminalSession(sdk: ReturnType, dir: str const nextNumber = pickNextTerminalNumber() sdk.client.pty - .create({ title: `Terminal ${nextNumber}` }) + .create({ title: defaultTitle(nextNumber) }) .then((pty: { data?: { id?: string; title?: string } }) => { const id = pty.data?.id if (!id) return const newTerminal = { id, - title: pty.data?.title ?? "Terminal", + title: pty.data?.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } setStore("all", store.all.length, newTerminal) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 1b872082679..99a2d03d094 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -778,4 +778,77 @@ export const dict = { "common.time.daysAgo.short": "قبل {{count}} ي", "settings.providers.connected.environmentDescription": "متصل من متغيرات البيئة الخاصة بك", "settings.providers.custom.description": "أضف مزود متوافق مع OpenAI بواسطة عنوان URL الأساسي.", + + "app.server.unreachable": "تعذر الوصول إلى {{server}}", + "app.server.retrying": "جاري إعادة المحاولة تلقائيًا...", + "app.server.otherServers": "خوادم أخرى", + "dialog.server.add.usernamePlaceholder": "اسم المستخدم", + "dialog.server.add.passwordPlaceholder": "كلمة المرور", + "server.row.noUsername": "لا يوجد اسم مستخدم", + "session.review.noVcs.createGit.title": "إنشاء مستودع Git", + "session.review.noVcs.createGit.description": "تتبع ومراجعة والتراجع عن التغييرات في هذا المشروع", + "session.review.noVcs.createGit.actionLoading": "جاري إنشاء مستودع Git...", + "session.review.noVcs.createGit.action": "إنشاء مستودع Git", + "session.todo.progress": "تم إكمال {{done}} من {{total}} مهام", + "session.question.progress": "{{current}} من {{total}} أسئلة", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "مستكشف الملفات", + "session.header.open.fileManager": "مدير الملفات", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "المحطة الطرفية", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "تشخيص أداء التطوير", + "debugBar.na": "غير متاح", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "آخر انتقال مكتمل للمسار يمس صفحة جلسة، مُقاسًا من بدء التوجيه حتى أول رسم بعد استقراره.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "الإطارات المتجددة في الثانية خلال آخر 5 ثوانٍ.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "أسوأ وقت للإطار خلال آخر 5 ثوانٍ.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "الإطارات التي تزيد عن 32 مللي ثانية في آخر 5 ثوانٍ.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "الوقت المحظور وعدد المهام الطويلة في آخر 5 ثوانٍ. أقصى مهمة: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "أسوأ تأخير إدخال تمت ملاحظته في آخر 5 ثوانٍ.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "مدة التفاعل التقريبية خلال آخر 5 ثوانٍ. هذا يشبه INP، وليس Web Vitals INP الرسمي.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "التحول التخطيطي التراكمي لعمر التطبيق الحالي.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.", + "debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "غير معروف", + "error.page.circular": "[دائري]", + "error.globalSDK.noServerAvailable": "لا يوجد خادم متاح", + "error.globalSDK.serverNotAvailable": "الخادم غير متاح", + "error.childStore.persistedCacheCreateFailed": "فشل إنشاء ذاكرة التخزين المؤقت الدائمة", + "error.childStore.persistedProjectMetadataCreateFailed": "فشل إنشاء بيانات تعريف المشروع الدائمة", + "error.childStore.persistedProjectIconCreateFailed": "فشل إنشاء أيقونة المشروع الدائمة", + "error.childStore.storeCreateFailed": "فشل إنشاء المخزن", + "terminal.connectionLost.abnormalClose": "تم إغلاق WebSocket بشكل غير طبيعي: {{code}}", } diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 9e1f7f2a0e8..46ee7f114e1 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -788,4 +788,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}}d atrás", "settings.providers.connected.environmentDescription": "Conectado a partir de suas variáveis de ambiente", "settings.providers.custom.description": "Adicionar um provedor compatível com a OpenAI através do URL base.", + + "app.server.unreachable": "Não foi possível conectar a {{server}}", + "app.server.retrying": "Tentando novamente automaticamente...", + "app.server.otherServers": "Outros servidores", + "dialog.server.add.usernamePlaceholder": "nome de usuário", + "dialog.server.add.passwordPlaceholder": "senha", + "server.row.noUsername": "sem nome de usuário", + "session.review.noVcs.createGit.title": "Criar um repositório Git", + "session.review.noVcs.createGit.description": "Rastreie, revise e desfaça alterações neste projeto", + "session.review.noVcs.createGit.actionLoading": "Criando repositório Git...", + "session.review.noVcs.createGit.action": "Criar repositório Git", + "session.todo.progress": "{{done}} de {{total}} tarefas concluídas", + "session.question.progress": "{{current}} de {{total}} perguntas", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Explorador de Arquivos", + "session.header.open.fileManager": "Gerenciador de Arquivos", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnóstico de desempenho de desenvolvimento", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Última transição de rota concluída tocando em uma página de sessão, medida desde o início do roteador até a primeira pintura após o estabelecimento.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Quadros por segundo nos últimos 5 segundos.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Pior tempo de quadro nos últimos 5 segundos.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Quadros acima de 32ms nos últimos 5 segundos.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Tempo bloqueado e contagem de tarefas longas nos últimos 5 segundos. Tarefa máx: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Pior atraso de entrada observado nos últimos 5 segundos.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Duração aproximada da interação nos últimos 5 segundos. Isso é semelhante ao INP, não o INP oficial do Web Vitals.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Mudança cumulativa de layout para o tempo de vida atual do aplicativo.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.", + "debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Espaço", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "desconhecido", + "error.page.circular": "[Circular]", + "error.globalSDK.noServerAvailable": "Nenhum servidor disponível", + "error.globalSDK.serverNotAvailable": "Servidor indisponível", + "error.childStore.persistedCacheCreateFailed": "Falha ao criar cache persistente", + "error.childStore.persistedProjectMetadataCreateFailed": "Falha ao criar metadados de projeto persistentes", + "error.childStore.persistedProjectIconCreateFailed": "Falha ao criar ícone de projeto persistente", + "error.childStore.storeCreateFailed": "Falha ao criar armazenamento", + "terminal.connectionLost.abnormalClose": "WebSocket fechado anormalmente: {{code}}", } diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 3151c9b2213..140b838103b 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -864,4 +864,79 @@ export const dict = { "common.time.daysAgo.short": "prije {{count}} d", "settings.providers.connected.environmentDescription": "Povezano sa vašim varijablama okruženja", "settings.providers.custom.description": "Dodajte provajdera kompatibilnog s OpenAI putem osnovnog URL-a.", + + "app.server.unreachable": "Nije moguće pristupiti {{server}}", + "app.server.retrying": "Automatski ponovni pokušaj...", + "app.server.otherServers": "Drugi serveri", + "dialog.server.add.usernamePlaceholder": "korisničko ime", + "dialog.server.add.passwordPlaceholder": "lozinka", + "server.row.noUsername": "nema korisničkog imena", + "session.review.noVcs.createGit.title": "Kreiraj Git repozitorij", + "session.review.noVcs.createGit.description": "Pratite, pregledajte i poništite promjene u ovom projektu", + "session.review.noVcs.createGit.actionLoading": "Kreiranje Git repozitorija...", + "session.review.noVcs.createGit.action": "Kreiraj Git repozitorij", + "session.todo.progress": "{{done}} od {{total}} zadataka završeno", + "session.question.progress": "{{current}} od {{total}} pitanja", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "File Explorer", + "session.header.open.fileManager": "File Manager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Dijagnostika performansi razvoja", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Posljednji završeni prelazak rute koji dotiče stranicu sesije, mjeren od početka rutera do prvog iscrtavanja nakon smirivanja.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Kadrovi u sekundi tokom posljednjih 5 sekundi.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Najgore vrijeme kadra u posljednjih 5 sekundi.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Kadrovi duži od 32ms u posljednjih 5 sekundi.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blokirano vrijeme i broj dugih zadataka u posljednjih 5 sekundi. Maks zadatak: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Najgore zabilježeno kašnjenje unosa u posljednjih 5 sekundi.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Približno trajanje interakcije tokom posljednjih 5 sekundi. Ovo je slično INP-u, nije službeni Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulativni pomak rasporeda za trenutni životni vijek aplikacije.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.", + "debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "nepoznato", + "error.page.circular": "[Kružno]", + "error.globalSDK.noServerAvailable": "Nema dostupnog servera", + "error.globalSDK.serverNotAvailable": "Server nije dostupan", + "error.childStore.persistedCacheCreateFailed": "Nije uspjelo kreiranje trajnog keša", + "error.childStore.persistedProjectMetadataCreateFailed": "Nije uspjelo kreiranje trajnih metapodataka projekta", + "error.childStore.persistedProjectIconCreateFailed": "Nije uspjelo kreiranje trajne ikone projekta", + "error.childStore.storeCreateFailed": "Nije uspjelo kreiranje skladišta", + "terminal.connectionLost.abnormalClose": "WebSocket zatvoren nenormalno: {{code}}", } diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 8d9331ab68f..9b776c143e2 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -858,4 +858,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}}d siden", "settings.providers.connected.environmentDescription": "Tilsluttet fra dine miljøvariabler", "settings.providers.custom.description": "Tilføj en OpenAI-kompatibel udbyder via basis-URL.", + + "app.server.unreachable": "Kunne ikke nå {{server}}", + "app.server.retrying": "Prøver igen automatisk...", + "app.server.otherServers": "Andre servere", + "dialog.server.add.usernamePlaceholder": "brugernavn", + "dialog.server.add.passwordPlaceholder": "adgangskode", + "server.row.noUsername": "intet brugernavn", + "session.review.noVcs.createGit.title": "Opret et Git-repository", + "session.review.noVcs.createGit.description": "Spor, gennemgå og fortryd ændringer i dette projekt", + "session.review.noVcs.createGit.actionLoading": "Opretter Git-repository...", + "session.review.noVcs.createGit.action": "Opret Git-repository", + "session.todo.progress": "{{done}} af {{total}} opgaver fuldført", + "session.question.progress": "{{current}} af {{total}} spørgsmål", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Stifinder", + "session.header.open.fileManager": "Filhåndtering", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Udviklingsydelsesdiagnostik", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Sidste gennemførte ruteovergang, der berører en sessionsside, målt fra routerstart til den første optegning efter den falder til ro.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Rullende billeder pr. sekund over de sidste 5 sekunder.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Værste billedtid over de sidste 5 sekunder.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Billeder over 32ms i de sidste 5 sekunder.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blokeret tid og antal lange opgaver i de sidste 5 sekunder. Maks opgave: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Værste observerede inputforsinkelse i de sidste 5 sekunder.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Omtrentlig interaktionsvarighed over de sidste 5 sekunder. Dette er INP-lignende, ikke den officielle Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulativt layoutskift for den nuværende app-levetid.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Brugt JS-heap vs heap-grænse. Kun Chromium.", + "debugBar.mem.tip": "Brugt JS-heap vs heap-grænse. {{used}} af {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Mellemrum", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "ukendt", + "error.page.circular": "[Cirkulær]", + "error.globalSDK.noServerAvailable": "Ingen server tilgængelig", + "error.globalSDK.serverNotAvailable": "Server ikke tilgængelig", + "error.childStore.persistedCacheCreateFailed": "Kunne ikke oprette vedvarende cache", + "error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke oprette vedvarende projektmetadata", + "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon", + "error.childStore.storeCreateFailed": "Kunne ikke oprette lager", + "terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}", } diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 782b67262fc..5031748b46c 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -799,4 +799,80 @@ export const dict = { "common.time.daysAgo.short": "vor {{count}} Tg", "settings.providers.connected.environmentDescription": "Verbunden aus Ihren Umgebungsvariablen", "settings.providers.custom.description": "Fügen Sie einen OpenAI-kompatiblen Anbieter per Basis-URL hinzu.", + + "app.server.unreachable": "Konnte {{server}} nicht erreichen", + "app.server.retrying": "Automatische erneute Verbindung...", + "app.server.otherServers": "Andere Server", + "dialog.server.add.usernamePlaceholder": "Benutzername", + "dialog.server.add.passwordPlaceholder": "Passwort", + "server.row.noUsername": "Kein Benutzername", + "session.review.noVcs.createGit.title": "Git-Repository erstellen", + "session.review.noVcs.createGit.description": + "Änderungen in diesem Projekt verfolgen, überprüfen und rückgängig machen", + "session.review.noVcs.createGit.actionLoading": "Git-Repository wird erstellt...", + "session.review.noVcs.createGit.action": "Git-Repository erstellen", + "session.todo.progress": "{{done}} von {{total}} Aufgaben erledigt", + "session.question.progress": "{{current}} von {{total}} Fragen", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Datei-Explorer", + "session.header.open.fileManager": "Dateimanager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Entwicklungs-Leistungsdiagnose", + "debugBar.na": "n.v.", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Letzter abgeschlossener Routenübergang, der eine Sitzungsseite berührt, gemessen vom Start des Routers bis zum ersten Rendern nach dem Einschwingen.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Gleitende Bilder pro Sekunde in den letzten 5 Sekunden.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Schlechteste Frame-Zeit in den letzten 5 Sekunden.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Frames über 32ms in den letzten 5 Sekunden.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blockierte Zeit und Anzahl langer Aufgaben in den letzten 5 Sekunden. Max Aufgabe: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Schlechteste beobachtete Eingabeverzögerung in den letzten 5 Sekunden.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Ungefähre Interaktionsdauer in den letzten 5 Sekunden. Dies ist INP-ähnlich, nicht das offizielle Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulative Layoutverschiebung für die aktuelle App-Lebensdauer.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Verwendeter JS-Heap vs Heap-Limit. Nur Chromium.", + "debugBar.mem.tip": "Verwendeter JS-Heap vs Heap-Limit. {{used}} von {{limit}}.", + "common.key.ctrl": "Strg", + "common.key.alt": "Alt", + "common.key.shift": "Umschalt", + "common.key.meta": "Meta", + "common.key.space": "Leertaste", + "common.key.backspace": "Rücktaste", + "common.key.enter": "Eingabe", + "common.key.tab": "Tab", + "common.key.delete": "Entf", + "common.key.home": "Pos1", + "common.key.end": "Ende", + "common.key.pageUp": "Bild auf", + "common.key.pageDown": "Bild ab", + "common.key.insert": "Einfg", + "common.unknown": "unbekannt", + "error.page.circular": "[Zirkulär]", + "error.globalSDK.noServerAvailable": "Kein Server verfügbar", + "error.globalSDK.serverNotAvailable": "Server nicht verfügbar", + "error.childStore.persistedCacheCreateFailed": "Dauerhafter Cache konnte nicht erstellt werden", + "error.childStore.persistedProjectMetadataCreateFailed": "Dauerhafte Projektmetadaten konnten nicht erstellt werden", + "error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projekticon konnte nicht erstellt werden", + "error.childStore.storeCreateFailed": "Speicher konnte nicht erstellt werden", + "terminal.connectionLost.abnormalClose": "WebSocket abnormal geschlossen: {{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b950cab8d64..65e878b4e9b 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -306,6 +306,10 @@ export const dict = { "dialog.directory.search.placeholder": "Search folders", "dialog.directory.empty": "No folders found", + "app.server.unreachable": "Could not reach {{server}}", + "app.server.retrying": "Retrying automatically...", + "app.server.otherServers": "Other servers", + "dialog.server.title": "Servers", "dialog.server.description": "Switch which OpenCode server this app connects to.", "dialog.server.search.placeholder": "Search servers", @@ -319,7 +323,9 @@ export const dict = { "dialog.server.add.name": "Server name (optional)", "dialog.server.add.namePlaceholder": "Localhost", "dialog.server.add.username": "Username (optional)", + "dialog.server.add.usernamePlaceholder": "username", "dialog.server.add.password": "Password (optional)", + "dialog.server.add.passwordPlaceholder": "password", "dialog.server.edit.title": "Edit server", "dialog.server.default.title": "Default server", "dialog.server.default.description": @@ -335,6 +341,7 @@ export const dict = { "dialog.server.menu.delete": "Delete", "dialog.server.current": "Current Server", "dialog.server.status.default": "Default", + "server.row.noUsername": "no username", "dialog.project.edit.title": "Edit project", "dialog.project.edit.name": "Name", @@ -456,6 +463,7 @@ export const dict = { "error.page.action.checking": "Checking...", "error.page.action.checkUpdates": "Check for updates", "error.page.action.updateTo": "Update to {{version}}", + "error.page.circular": "[Circular]", "error.page.report.prefix": "Please report this error to the OpenCode team", "error.page.report.discord": "on Discord", "error.page.version": "Version: {{version}}", @@ -464,6 +472,12 @@ export const dict = { "Root element not found. Did you forget to add it to your index.html? Or maybe the id attribute got misspelled?", "error.globalSync.connectFailed": "Could not connect to server. Is there a server running at `{{url}}`?", + "error.globalSDK.noServerAvailable": "No server available", + "error.globalSDK.serverNotAvailable": "Server not available", + "error.childStore.persistedCacheCreateFailed": "Failed to create persisted cache", + "error.childStore.persistedProjectMetadataCreateFailed": "Failed to create persisted project metadata", + "error.childStore.persistedProjectIconCreateFailed": "Failed to create persisted project icon", + "error.childStore.storeCreateFailed": "Failed to create store", "directory.error.invalidUrl": "Invalid directory in URL.", "error.chain.unknown": "Unknown error", @@ -512,6 +526,10 @@ export const dict = { "session.review.loadingChanges": "Loading changes...", "session.review.empty": "No changes in this session yet", "session.review.noVcs": "No Git Version Control System detected, changes not displayed", + "session.review.noVcs.createGit.title": "Create a Git repository", + "session.review.noVcs.createGit.description": "Track, review, and undo changes in this project", + "session.review.noVcs.createGit.actionLoading": "Creating Git repository...", + "session.review.noVcs.createGit.action": "Create Git repository", "session.review.noSnapshot": "Snapshot tracking is disabled in config, so session changes are unavailable", "session.review.noChanges": "No changes", @@ -530,6 +548,8 @@ export const dict = { "session.todo.title": "Todos", "session.todo.collapse": "Collapse", "session.todo.expand": "Expand", + "session.todo.progress": "{{done}} of {{total}} todos completed", + "session.question.progress": "{{current}} of {{total}} questions", "session.followupDock.summary.one": "{{count}} queued message", "session.followupDock.summary.other": "{{count}} queued messages", "session.followupDock.sendNow": "Send now", @@ -555,6 +575,22 @@ export const dict = { "session.header.open.ariaLabel": "Open in {{app}}", "session.header.open.menu": "Open options", "session.header.open.copyPath": "Copy path", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "File Explorer", + "session.header.open.fileManager": "File Manager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", "status.popover.trigger": "Status", "status.popover.ariaLabel": "Server configurations", @@ -587,6 +623,7 @@ export const dict = { "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Close terminal", "terminal.connectionLost.title": "Connection Lost", + "terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}", "terminal.connectionLost.description": "The terminal connection was interrupted. This can happen when the server restarts.", @@ -604,6 +641,21 @@ export const dict = { "common.edit": "Edit", "common.loadMore": "Load more", "common.key.esc": "ESC", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "unknown", "common.time.justNow": "Just now", "common.time.minutesAgo.short": "{{count}}m ago", @@ -623,6 +675,30 @@ export const dict = { "sidebar.project.viewAllSessions": "View all sessions", "sidebar.project.clearNotifications": "Clear notifications", + "debugBar.ariaLabel": "Development performance diagnostics", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Last completed route transition touching a session page, measured from router start until the first paint after it settles.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Rolling frames per second over the last 5 seconds.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Worst frame time over the last 5 seconds.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Frames over 32ms in the last 5 seconds.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blocked time and long-task count in the last 5 seconds. Max task: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Worst observed input delay in the last 5 seconds.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Approximate interaction duration over the last 5 seconds. This is INP-like, not the official Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Cumulative layout shift for the current app lifetime.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.", + "debugBar.mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index a2633004cf7..2fabd6d4c8e 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -871,4 +871,79 @@ export const dict = { "common.time.daysAgo.short": "hace {{count}} d", "settings.providers.connected.environmentDescription": "Conectado desde tus variables de entorno", "settings.providers.custom.description": "Añade un proveedor compatible con OpenAI por su URL base.", + + "app.server.unreachable": "No se pudo conectar con {{server}}", + "app.server.retrying": "Reintentando automáticamente...", + "app.server.otherServers": "Otros servidores", + "dialog.server.add.usernamePlaceholder": "usuario", + "dialog.server.add.passwordPlaceholder": "contraseña", + "server.row.noUsername": "sin usuario", + "session.review.noVcs.createGit.title": "Crear repositorio Git", + "session.review.noVcs.createGit.description": "Rastrea, revisa y deshaz cambios en este proyecto", + "session.review.noVcs.createGit.actionLoading": "Creando repositorio Git...", + "session.review.noVcs.createGit.action": "Crear repositorio Git", + "session.todo.progress": "{{done}} de {{total}} tareas completadas", + "session.question.progress": "{{current}} de {{total}} preguntas", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Explorador de archivos", + "session.header.open.fileManager": "Gestor de archivos", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnóstico de rendimiento de desarrollo", + "debugBar.na": "n/d", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Última transición de ruta completada tocando una página de sesión, medida desde el inicio del router hasta el primer pintado después de asentarse.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Cuadros por segundo en los últimos 5 segundos.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Peor tiempo de cuadro en los últimos 5 segundos.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Cuadros superiores a 32ms en los últimos 5 segundos.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Tiempo bloqueado y recuento de tareas largas en los últimos 5 segundos. Tarea máx: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Peor retraso de entrada observado en los últimos 5 segundos.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Duración aproximada de la interacción en los últimos 5 segundos. Esto es similar a INP, no el INP oficial de Web Vitals.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Cambio de diseño acumulativo para la vida útil actual de la aplicación.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Heap JS usado vs límite de heap. Solo Chromium.", + "debugBar.mem.tip": "Heap JS usado vs límite de heap. {{used}} de {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Mayús", + "common.key.meta": "Meta", + "common.key.space": "Espacio", + "common.key.backspace": "Retroceso", + "common.key.enter": "Intro", + "common.key.tab": "Tab", + "common.key.delete": "Supr", + "common.key.home": "Inicio", + "common.key.end": "Fin", + "common.key.pageUp": "RePág", + "common.key.pageDown": "AvPág", + "common.key.insert": "Insert", + "common.unknown": "desconocido", + "error.page.circular": "[Circular]", + "error.globalSDK.noServerAvailable": "Ningún servidor disponible", + "error.globalSDK.serverNotAvailable": "Servidor no disponible", + "error.childStore.persistedCacheCreateFailed": "Error al crear caché persistente", + "error.childStore.persistedProjectMetadataCreateFailed": "Error al crear metadatos de proyecto persistentes", + "error.childStore.persistedProjectIconCreateFailed": "Error al crear icono de proyecto persistente", + "error.childStore.storeCreateFailed": "Error al crear almacén", + "terminal.connectionLost.abnormalClose": "WebSocket cerrado anormalmente: {{code}}", } diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index cbde19316dd..dc30a0e537a 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -796,4 +796,81 @@ export const dict = { "common.time.daysAgo.short": "il y a {{count}}j", "settings.providers.connected.environmentDescription": "Connecté à partir de vos variables d'environnement", "settings.providers.custom.description": "Ajouter un fournisseur compatible avec OpenAI via l'URL de base.", + + "app.server.unreachable": "Impossible de joindre {{server}}", + "app.server.retrying": "Nouvelle tentative automatique...", + "app.server.otherServers": "Autres serveurs", + "dialog.server.add.usernamePlaceholder": "nom d'utilisateur", + "dialog.server.add.passwordPlaceholder": "mot de passe", + "server.row.noUsername": "aucun nom d'utilisateur", + "session.review.noVcs.createGit.title": "Créer un dépôt Git", + "session.review.noVcs.createGit.description": "Suivre, examiner et annuler les modifications dans ce projet", + "session.review.noVcs.createGit.actionLoading": "Création du dépôt Git...", + "session.review.noVcs.createGit.action": "Créer un dépôt Git", + "session.todo.progress": "{{done}} tâches sur {{total}} terminées", + "session.question.progress": "{{current}} questions sur {{total}}", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Explorateur de fichiers", + "session.header.open.fileManager": "Gestionnaire de fichiers", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnostics de performance de développement", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Dernière transition de route terminée touchant une page de session, mesurée du début du routeur jusqu'au premier affichage après stabilisation.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Images par seconde glissantes sur les 5 dernières secondes.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Pire temps d'image sur les 5 dernières secondes.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Images de plus de 32ms au cours des 5 dernières secondes.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": + "Temps bloqué et nombre de tâches longues au cours des 5 dernières secondes. Tâche max : {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Pire délai d'entrée observé au cours des 5 dernières secondes.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Durée approximative d'interaction au cours des 5 dernières secondes. Ceci est similaire à INP, pas le INP officiel des Web Vitals.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Décalage cumulatif de la mise en page pour la durée de vie actuelle de l'application.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Tas JS utilisé vs limite de tas. Chromium uniquement.", + "debugBar.mem.tip": "Tas JS utilisé vs limite de tas. {{used}} sur {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Maj", + "common.key.meta": "Méta", + "common.key.space": "Espace", + "common.key.backspace": "Retour arrière", + "common.key.enter": "Entrée", + "common.key.tab": "Tab", + "common.key.delete": "Suppr", + "common.key.home": "Début", + "common.key.end": "Fin", + "common.key.pageUp": "Page précédente", + "common.key.pageDown": "Page suivante", + "common.key.insert": "Inser", + "common.unknown": "inconnu", + "error.page.circular": "[Circulaire]", + "error.globalSDK.noServerAvailable": "Aucun serveur disponible", + "error.globalSDK.serverNotAvailable": "Serveur non disponible", + "error.childStore.persistedCacheCreateFailed": "Échec de la création du cache persistant", + "error.childStore.persistedProjectMetadataCreateFailed": + "Échec de la création des métadonnées de projet persistantes", + "error.childStore.persistedProjectIconCreateFailed": "Échec de la création de l'icône de projet persistante", + "error.childStore.storeCreateFailed": "Échec de la création du stockage", + "terminal.connectionLost.abnormalClose": "WebSocket fermé anormalement : {{code}}", } diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 914ac5cd796..1f5615c9bd1 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -783,4 +783,78 @@ export const dict = { "common.time.daysAgo.short": "{{count}} 日前", "settings.providers.connected.environmentDescription": "環境変数から接続されました", "settings.providers.custom.description": "ベース URL を指定して OpenAI 互換のプロバイダーを追加します。", + + "app.server.unreachable": "{{server}} に到達できませんでした", + "app.server.retrying": "自動的に再試行中...", + "app.server.otherServers": "その他のサーバー", + "dialog.server.add.usernamePlaceholder": "ユーザー名", + "dialog.server.add.passwordPlaceholder": "パスワード", + "server.row.noUsername": "ユーザー名なし", + "session.review.noVcs.createGit.title": "Git リポジトリを作成", + "session.review.noVcs.createGit.description": "このプロジェクトの変更を追跡、レビュー、元に戻す", + "session.review.noVcs.createGit.actionLoading": "Git リポジトリを作成中...", + "session.review.noVcs.createGit.action": "Git リポジトリを作成", + "session.todo.progress": "{{done}} 個中 {{total}} 個の Todo が完了", + "session.question.progress": "{{total}} 問中 {{current}} 問", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "エクスプローラー", + "session.header.open.fileManager": "ファイルマネージャー", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "ターミナル", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "開発パフォーマンス診断", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "セッションページに触れる最後に完了したルート遷移。ルーター開始から安定後の最初の描画まで測定。", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "過去5秒間のローリングフレーム/秒。", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "過去5秒間の最悪フレーム時間。", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "過去5秒間で32msを超えたフレーム。", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "過去5秒間のブロック時間と長時間タスク数。最大タスク: {{max}}。", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "過去5秒間で観測された最悪の入力遅延。", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "過去5秒間の概算インタラクション時間。これは INP に似ていますが、公式の Web Vitals INP ではありません。", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "現在のアプリ寿命の累積レイアウトシフト。", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "使用中の JS ヒープ対ヒープ制限。Chromium のみ。", + "debugBar.mem.tip": "使用中の JS ヒープ対ヒープ制限。{{limit}} 中 {{used}}。", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "不明", + "error.page.circular": "[循環]", + "error.globalSDK.noServerAvailable": "利用可能なサーバーがありません", + "error.globalSDK.serverNotAvailable": "サーバーが利用できません", + "error.childStore.persistedCacheCreateFailed": "永続キャッシュの作成に失敗しました", + "error.childStore.persistedProjectMetadataCreateFailed": "永続プロジェクトメタデータの作成に失敗しました", + "error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました", + "error.childStore.storeCreateFailed": "ストアの作成に失敗しました", + "terminal.connectionLost.abnormalClose": "WebSocket が異常終了しました: {{code}}", } diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index f0a3f3ae6b2..a2f5e5c7c8b 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -782,4 +782,78 @@ export const dict = { "common.time.daysAgo.short": "{{count}}일 전", "settings.providers.connected.environmentDescription": "환경 변수에서 연결됨", "settings.providers.custom.description": "기본 URL로 OpenAI 호환 공급자를 추가합니다.", + + "app.server.unreachable": "{{server}}에 연결할 수 없습니다", + "app.server.retrying": "자동으로 재시도 중...", + "app.server.otherServers": "다른 서버", + "dialog.server.add.usernamePlaceholder": "사용자 이름", + "dialog.server.add.passwordPlaceholder": "비밀번호", + "server.row.noUsername": "사용자 이름 없음", + "session.review.noVcs.createGit.title": "Git 저장소 생성", + "session.review.noVcs.createGit.description": "이 프로젝트의 변경 사항을 추적, 검토 및 실행 취소", + "session.review.noVcs.createGit.actionLoading": "Git 저장소 생성 중...", + "session.review.noVcs.createGit.action": "Git 저장소 생성", + "session.todo.progress": "{{total}}개의 할 일 중 {{done}}개 완료", + "session.question.progress": "{{total}}개의 질문 중 {{current}}개", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "파일 탐색기", + "session.header.open.fileManager": "파일 관리자", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "터미널", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "개발 성능 진단", + "debugBar.na": "해당 없음", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "세션 페이지에 닿은 마지막 완료된 라우트 전환. 라우터 시작부터 정착 후 첫 번째 페인트까지 측정됨.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "지난 5초간의 초당 프레임 수.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "지난 5초간의 최악의 프레임 시간.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "지난 5초간 32ms를 초과한 프레임.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "지난 5초간의 차단된 시간 및 긴 작업 수. 최대 작업: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "지난 5초간 관찰된 최악의 입력 지연.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "지난 5초간의 대략적인 상호작용 지속 시간. 이것은 공식 Web Vitals INP가 아닌 INP와 유사합니다.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "현재 앱 수명 동안의 누적 레이아웃 이동.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "사용된 JS 힙 대 힙 제한. Chromium 전용.", + "debugBar.mem.tip": "사용된 JS 힙 대 힙 제한. {{limit}} 중 {{used}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "알 수 없음", + "error.page.circular": "[순환]", + "error.globalSDK.noServerAvailable": "사용 가능한 서버 없음", + "error.globalSDK.serverNotAvailable": "서버를 사용할 수 없음", + "error.childStore.persistedCacheCreateFailed": "영구 캐시 생성 실패", + "error.childStore.persistedProjectMetadataCreateFailed": "영구 프로젝트 메타데이터 생성 실패", + "error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패", + "error.childStore.storeCreateFailed": "저장소 생성 실패", + "terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}", } diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 17ba96058b5..ed75e556ea6 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -865,4 +865,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}} d siden", "settings.providers.connected.environmentDescription": "Koblet til fra miljøvariablene dine", "settings.providers.custom.description": "Legg til en OpenAI-kompatibel leverandør via basis-URL.", + + "app.server.unreachable": "Kunne ikke nå {{server}}", + "app.server.retrying": "Prøver på nytt automatisk...", + "app.server.otherServers": "Andre servere", + "dialog.server.add.usernamePlaceholder": "brukernavn", + "dialog.server.add.passwordPlaceholder": "passord", + "server.row.noUsername": "inget brukernavn", + "session.review.noVcs.createGit.title": "Opprett et Git-depot", + "session.review.noVcs.createGit.description": "Spor, gjennomgå og angre endringer i dette prosjektet", + "session.review.noVcs.createGit.actionLoading": "Oppretter Git-depot...", + "session.review.noVcs.createGit.action": "Opprett Git-depot", + "session.todo.progress": "{{done}} av {{total}} oppgaver fullført", + "session.question.progress": "{{current}} av {{total}} spørsmål", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Filutforsker", + "session.header.open.fileManager": "Filbehandler", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Utviklingsytelsesdiagnostikk", + "debugBar.na": "i/t", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Siste fullførte ruteovergang som berører en sesjonsside, målt fra ruterstart til første opptegning etter at den har roet seg.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Rullende bilder per sekund over de siste 5 sekundene.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Verste bildetid over de siste 5 sekundene.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Bilder over 32ms i de siste 5 sekundene.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Blokkert tid og antall lange oppgaver i de siste 5 sekundene. Maks oppgave: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Verste observerte inndataforsinkelse i de siste 5 sekundene.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Omtrentlig interaksjonsvarighet over de siste 5 sekundene. Dette er INP-lignende, ikke den offisielle Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Kumulativ layoutforskyvning for gjeldende app-levetid.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Brukt JS-heap vs heap-grense. Kun Chromium.", + "debugBar.mem.tip": "Brukt JS-heap vs heap-grense. {{used}} av {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Mellomrom", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "ukjent", + "error.page.circular": "[Sirkulær]", + "error.globalSDK.noServerAvailable": "Ingen server tilgjengelig", + "error.globalSDK.serverNotAvailable": "Server ikke tilgjengelig", + "error.childStore.persistedCacheCreateFailed": "Kunne ikke opprette vedvarende hurtigbuffer", + "error.childStore.persistedProjectMetadataCreateFailed": "Kunne ikke opprette vedvarende prosjektmetadata", + "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke opprette vedvarende prosjektikon", + "error.childStore.storeCreateFailed": "Kunne ikke opprette lager", + "terminal.connectionLost.abnormalClose": "WebSocket lukket unormalt: {{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 38111c8738b..2507acd9d2a 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -785,4 +785,80 @@ export const dict = { "common.time.daysAgo.short": "{{count}} dni temu", "settings.providers.connected.environmentDescription": "Połączono ze zmiennymi środowiskowymi", "settings.providers.custom.description": "Dodaj dostawcę zgodnego z OpenAI poprzez podstawowy URL.", + + "app.server.unreachable": "Nie można połączyć z {{server}}", + "app.server.retrying": "Ponawianie automatycznie...", + "app.server.otherServers": "Inne serwery", + "dialog.server.add.usernamePlaceholder": "nazwa użytkownika", + "dialog.server.add.passwordPlaceholder": "hasło", + "server.row.noUsername": "brak nazwy użytkownika", + "session.review.noVcs.createGit.title": "Utwórz repozytorium Git", + "session.review.noVcs.createGit.description": "Śledź, przeglądaj i cofaj zmiany w tym projekcie", + "session.review.noVcs.createGit.actionLoading": "Tworzenie repozytorium Git...", + "session.review.noVcs.createGit.action": "Utwórz repozytorium Git", + "session.todo.progress": "Ukończono {{done}} z {{total}} zadań", + "session.question.progress": "{{current}} z {{total}} pytań", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Eksplorator plików", + "session.header.open.fileManager": "Menedżer plików", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Diagnostyka wydajności deweloperskiej", + "debugBar.na": "n.d.", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Ostatnie zakończone przejście trasy dotykające strony sesji, mierzone od startu routera do pierwszego odrysowania po ustaleniu.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Średnia liczba klatek na sekundę w ciągu ostatnich 5 sekund.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Najgorszy czas klatki w ciągu ostatnich 5 sekund.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Klatki powyżej 32ms w ciągu ostatnich 5 sekund.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": + "Zablokowany czas i liczba długich zadań w ciągu ostatnich 5 sekund. Maksymalne zadanie: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Najgorsze zaobserwowane opóźnienie wejścia w ciągu ostatnich 5 sekund.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Przybliżony czas trwania interakcji w ciągu ostatnich 5 sekund. Jest to podobne do INP, a nie oficjalne Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Skumulowane przesunięcie układu dla bieżącego czasu życia aplikacji.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Użyta sterta JS vs limit sterty. Tylko Chromium.", + "debugBar.mem.tip": "Użyta sterta JS vs limit sterty. {{used}} z {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Spacja", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "nieznany", + "error.page.circular": "[Cykliczne]", + "error.globalSDK.noServerAvailable": "Brak dostępnego serwera", + "error.globalSDK.serverNotAvailable": "Serwer niedostępny", + "error.childStore.persistedCacheCreateFailed": "Nie udało się utworzyć trwałej pamięci podręcznej", + "error.childStore.persistedProjectMetadataCreateFailed": "Nie udało się utworzyć trwałych metadanych projektu", + "error.childStore.persistedProjectIconCreateFailed": "Nie udało się utworzyć trwałej ikony projektu", + "error.childStore.storeCreateFailed": "Nie udało się utworzyć magazynu", + "terminal.connectionLost.abnormalClose": "WebSocket zamknięty nieprawidłowo: {{code}}", } diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 0b63e842273..6145b3011b3 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -867,4 +867,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}} д назад", "settings.providers.connected.environmentDescription": "Подключено из ваших переменных окружения", "settings.providers.custom.description": "Добавить провайдера, совместимого с OpenAI, по базовому URL.", + + "app.server.unreachable": "Не удалось связаться с {{server}}", + "app.server.retrying": "Автоматическая повторная попытка...", + "app.server.otherServers": "Другие серверы", + "dialog.server.add.usernamePlaceholder": "имя пользователя", + "dialog.server.add.passwordPlaceholder": "пароль", + "server.row.noUsername": "нет имени пользователя", + "session.review.noVcs.createGit.title": "Создать репозиторий Git", + "session.review.noVcs.createGit.description": "Отслеживайте, просматривайте и отменяйте изменения в этом проекте", + "session.review.noVcs.createGit.actionLoading": "Создание репозитория Git...", + "session.review.noVcs.createGit.action": "Создать репозиторий Git", + "session.todo.progress": "Выполнено {{done}} из {{total}} задач", + "session.question.progress": "{{current}} из {{total}} вопросов", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Проводник", + "session.header.open.fileManager": "Файловый менеджер", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Терминал", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Диагностика производительности разработки", + "debugBar.na": "н/д", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Последний завершенный переход маршрута, затрагивающий страницу сеанса, измеренный от запуска маршрутизатора до первой отрисовки после стабилизации.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Скользящая частота кадров в секунду за последние 5 секунд.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Худшее время кадра за последние 5 секунд.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Кадры более 32 мс за последние 5 секунд.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Заблокированное время и количество длинных задач за последние 5 секунд. Макс. задача: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Худшая наблюдаемая задержка ввода за последние 5 секунд.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "Приблизительная продолжительность взаимодействия за последние 5 секунд. Это похоже на INP, а не официальный Web Vitals INP.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Кумулятивный сдвиг макета за текущее время жизни приложения.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Используемая куча JS по сравнению с лимитом кучи. Только Chromium.", + "debugBar.mem.tip": "Используемая куча JS по сравнению с лимитом кучи. {{used}} из {{limit}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Пробел", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "неизвестно", + "error.page.circular": "[Циклично]", + "error.globalSDK.noServerAvailable": "Нет доступного сервера", + "error.globalSDK.serverNotAvailable": "Сервер недоступен", + "error.childStore.persistedCacheCreateFailed": "Не удалось создать постоянный кэш", + "error.childStore.persistedProjectMetadataCreateFailed": "Не удалось создать постоянные метаданные проекта", + "error.childStore.persistedProjectIconCreateFailed": "Не удалось создать постоянный значок проекта", + "error.childStore.storeCreateFailed": "Не удалось создать хранилище", + "terminal.connectionLost.abnormalClose": "WebSocket закрыт аварийно: {{code}}", } diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 94bb2c2c318..9cc3c5be1da 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -854,4 +854,79 @@ export const dict = { "common.time.daysAgo.short": "{{count}} วันที่แล้ว", "settings.providers.connected.environmentDescription": "เชื่อมต่อจากตัวแปรสภาพแวดล้อมของคุณ", "settings.providers.custom.description": "เพิ่มผู้ให้บริการที่รองรับ OpenAI ด้วย URL หลัก", + + "app.server.unreachable": "ไม่สามารถติดต่อ {{server}}", + "app.server.retrying": "กำลังลองใหม่โดยอัตโนมัติ...", + "app.server.otherServers": "เซิร์ฟเวอร์อื่น ๆ", + "dialog.server.add.usernamePlaceholder": "ชื่อผู้ใช้", + "dialog.server.add.passwordPlaceholder": "รหัสผ่าน", + "server.row.noUsername": "ไม่มีชื่อผู้ใช้", + "session.review.noVcs.createGit.title": "สร้าง Git repository", + "session.review.noVcs.createGit.description": "ติดตาม ตรวจสอบ และเลิกทำสิ่งเปลี่ยนแปลงในโปรเจกต์นี้", + "session.review.noVcs.createGit.actionLoading": "กำลังสร้าง Git repository...", + "session.review.noVcs.createGit.action": "สร้าง Git repository", + "session.todo.progress": "เสร็จสิ้น {{done}} จาก {{total}} รายการ", + "session.question.progress": "{{current}} จาก {{total}} คำถาม", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "File Explorer", + "session.header.open.fileManager": "File Manager", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "การวินิจฉัยประสิทธิภาพการพัฒนา", + "debugBar.na": "n/a", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "การเปลี่ยนเส้นทางที่เสร็จสมบูรณ์ล่าสุดที่สัมผัสหน้าเซสชัน วัดจากจุดเริ่มต้นเราเตอร์จนถึงการวาดครั้งแรกหลังจากที่นิ่ง", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "เฟรมต่อวินาทีแบบต่อเนื่องในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "เวลาเฟรมที่แย่ที่สุดในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "เฟรมที่เกิน 32ms ในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "เวลาที่ถูกบล็อกและจำนวนงานยาวในช่วง 5 วินาทีที่ผ่านมา งานสูงสุด: {{max}}", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "ความล่าช้าในการป้อนข้อมูลที่แย่ที่สุดที่สังเกตได้ในช่วง 5 วินาทีที่ผ่านมา", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": + "ระยะเวลาการโต้ตอบโดยประมาณในช่วง 5 วินาทีที่ผ่านมา นี่เป็นเหมือน INP ไม่ใช่ Web Vitals INP อย่างเป็นทางการ", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "การเลื่อนเลย์เอาต์สะสมสำหรับอายุการใช้งานของแอปปัจจุบัน", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "JS heap ที่ใช้เทียบกับขีดจำกัด heap เฉพาะ Chromium", + "debugBar.mem.tip": "JS heap ที่ใช้เทียบกับขีดจำกัด heap {{used}} จาก {{limit}}", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Space", + "common.key.backspace": "Backspace", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "ไม่ทราบ", + "error.page.circular": "[วงกลม]", + "error.globalSDK.noServerAvailable": "ไม่มีเซิร์ฟเวอร์", + "error.globalSDK.serverNotAvailable": "เซิร์ฟเวอร์ไม่พร้อมใช้งาน", + "error.childStore.persistedCacheCreateFailed": "ไม่สามารถสร้างแคชถาวร", + "error.childStore.persistedProjectMetadataCreateFailed": "ไม่สามารถสร้างเมตาดาต้าโปรเจกต์ถาวร", + "error.childStore.persistedProjectIconCreateFailed": "ไม่สามารถสร้างไอคอนโปรเจกต์ถาวร", + "error.childStore.storeCreateFailed": "ไม่สามารถสร้างที่เก็บ", + "terminal.connectionLost.abnormalClose": "WebSocket ปิดอย่างผิดปกติ: {{code}}", } diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 66835c1c574..373f26ad6fb 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -874,4 +874,78 @@ export const dict = { "common.time.daysAgo.short": "{{count}}g önce", "settings.providers.connected.environmentDescription": "Ortam değişkenlerinizden bağlandı", "settings.providers.custom.description": "Temel URL üzerinden OpenAI uyumlu bir sağlayıcı ekleyin.", + + "app.server.unreachable": "{{server}} sunucusuna ulaşılamadı", + "app.server.retrying": "Otomatik olarak tekrar deneniyor...", + "app.server.otherServers": "Diğer sunucular", + "dialog.server.add.usernamePlaceholder": "kullanıcı adı", + "dialog.server.add.passwordPlaceholder": "parola", + "server.row.noUsername": "kullanıcı adı yok", + "session.review.noVcs.createGit.title": "Git deposu oluştur", + "session.review.noVcs.createGit.description": "Bu projedeki değişiklikleri takip et, incele ve geri al", + "session.review.noVcs.createGit.actionLoading": "Git deposu oluşturuluyor...", + "session.review.noVcs.createGit.action": "Git deposu oluştur", + "session.todo.progress": "{{total}} görevin {{done}} tanesi tamamlandı", + "session.question.progress": "{{total}} sorunun {{current}} tanesi", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "Dosya Gezgini", + "session.header.open.fileManager": "Dosya Yöneticisi", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "Terminal", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "Geliştirme performansı teşhisi", + "debugBar.na": "yok", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": + "Yönlendirici başlangıcından yerleşme sonrası ilk boyamaya kadar ölçülen, bir oturum sayfasına dokunan son tamamlanmış rota geçişi.", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "Son 5 saniyedeki kayan saniye başına kare sayısı.", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "Son 5 saniyedeki en kötü kare süresi.", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "Son 5 saniyede 32ms üzerindeki kareler.", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "Son 5 saniyedeki engellenen süre ve uzun görev sayısı. Maksimum görev: {{max}}.", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "Son 5 saniyede gözlemlenen en kötü giriş gecikmesi.", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "Son 5 saniyedeki yaklaşık etkileşim süresi. Bu INP benzeridir, resmi Web Vitals INP değildir.", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "Mevcut uygulama ömrü için kümülatif düzen kayması.", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "Kullanılan JS yığını vs yığın sınırı. Yalnızca Chromium.", + "debugBar.mem.tip": "Kullanılan JS yığını vs yığın sınırı. {{limit}} içinde {{used}}.", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "Boşluk", + "common.key.backspace": "Geri", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "bilinmiyor", + "error.page.circular": "[Döngüsel]", + "error.globalSDK.noServerAvailable": "Sunucu yok", + "error.globalSDK.serverNotAvailable": "Sunucu mevcut değil", + "error.childStore.persistedCacheCreateFailed": "Kalıcı önbellek oluşturulamadı", + "error.childStore.persistedProjectMetadataCreateFailed": "Kalıcı proje meta verileri oluşturulamadı", + "error.childStore.persistedProjectIconCreateFailed": "Kalıcı proje simgesi oluşturulamadı", + "error.childStore.storeCreateFailed": "Depo oluşturulamadı", + "terminal.connectionLost.abnormalClose": "WebSocket anormal şekilde kapandı: {{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index d6b179c5c50..819e1cd87d7 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -853,4 +853,77 @@ export const dict = { "common.time.daysAgo.short": "{{count}}天前", "settings.providers.connected.environmentDescription": "已通过环境变量连接", "settings.providers.custom.description": "通过基础 URL 添加与 OpenAI 兼容的提供商。", + + "app.server.unreachable": "无法连接到 {{server}}", + "app.server.retrying": "正在自动重试...", + "app.server.otherServers": "其他服务器", + "dialog.server.add.usernamePlaceholder": "用户名", + "dialog.server.add.passwordPlaceholder": "密码", + "server.row.noUsername": "无用户名", + "session.review.noVcs.createGit.title": "创建 Git 仓库", + "session.review.noVcs.createGit.description": "在此项目中跟踪、审查和撤消更改", + "session.review.noVcs.createGit.actionLoading": "正在创建 Git 仓库...", + "session.review.noVcs.createGit.action": "创建 Git 仓库", + "session.todo.progress": "已完成 {{done}} 个任务(共 {{total}} 个)", + "session.question.progress": "{{current}}/{{total}} 个问题", + "session.header.open.finder": "访达", + "session.header.open.fileExplorer": "文件资源管理器", + "session.header.open.fileManager": "文件管理器", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "终端", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "开发性能诊断", + "debugBar.na": "不适用", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "最后一次完成的涉及会话页面的路由转换,从路由器启动到稳定后的第一次绘制。", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "过去 5 秒内的滚动帧率。", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "过去 5 秒内最差的帧时间。", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "过去 5 秒内超过 32ms 的帧。", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "过去 5 秒内的阻塞时间和长任务计数。最大任务:{{max}}。", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "过去 5 秒内观察到的最差输入延迟。", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "过去 5 秒内的近似交互持续时间。这类似于 INP,而非官方的 Web Vitals INP。", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "当前应用生命周期的累积布局偏移。", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "使用的 JS 堆与堆限制。仅限 Chromium。", + "debugBar.mem.tip": "使用的 JS 堆与堆限制。{{used}} / {{limit}}。", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "空格", + "common.key.backspace": "退格", + "common.key.enter": "回车", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "未知", + "error.page.circular": "[循环]", + "error.globalSDK.noServerAvailable": "无可用服务器", + "error.globalSDK.serverNotAvailable": "服务器不可用", + "error.childStore.persistedCacheCreateFailed": "创建持久化缓存失败", + "error.childStore.persistedProjectMetadataCreateFailed": "创建持久化项目元数据失败", + "error.childStore.persistedProjectIconCreateFailed": "创建持久化项目图标失败", + "error.childStore.storeCreateFailed": "创建存储失败", + "terminal.connectionLost.abnormalClose": "WebSocket 异常关闭:{{code}}", } satisfies Partial> diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 3796350d2c4..8c80cd3235e 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -848,4 +848,77 @@ export const dict = { "common.time.daysAgo.short": "{{count}}天前", "settings.providers.connected.environmentDescription": "已從環境變數連線", "settings.providers.custom.description": "透過基本 URL 新增與 OpenAI 相容的提供者。", + + "app.server.unreachable": "無法連線至 {{server}}", + "app.server.retrying": "正在自動重試...", + "app.server.otherServers": "其他伺服器", + "dialog.server.add.usernamePlaceholder": "使用者名稱", + "dialog.server.add.passwordPlaceholder": "密碼", + "server.row.noUsername": "無使用者名稱", + "session.review.noVcs.createGit.title": "建立 Git 儲存庫", + "session.review.noVcs.createGit.description": "追蹤、檢閱及復原此專案中的變更", + "session.review.noVcs.createGit.actionLoading": "正在建立 Git 儲存庫...", + "session.review.noVcs.createGit.action": "建立 Git 儲存庫", + "session.todo.progress": "已完成 {{done}} 個待辦事項(共 {{total}} 個)", + "session.question.progress": "{{current}}/{{total}} 個問題", + "session.header.open.finder": "Finder", + "session.header.open.fileExplorer": "檔案總管", + "session.header.open.fileManager": "檔案管理員", + "session.header.open.app.vscode": "VS Code", + "session.header.open.app.cursor": "Cursor", + "session.header.open.app.zed": "Zed", + "session.header.open.app.textmate": "TextMate", + "session.header.open.app.antigravity": "Antigravity", + "session.header.open.app.terminal": "終端機", + "session.header.open.app.iterm2": "iTerm2", + "session.header.open.app.ghostty": "Ghostty", + "session.header.open.app.warp": "Warp", + "session.header.open.app.xcode": "Xcode", + "session.header.open.app.androidStudio": "Android Studio", + "session.header.open.app.powershell": "PowerShell", + "session.header.open.app.sublimeText": "Sublime Text", + "debugBar.ariaLabel": "開發效能診斷", + "debugBar.na": "不適用", + "debugBar.nav.label": "NAV", + "debugBar.nav.tip": "最後一次完成的涉及工作階段頁面的路由轉換,從路由器啟動到穩定後的第一次繪製。", + "debugBar.fps.label": "FPS", + "debugBar.fps.tip": "過去 5 秒內的滾動幀率。", + "debugBar.frame.label": "FRAME", + "debugBar.frame.tip": "過去 5 秒內最差的幀時間。", + "debugBar.jank.label": "JANK", + "debugBar.jank.tip": "過去 5 秒內超過 32ms 的幀。", + "debugBar.long.label": "LONG", + "debugBar.long.tip": "過去 5 秒內的阻塞時間和長任務計數。最大任務:{{max}}。", + "debugBar.delay.label": "DELAY", + "debugBar.delay.tip": "過去 5 秒內觀察到的最差輸入延遲。", + "debugBar.inp.label": "INP", + "debugBar.inp.tip": "過去 5 秒內的近似互動持續時間。這類似於 INP,而非官方的 Web Vitals INP。", + "debugBar.cls.label": "CLS", + "debugBar.cls.tip": "目前應用程式生命週期的累積版面配置位移。", + "debugBar.mem.label": "MEM", + "debugBar.mem.tipUnavailable": "使用的 JS 堆積與堆積限制。僅限 Chromium。", + "debugBar.mem.tip": "使用的 JS 堆積與堆積限制。{{used}} / {{limit}}。", + "common.key.ctrl": "Ctrl", + "common.key.alt": "Alt", + "common.key.shift": "Shift", + "common.key.meta": "Meta", + "common.key.space": "空白鍵", + "common.key.backspace": "退格鍵", + "common.key.enter": "Enter", + "common.key.tab": "Tab", + "common.key.delete": "Delete", + "common.key.home": "Home", + "common.key.end": "End", + "common.key.pageUp": "Page Up", + "common.key.pageDown": "Page Down", + "common.key.insert": "Insert", + "common.unknown": "未知", + "error.page.circular": "[循環]", + "error.globalSDK.noServerAvailable": "無可用的伺服器", + "error.globalSDK.serverNotAvailable": "伺服器無法使用", + "error.childStore.persistedCacheCreateFailed": "建立持續性快取失敗", + "error.childStore.persistedProjectMetadataCreateFailed": "建立持續性專案中繼資料失敗", + "error.childStore.persistedProjectIconCreateFailed": "建立持續性專案圖示失敗", + "error.childStore.storeCreateFailed": "建立儲存區失敗", + "terminal.connectionLost.abnormalClose": "WebSocket 異常關閉:{{code}}", } satisfies Partial> diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index a30d86d1809..11284b3d2d7 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -35,14 +35,14 @@ function isInitError(error: unknown): error is InitError { ) } -function safeJson(value: unknown): string { +function safeJson(value: unknown, circular: string): string { const seen = new WeakSet() const json = JSON.stringify( value, (_key, val) => { if (typeof val === "bigint") return val.toString() if (typeof val === "object" && val) { - if (seen.has(val)) return "[Circular]" + if (seen.has(val)) return circular seen.add(val) } return val @@ -54,14 +54,15 @@ function safeJson(value: unknown): string { function formatInitError(error: InitError, t: Translator): string { const data = error.data + const json = (value: unknown) => safeJson(value, t("error.page.circular")) switch (error.name) { case "MCPFailed": { const name = typeof data.name === "string" ? data.name : "" return t("error.chain.mcpFailed", { name }) } case "ProviderAuthError": { - const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" - const message = typeof data.message === "string" ? data.message : safeJson(data.message) + const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown") + const message = typeof data.message === "string" ? data.message : json(data.message) return t("error.chain.providerAuthFailed", { provider: providerID, message }) } case "APIError": { @@ -101,24 +102,24 @@ function formatInitError(error: InitError, t: Translator): string { ].join("\n") } case "ProviderInitError": { - const providerID = typeof data.providerID === "string" ? data.providerID : "unknown" + const providerID = typeof data.providerID === "string" ? data.providerID : t("common.unknown") return t("error.chain.providerInitFailed", { provider: providerID }) } case "ConfigJsonError": { - const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const path = typeof data.path === "string" ? data.path : json(data.path) const message = typeof data.message === "string" ? data.message : "" if (message) return t("error.chain.configJsonInvalidWithMessage", { path, message }) return t("error.chain.configJsonInvalid", { path }) } case "ConfigDirectoryTypoError": { - const path = typeof data.path === "string" ? data.path : safeJson(data.path) - const dir = typeof data.dir === "string" ? data.dir : safeJson(data.dir) - const suggestion = typeof data.suggestion === "string" ? data.suggestion : safeJson(data.suggestion) + const path = typeof data.path === "string" ? data.path : json(data.path) + const dir = typeof data.dir === "string" ? data.dir : json(data.dir) + const suggestion = typeof data.suggestion === "string" ? data.suggestion : json(data.suggestion) return t("error.chain.configDirectoryTypo", { dir, path, suggestion }) } case "ConfigFrontmatterError": { - const path = typeof data.path === "string" ? data.path : safeJson(data.path) - const message = typeof data.message === "string" ? data.message : safeJson(data.message) + const path = typeof data.path === "string" ? data.path : json(data.path) + const message = typeof data.message === "string" ? data.message : json(data.message) return t("error.chain.configFrontmatterError", { path, message }) } case "ConfigInvalidError": { @@ -126,7 +127,7 @@ function formatInitError(error: InitError, t: Translator): string { ? data.issues.filter(isIssue).map((issue) => "↳ " + issue.message + " " + issue.path.join(".")) : [] const message = typeof data.message === "string" ? data.message : "" - const path = typeof data.path === "string" ? data.path : safeJson(data.path) + const path = typeof data.path === "string" ? data.path : json(data.path) const line = message ? t("error.chain.configInvalidWithMessage", { path, message }) @@ -135,14 +136,15 @@ function formatInitError(error: InitError, t: Translator): string { return [line, ...issues].join("\n") } case "UnknownError": - return typeof data.message === "string" ? data.message : safeJson(data) + return typeof data.message === "string" ? data.message : json(data) default: if (typeof data.message === "string") return data.message - return safeJson(data) + return json(data) } } function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessage?: string): string { + const json = (value: unknown) => safeJson(value, t("error.page.circular")) if (!error) return t("error.chain.unknown") if (isInitError(error)) { @@ -204,7 +206,7 @@ function formatErrorChain(error: unknown, t: Translator, depth = 0, parentMessag } const indent = depth > 0 ? `\n${CHAIN_SEPARATOR}${t("error.chain.causedBy")}\n` : "" - return indent + safeJson(error) + return indent + json(error) } function formatError(error: unknown, t: Translator): string { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c1088622a26..bc04f9ecf27 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2159,7 +2159,7 @@ export default function Layout(props: ParentProps) { {language.t("command.provider.connect")} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 57ef1853d15..d917ce4c7aa 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -956,13 +956,15 @@ export default function Page() { return (
-
Create a Git repository
+
{language.t("session.review.noVcs.createGit.title")}
- Track, review, and undo changes in this project + {language.t("session.review.noVcs.createGit.description")}
) diff --git a/packages/app/src/pages/session/composer/session-composer-region.tsx b/packages/app/src/pages/session/composer/session-composer-region.tsx index 84f77ea4a3c..a5263cd743e 100644 --- a/packages/app/src/pages/session/composer/session-composer-region.tsx +++ b/packages/app/src/pages/session/composer/session-composer-region.tsx @@ -196,7 +196,6 @@ export function SessionComposerRegion(props: { { const n = Math.min(store.tab + 1, total()) - return `${n} of ${total()} questions` + return language.t("session.question.progress", { current: n, total: total() }) }) const last = createMemo(() => store.tab >= total() - 1) diff --git a/packages/app/src/pages/session/composer/session-todo-dock.tsx b/packages/app/src/pages/session/composer/session-todo-dock.tsx index 5500de97a49..2cd660b39f4 100644 --- a/packages/app/src/pages/session/composer/session-todo-dock.tsx +++ b/packages/app/src/pages/session/composer/session-todo-dock.tsx @@ -9,6 +9,10 @@ import { TextStrikethrough } from "@opencode-ai/ui/text-strikethrough" import { Index, createEffect, createMemo, on, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { composerEnabled, composerProbe } from "@/testing/session-composer" +import { useLanguage } from "@/context/language" + +const doneToken = "\u0000done\u0000" +const totalToken = "\u0000total\u0000" function dot(status: Todo["status"]) { if (status !== "in_progress") return undefined @@ -38,11 +42,11 @@ function dot(status: Todo["status"]) { export function SessionTodoDock(props: { sessionID?: string todos: Todo[] - title: string collapseLabel: string expandLabel: string dockProgress: number }) { + const language = useLanguage() const [store, setStore] = createStore({ collapsed: false, height: 320, @@ -52,7 +56,12 @@ export function SessionTodoDock(props: { const total = createMemo(() => props.todos.length) const done = createMemo(() => props.todos.filter((todo) => todo.status === "completed").length) - const label = createMemo(() => `${done()} of ${total()} ${props.title.toLowerCase()} completed`) + const label = createMemo(() => language.t("session.todo.progress", { done: done(), total: total() })) + const progress = createMemo(() => + language + .t("session.todo.progress", { done: doneToken, total: totalToken }) + .split(/(\u0000done\u0000|\u0000total\u0000)/), + ) const active = createMemo( () => @@ -137,10 +146,17 @@ export function SessionTodoDock(props: { opacity: `${Math.max(0, Math.min(1, 1 - shut()))}`, }} > - - of - -  {props.title.toLowerCase()} completed + + {(item) => + item() === doneToken ? ( + + ) : item() === totalToken ? ( + + ) : ( + {item()} + ) + } +
{ const title = input.title ?? "" const number = input.titleNumber ?? 0 - const match = title.match(/^Terminal (\d+)$/) - const parsed = match ? Number(match[1]) : undefined - const isDefaultTitle = Number.isFinite(number) && number > 0 && Number.isFinite(parsed) && parsed === number + const isDefaultTitle = Number.isFinite(number) && number > 0 && isDefaultTerminalTitle(title, number) if (title && !isDefaultTitle) return title if (number > 0) return input.t("terminal.title.numbered", { number }) diff --git a/packages/ui/src/components/basic-tool.tsx b/packages/ui/src/components/basic-tool.tsx index 3f009f4e0fb..0b2c1e1ce4f 100644 --- a/packages/ui/src/components/basic-tool.tsx +++ b/packages/ui/src/components/basic-tool.tsx @@ -1,5 +1,6 @@ import { createEffect, For, Match, on, onCleanup, Show, Switch, type JSX } from "solid-js" import { animate, type AnimationPlaybackControls } from "motion" +import { useI18n } from "../context/i18n" import { createStore } from "solid-js/store" import { Collapsible } from "./collapsible" import type { IconProps } from "./icon" @@ -233,12 +234,14 @@ export function GenericTool(props: { hideDetails?: boolean input?: Record }) { + const i18n = useI18n() + return ( void onNext: () => void }) { + const i18n = useI18n() + return (
props.onInput(e.currentTarget.value)} @@ -40,7 +43,7 @@ export function FileSearchBar(props: { type="button" class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" disabled={props.count() === 0} - aria-label="Previous match" + aria-label={i18n.t("ui.fileSearch.previousMatch")} onClick={props.onPrev} > @@ -49,7 +52,7 @@ export function FileSearchBar(props: { type="button" class="size-6 grid place-items-center rounded text-text-weak hover:bg-surface-base-hover hover:text-text-strong disabled:opacity-40 disabled:pointer-events-none" disabled={props.count() === 0} - aria-label="Next match" + aria-label={i18n.t("ui.fileSearch.nextMatch")} onClick={props.onNext} > @@ -58,7 +61,7 @@ export function FileSearchBar(props: { diff --git a/packages/app/src/pages/layout/sidebar-items.tsx b/packages/app/src/pages/layout/sidebar-items.tsx index 17572923eb8..04d898134cf 100644 --- a/packages/app/src/pages/layout/sidebar-items.tsx +++ b/packages/app/src/pages/layout/sidebar-items.tsx @@ -9,7 +9,8 @@ import { Tooltip } from "@opencode-ai/ui/tooltip" import { base64Encode } from "@opencode-ai/util/encode" import { getFilename } from "@opencode-ai/util/path" import { A, useNavigate, useParams } from "@solidjs/router" -import { type Accessor, createMemo, For, type JSX, Match, onCleanup, Show, Switch } from "solid-js" +import { type Accessor, createEffect, createMemo, For, type JSX, on, onCleanup, Show } from "solid-js" +import { createStore } from "solid-js/store" import { useGlobalSync } from "@/context/global-sync" import { useLanguage } from "@/context/language" import { getAvatarColors, type LocalProject, useLayout } from "@/context/layout" @@ -101,46 +102,94 @@ const SessionRow = (props: { warmPress: () => void warmFocus: () => void cancelHoverPrefetch: () => void -}): JSX.Element => ( - { - props.setHoverSession(undefined) - if (props.sidebarOpened()) return - props.clearHoverProjectSoon() - }} - > -
-
- }> - - - - -
- - -
- - 0}> -
- - +}): JSX.Element => { + const [slot, setSlot] = createStore({ + open: false, + show: false, + fade: false, + }) + + let f: number | undefined + const clear = () => { + if (f !== undefined) window.clearTimeout(f) + f = undefined + } + + onCleanup(clear) + createEffect( + on( + () => props.isWorking(), + (on, prev) => { + clear() + if (on) { + setSlot({ open: true, show: true, fade: false }) + return + } + if (prev) { + setSlot({ open: false, show: true, fade: true }) + f = window.setTimeout(() => setSlot({ show: false, fade: false }), 260) + return + } + setSlot({ open: false, show: false, fade: false }) + }, + { defer: true }, + ), + ) + + return ( + { + props.setHoverSession(undefined) + if (props.sidebarOpened()) return + props.clearHoverProjectSoon() + }} + > + 0)}> +
0, + }} + aria-hidden="true" + /> + + +
+ + + + {props.session.title} +
- - {props.session.title} - -
-
-) + + ) +} const SessionHoverPreview = (props: { mobile?: boolean @@ -204,8 +253,18 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { }) const isWorking = createMemo(() => { if (hasPermissions()) return false + const pending = (sessionStore.message[props.session.id] ?? []).findLast( + (message) => + message.role === "assistant" && + typeof (message as { time?: { completed?: unknown } }).time?.completed !== "number", + ) const status = sessionStore.session_status[props.session.id] - return status?.type === "busy" || status?.type === "retry" + return ( + pending !== undefined || + status?.type === "busy" || + status?.type === "retry" || + (status !== undefined && status.type !== "idle") + ) }) const tint = createMemo(() => { @@ -300,7 +359,7 @@ export const SessionItem = (props: SessionItemProps): JSX.Element => { return (
Promise language: ReturnType }): JSX.Element => ( -