diff --git a/bun.lock b/bun.lock index fd562bd237f2..8d2fe82d78f9 100644 --- a/bun.lock +++ b/bun.lock @@ -1,6 +1,6 @@ { "lockfileVersion": 1, - "configVersion": 0, + "configVersion": 1, "workspaces": { "": { "name": "opencode", @@ -190,6 +190,7 @@ "@solid-primitives/storage": "catalog:", "@solidjs/meta": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-http": "~2", @@ -1784,6 +1785,8 @@ "@tauri-apps/cli-win32-x64-msvc": ["@tauri-apps/cli-win32-x64-msvc@2.9.6", "", { "os": "win32", "cpu": "x64" }, "sha512-ldWuWSSkWbKOPjQMJoYVj9wLHcOniv7diyI5UAJ4XsBdtaFB0pKHQsqw/ItUma0VXGC7vB4E9fZjivmxur60aw=="], + "@tauri-apps/plugin-clipboard-manager": ["@tauri-apps/plugin-clipboard-manager@2.3.2", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-CUlb5Hqi2oZbcZf4VUyUH53XWPPdtpw43EUpCza5HWZJwxEoDowFzNUDt1tRUXA8Uq+XPn17Ysfptip33sG4eQ=="], + "@tauri-apps/plugin-deep-link": ["@tauri-apps/plugin-deep-link@2.4.6", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-UUOSt0U5juK20uhO2MoHZX/IPblkrhUh+VPtIeu3RwtzI0R9Em3Auzfg/PwcZ9Pv8mLne3cQ4p9CFXD6WxqCZA=="], "@tauri-apps/plugin-dialog": ["@tauri-apps/plugin-dialog@2.6.0", "", { "dependencies": { "@tauri-apps/api": "^2.8.0" } }, "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg=="], diff --git a/flake.nix b/flake.nix index ea78b1a43482..40e9d337f58b 100644 --- a/flake.nix +++ b/flake.nix @@ -30,6 +30,26 @@ }; }); + overlays = { + default = + final: _prev: + let + node_modules = final.callPackage ./nix/node_modules.nix { + inherit rev; + }; + opencode = final.callPackage ./nix/opencode.nix { + inherit node_modules; + }; + desktop = final.callPackage ./nix/desktop.nix { + inherit opencode; + }; + in + { + inherit opencode; + opencode-desktop = desktop; + }; + }; + packages = forEachSystem ( pkgs: let diff --git a/infra/console.ts b/infra/console.ts index ba1ff15bf2d1..5abffb555a82 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -135,6 +135,16 @@ const ZEN_MODELS = [ new sst.Secret("ZEN_MODELS8"), new sst.Secret("ZEN_MODELS9"), new sst.Secret("ZEN_MODELS10"), + new sst.Secret("ZEN_MODELS11"), + new sst.Secret("ZEN_MODELS12"), + new sst.Secret("ZEN_MODELS13"), + new sst.Secret("ZEN_MODELS14"), + new sst.Secret("ZEN_MODELS15"), + new sst.Secret("ZEN_MODELS16"), + new sst.Secret("ZEN_MODELS17"), + new sst.Secret("ZEN_MODELS18"), + new sst.Secret("ZEN_MODELS19"), + new sst.Secret("ZEN_MODELS20"), ] const STRIPE_SECRET_KEY = new sst.Secret("STRIPE_SECRET_KEY") const STRIPE_PUBLISHABLE_KEY = new sst.Secret("STRIPE_PUBLISHABLE_KEY") diff --git a/nix/hashes.json b/nix/hashes.json index eb1578dcde28..ddd1253ed1a5 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-UBz5qXhO+Xy6XptVdbo9V0wKsvZgItmHkWDm6I5VRCk=", - "aarch64-linux": "sha256-G2ezu/ThZR3kYfHnbD0EOcLoAa6hwtICpmo9r+bqibE=", - "aarch64-darwin": "sha256-PhSE23OzNlyfNFP5LffA3AtyN+hsyCeGInmDBBRjr0g=", - "x86_64-darwin": "sha256-vWusYJD+7ClDLUFy1wEqRLf9hY8V43iqdqnZ6YWkh1Q=" + "x86_64-linux": "sha256-1IpZnnN6+acCcV0AgO4OVdvgf4TFBFId5dms5W5ecA0=", + "aarch64-linux": "sha256-TKmPhXokOav46ucP9AFwHGgKmB9CdGCcUtwqUtLlzG4=", + "aarch64-darwin": "sha256-xJQuw3+QHYnlClDrafQKPQyR+aqyAEofvYYjCowHDps=", + "x86_64-darwin": "sha256-ywU3Oka2QNGKu/HI+//3bdYJ9qo1N7K5Wr2vpTgSM/g=" } } diff --git a/nix/opencode.nix b/nix/opencode.nix index 23d9fbe34e04..b7d6f95947c1 100644 --- a/nix/opencode.nix +++ b/nix/opencode.nix @@ -34,6 +34,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { ''; env.MODELS_DEV_API_JSON = "${models-dev}/dist/_api.json"; + env.OPENCODE_DISABLE_MODELS_FETCH = true; env.OPENCODE_VERSION = finalAttrs.version; env.OPENCODE_CHANNEL = "local"; @@ -79,7 +80,7 @@ stdenvNoCC.mkDerivation (finalAttrs: { writableTmpDirAsHomeHook ]; doInstallCheck = true; - versionCheckKeepEnvironment = [ "HOME" ]; + versionCheckKeepEnvironment = [ "HOME" "OPENCODE_DISABLE_MODELS_FETCH" ]; versionCheckProgramArg = "--version"; passthru = { diff --git a/packages/app/e2e/settings/settings-keybinds.spec.ts b/packages/app/e2e/settings/settings-keybinds.spec.ts index eceb82b7414c..a8e7f335266a 100644 --- a/packages/app/e2e/settings/settings-keybinds.spec.ts +++ b/packages/app/e2e/settings/settings-keybinds.spec.ts @@ -1,6 +1,6 @@ import { test, expect } from "../fixtures" import { openSettings, closeDialog, withSession } from "../actions" -import { keybindButtonSelector } from "../selectors" +import { keybindButtonSelector, terminalSelector } from "../selectors" import { modKey } from "../utils" test("changing sidebar toggle keybind works", async ({ page, gotoSession }) => { @@ -267,11 +267,14 @@ test("changing terminal toggle keybind works", async ({ page, gotoSession }) => await closeDialog(page, dialog) + const terminal = page.locator(terminalSelector) + await expect(terminal).not.toBeVisible() + await page.keyboard.press(`${modKey}+Y`) - await page.waitForTimeout(100) + await expect(terminal).toBeVisible() - const pageStable = await page.evaluate(() => document.readyState === "complete") - expect(pageStable).toBe(true) + await page.keyboard.press(`${modKey}+Y`) + await expect(terminal).not.toBeVisible() }) test("changing command palette keybind works", async ({ page, gotoSession }) => { diff --git a/packages/app/playwright.config.ts b/packages/app/playwright.config.ts index 10819e69ffef..ea85829e0bcc 100644 --- a/packages/app/playwright.config.ts +++ b/packages/app/playwright.config.ts @@ -14,7 +14,7 @@ export default defineConfig({ expect: { timeout: 10_000, }, - fullyParallel: true, + fullyParallel: process.env.PLAYWRIGHT_FULLY_PARALLEL === "1", forbidOnly: !!process.env.CI, retries: process.env.CI ? 2 : 0, reporter: [["html", { outputFolder: "e2e/playwright-report", open: "never" }], ["line"]], diff --git a/packages/app/script/e2e-local.ts b/packages/app/script/e2e-local.ts index df2107f76d9b..96b35cfb7f4d 100644 --- a/packages/app/script/e2e-local.ts +++ b/packages/app/script/e2e-local.ts @@ -55,6 +55,7 @@ const extraArgs = (() => { const [serverPort, webPort] = await Promise.all([freePort(), freePort()]) const sandbox = await fs.mkdtemp(path.join(os.tmpdir(), "opencode-e2e-")) +const keepSandbox = process.env.OPENCODE_E2E_KEEP_SANDBOX === "1" const serverEnv = { ...process.env, @@ -83,58 +84,99 @@ const runnerEnv = { PLAYWRIGHT_PORT: String(webPort), } satisfies Record -const seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { - cwd: opencodeDir, - env: serverEnv, - stdout: "inherit", - stderr: "inherit", -}) +let seed: ReturnType | undefined +let runner: ReturnType | undefined +let server: { stop: () => Promise | void } | undefined +let inst: { Instance: { disposeAll: () => Promise | void } } | undefined +let cleaned = false +let internalError = false + +const cleanup = async () => { + if (cleaned) return + cleaned = true + + if (seed && seed.exitCode === null) seed.kill("SIGTERM") + if (runner && runner.exitCode === null) runner.kill("SIGTERM") + + const jobs = [ + inst?.Instance.disposeAll(), + server?.stop(), + keepSandbox ? undefined : fs.rm(sandbox, { recursive: true, force: true }), + ].filter(Boolean) + await Promise.allSettled(jobs) +} -const seedExit = await seed.exited -if (seedExit !== 0) { - process.exit(seedExit) +const shutdown = (code: number, reason: string) => { + process.exitCode = code + void cleanup().finally(() => { + console.error(`e2e-local shutdown: ${reason}`) + process.exit(code) + }) } -Object.assign(process.env, serverEnv) -process.env.AGENT = "1" -process.env.OPENCODE = "1" +const reportInternalError = (reason: string, error: unknown) => { + internalError = true + console.error(`e2e-local internal error: ${reason}`) + console.error(error) +} -const log = await import("../../opencode/src/util/log") -const install = await import("../../opencode/src/installation") -await log.Log.init({ - print: true, - dev: install.Installation.isLocal(), - level: "WARN", +process.once("SIGINT", () => shutdown(130, "SIGINT")) +process.once("SIGTERM", () => shutdown(143, "SIGTERM")) +process.once("SIGHUP", () => shutdown(129, "SIGHUP")) +process.once("uncaughtException", (error) => { + reportInternalError("uncaughtException", error) +}) +process.once("unhandledRejection", (error) => { + reportInternalError("unhandledRejection", error) }) -const servermod = await import("../../opencode/src/server/server") -const inst = await import("../../opencode/src/project/instance") -const server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) -console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) +let code = 1 -const result = await (async () => { - try { - await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) +try { + seed = Bun.spawn(["bun", "script/seed-e2e.ts"], { + cwd: opencodeDir, + env: serverEnv, + stdout: "inherit", + stderr: "inherit", + }) - const runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { + const seedExit = await seed.exited + if (seedExit !== 0) { + code = seedExit + } else { + Object.assign(process.env, serverEnv) + process.env.AGENT = "1" + process.env.OPENCODE = "1" + + const log = await import("../../opencode/src/util/log") + const install = await import("../../opencode/src/installation") + await log.Log.init({ + print: true, + dev: install.Installation.isLocal(), + level: "WARN", + }) + + const servermod = await import("../../opencode/src/server/server") + inst = await import("../../opencode/src/project/instance") + server = servermod.Server.listen({ port: serverPort, hostname: "127.0.0.1" }) + console.log(`opencode server listening on http://127.0.0.1:${serverPort}`) + + await waitForHealth(`http://127.0.0.1:${serverPort}/global/health`) + runner = Bun.spawn(["bun", "test:e2e", ...extraArgs], { cwd: appDir, env: runnerEnv, stdout: "inherit", stderr: "inherit", }) - - return { code: await runner.exited } - } catch (error) { - return { error } - } finally { - await inst.Instance.disposeAll() - await server.stop() + code = await runner.exited } -})() - -if ("error" in result) { - console.error(result.error) - process.exit(1) +} catch (error) { + console.error(error) + code = 1 +} finally { + await cleanup() } -process.exit(result.code) +if (code === 0 && internalError) code = 1 + +process.exit(code) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 0b303cf55129..da45c351ec76 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -35,6 +35,7 @@ import { Persist, persisted } from "@/utils/persist" import { SessionContextUsage } from "@/components/session-context-usage" import { usePermission } from "@/context/permission" import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" import { createTextFragment, getCursorPosition, setCursorPosition, setRangeEdge } from "./prompt-input/editor-dom" import { createPromptAttachments, ACCEPTED_FILE_TYPES } from "./prompt-input/attachments" import { navigatePromptHistory, prependHistoryEntry, promptLength } from "./prompt-input/history" @@ -97,6 +98,7 @@ export const PromptInput: Component = (props) => { const command = useCommand() const permission = usePermission() const language = useLanguage() + const platform = usePlatform() let editorRef!: HTMLDivElement let fileInputRef!: HTMLInputElement let scrollRef!: HTMLDivElement @@ -413,7 +415,7 @@ export const PromptInput: Component = (props) => { } = useFilteredList({ items: slashCommands, key: (x) => x?.id, - filterKeys: ["trigger", "title", "description"], + filterKeys: ["trigger", "title"], onSelect: handleSlashSelect, }) @@ -766,6 +768,7 @@ export const PromptInput: Component = (props) => { setCursorPosition(editorRef, promptLength(prompt.current())) }, addPart, + readClipboardImage: platform.readClipboardImage, }) const { abort, handleSubmit } = createPromptSubmit({ diff --git a/packages/app/src/components/prompt-input/attachments.ts b/packages/app/src/components/prompt-input/attachments.ts index 48eda3742326..b384bf7d84e4 100644 --- a/packages/app/src/components/prompt-input/attachments.ts +++ b/packages/app/src/components/prompt-input/attachments.ts @@ -14,6 +14,7 @@ type PromptAttachmentsInput = { setDraggingType: (type: "image" | "@mention" | null) => void focusEditor: () => void addPart: (part: ContentPart) => void + readClipboardImage?: () => Promise } export function createPromptAttachments(input: PromptAttachmentsInput) { @@ -76,6 +77,16 @@ export function createPromptAttachments(input: PromptAttachmentsInput) { } const plainText = clipboardData.getData("text/plain") ?? "" + + // Desktop: Browser clipboard has no images and no text, try platform's native clipboard for images + if (input.readClipboardImage && !plainText) { + const file = await input.readClipboardImage() + if (file) { + await addImageAttachment(file) + return + } + } + if (!plainText) return input.addPart({ type: "text", content: plainText, start: 0, end: 0 }) } diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 5ed5eedadae3..a96bdcbad5b2 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -200,7 +200,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { navigate(`/${base64Encode(sessionDirectory)}/session/${session.id}`) } } - if (!session) return + if (!session) { + showToast({ + title: language.t("prompt.toast.promptSendFailed.title"), + description: language.t("prompt.toast.promptSendFailed.description"), + }) + return + } input.onSubmit?.() diff --git a/packages/app/src/components/session/session-context-metrics.test.ts b/packages/app/src/components/session/session-context-metrics.test.ts index 68903a455b2d..e90df9a9486f 100644 --- a/packages/app/src/components/session/session-context-metrics.test.ts +++ b/packages/app/src/components/session/session-context-metrics.test.ts @@ -79,15 +79,16 @@ describe("getSessionContextMetrics", () => { expect(metrics.context?.usage).toBeNull() }) - test("memoizes by message and provider array identity", () => { + test("recomputes when message array is mutated in place", () => { const messages = [assistant("a1", { input: 10, output: 10, reasoning: 10, read: 10, write: 10 }, 0.25)] const providers = [{ id: "openai", models: {} }] const one = getSessionContextMetrics(messages, providers) + messages.push(assistant("a2", { input: 100, output: 20, reasoning: 0, read: 0, write: 0 }, 0.75)) const two = getSessionContextMetrics(messages, providers) - const three = getSessionContextMetrics([...messages], providers) - expect(two).toBe(one) - expect(three).not.toBe(one) + expect(one.context?.message.id).toBe("a1") + expect(two.context?.message.id).toBe("a2") + expect(two.totalCost).toBe(1) }) }) diff --git a/packages/app/src/components/session/session-context-metrics.ts b/packages/app/src/components/session/session-context-metrics.ts index 2b6edbd951dd..357205afb592 100644 --- a/packages/app/src/components/session/session-context-metrics.ts +++ b/packages/app/src/components/session/session-context-metrics.ts @@ -34,8 +34,6 @@ type Metrics = { context: Context | undefined } -const cache = new WeakMap>() - const tokenTotal = (msg: AssistantMessage) => { return msg.tokens.input + msg.tokens.output + msg.tokens.reasoning + msg.tokens.cache.read + msg.tokens.cache.write } @@ -80,15 +78,5 @@ const build = (messages: Message[], providers: Provider[]): Metrics => { } export function getSessionContextMetrics(messages: Message[], providers: Provider[]) { - const byProvider = cache.get(messages) - if (byProvider) { - const hit = byProvider.get(providers) - if (hit) return hit - } - - const value = build(messages, providers) - const next = byProvider ?? new WeakMap() - next.set(providers, value) - if (!byProvider) cache.set(messages, next) - return value + return build(messages, providers) } diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 7eaafc85423b..5f1597777c5a 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -322,57 +322,60 @@ export function SessionHeader() { } >
- - - + +
+ + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
@@ -420,7 +423,14 @@ export function SessionHeader() { } >
- +
) @@ -196,7 +194,9 @@ export const SortableWorkspace = (props: { when={workspaceEditActive()} fallback={ @@ -204,7 +204,13 @@ export const SortableWorkspace = (props: { } > -
{header()}
+
+ {header()} +
{ test("opens and loads selected review file", () => { @@ -59,3 +59,13 @@ describe("combineCommandSections", () => { expect(result.map((item) => item.id)).toEqual(["a", "b", "c"]) }) }) + +describe("getTabReorderIndex", () => { + test("returns target index for valid drag reorder", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "c")).toBe(2) + }) + + test("returns undefined for unknown droppable id", () => { + expect(getTabReorderIndex(["a", "b", "c"], "a", "missing")).toBeUndefined() + }) +}) diff --git a/packages/app/src/pages/session/helpers.ts b/packages/app/src/pages/session/helpers.ts index d9ce90793f2b..dcf2c8784998 100644 --- a/packages/app/src/pages/session/helpers.ts +++ b/packages/app/src/pages/session/helpers.ts @@ -36,3 +36,10 @@ export const createOpenReviewFile = (input: { export const combineCommandSections = (sections: readonly (readonly CommandOption[])[]) => { return sections.flatMap((section) => section) } + +export const getTabReorderIndex = (tabs: readonly string[], from: string, to: string) => { + const fromIndex = tabs.indexOf(from) + const toIndex = tabs.indexOf(to) + if (fromIndex === -1 || toIndex === -1 || fromIndex === toIndex) return undefined + return toIndex +} diff --git a/packages/app/src/utils/persist.test.ts b/packages/app/src/utils/persist.test.ts new file mode 100644 index 000000000000..5be68f3b0eef --- /dev/null +++ b/packages/app/src/utils/persist.test.ts @@ -0,0 +1,102 @@ +import { beforeAll, beforeEach, describe, expect, mock, test } from "bun:test" + +type PersistTestingType = typeof import("./persist").PersistTesting + +class MemoryStorage implements Storage { + private values = new Map() + readonly events: string[] = [] + readonly calls = { get: 0, set: 0, remove: 0 } + + clear() { + this.values.clear() + } + + get length() { + return this.values.size + } + + key(index: number) { + return Array.from(this.values.keys())[index] ?? null + } + + getItem(key: string) { + this.calls.get += 1 + this.events.push(`get:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage get failed") + return this.values.get(key) ?? null + } + + setItem(key: string, value: string) { + this.calls.set += 1 + this.events.push(`set:${key}`) + if (key.startsWith("opencode.quota")) throw new DOMException("quota", "QuotaExceededError") + if (key.startsWith("opencode.throw")) throw new Error("storage set failed") + this.values.set(key, value) + } + + removeItem(key: string) { + this.calls.remove += 1 + this.events.push(`remove:${key}`) + if (key.startsWith("opencode.throw")) throw new Error("storage remove failed") + this.values.delete(key) + } +} + +const storage = new MemoryStorage() + +let persistTesting: PersistTestingType + +beforeAll(async () => { + mock.module("@/context/platform", () => ({ + usePlatform: () => ({ platform: "web" }), + })) + + const mod = await import("./persist") + persistTesting = mod.PersistTesting +}) + +beforeEach(() => { + storage.clear() + storage.events.length = 0 + storage.calls.get = 0 + storage.calls.set = 0 + storage.calls.remove = 0 + Object.defineProperty(globalThis, "localStorage", { + value: storage, + configurable: true, + }) +}) + +describe("persist localStorage resilience", () => { + test("does not cache values as persisted when quota write and eviction fail", () => { + const storageApi = persistTesting.localStorageWithPrefix("opencode.quota.scope") + storageApi.setItem("value", '{"value":1}') + + expect(storage.getItem("opencode.quota.scope:value")).toBeNull() + expect(storageApi.getItem("value")).toBeNull() + }) + + test("disables only the failing scope when storage throws", () => { + const bad = persistTesting.localStorageWithPrefix("opencode.throw.scope") + bad.setItem("value", '{"value":1}') + + const before = storage.calls.set + bad.setItem("value", '{"value":2}') + expect(storage.calls.set).toBe(before) + expect(bad.getItem("value")).toBeNull() + + const healthy = persistTesting.localStorageWithPrefix("opencode.safe.scope") + healthy.setItem("value", '{"value":3}') + expect(storage.getItem("opencode.safe.scope:value")).toBe('{"value":3}') + }) + + test("failing fallback scope does not poison direct storage scope", () => { + const broken = persistTesting.localStorageWithPrefix("opencode.throw.scope2") + broken.setItem("value", '{"value":1}') + + const direct = persistTesting.localStorageDirect() + direct.setItem("direct-value", '{"value":5}') + + expect(storage.getItem("direct-value")).toBe('{"value":5}') + }) +}) diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index 0ca3abad0691..329a406dd83e 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -17,7 +17,7 @@ type PersistTarget = { const LEGACY_STORAGE = "default.dat" const GLOBAL_STORAGE = "opencode.global.dat" const LOCAL_PREFIX = "opencode." -const fallback = { disabled: false } +const fallback = new Map() const CACHE_MAX_ENTRIES = 500 const CACHE_MAX_BYTES = 8 * 1024 * 1024 @@ -65,6 +65,14 @@ function cacheGet(key: string) { return entry.value } +function fallbackDisabled(scope: string) { + return fallback.get(scope) === true +} + +function fallbackSet(scope: string) { + fallback.set(scope, true) +} + function quota(error: unknown) { if (error instanceof DOMException) { if (error.name === "QuotaExceededError") return true @@ -142,7 +150,6 @@ function write(storage: Storage, key: string, value: string) { } const ok = evict(storage, key, value) - if (!ok) cacheSet(key, value) return ok } @@ -196,18 +203,19 @@ function workspaceStorage(dir: string) { function localStorageWithPrefix(prefix: string): SyncStorage { const base = `${prefix}:` + const scope = `prefix:${prefix}` const item = (key: string) => base + key return { getItem: (key) => { const name = item(key) const cached = cacheGet(name) - if (fallback.disabled && cached !== undefined) return cached + if (fallbackDisabled(scope)) return cached ?? null const stored = (() => { try { return localStorage.getItem(name) } catch { - fallback.disabled = true + fallbackSet(scope) return null } })() @@ -217,40 +225,40 @@ function localStorageWithPrefix(prefix: string): SyncStorage { }, setItem: (key, value) => { const name = item(key) - cacheSet(name, value) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { if (write(localStorage, name, value)) return } catch { - fallback.disabled = true + fallbackSet(scope) return } - fallback.disabled = true + fallbackSet(scope) }, removeItem: (key) => { const name = item(key) cacheDelete(name) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { localStorage.removeItem(name) } catch { - fallback.disabled = true + fallbackSet(scope) } }, } } function localStorageDirect(): SyncStorage { + const scope = "direct" return { getItem: (key) => { const cached = cacheGet(key) - if (fallback.disabled && cached !== undefined) return cached + if (fallbackDisabled(scope)) return cached ?? null const stored = (() => { try { return localStorage.getItem(key) } catch { - fallback.disabled = true + fallbackSet(scope) return null } })() @@ -259,28 +267,32 @@ function localStorageDirect(): SyncStorage { return stored }, setItem: (key, value) => { - cacheSet(key, value) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { if (write(localStorage, key, value)) return } catch { - fallback.disabled = true + fallbackSet(scope) return } - fallback.disabled = true + fallbackSet(scope) }, removeItem: (key) => { cacheDelete(key) - if (fallback.disabled) return + if (fallbackDisabled(scope)) return try { localStorage.removeItem(key) } catch { - fallback.disabled = true + fallbackSet(scope) } }, } } +export const PersistTesting = { + localStorageDirect, + localStorageWithPrefix, +} + export const Persist = { global(key: string, legacy?: string[]): PersistTarget { return { storage: GLOBAL_STORAGE, key, legacy } diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index 34c86685ae3a..26bda070a67a 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -1,6 +1,12 @@ import { describe, expect, test } from "bun:test" import { checkServerHealth } from "./server-health" +function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { + if (init?.signal) return init.signal + if (input instanceof Request) return input.signal + return undefined +} + describe("checkServerHealth", () => { test("returns healthy response with version", async () => { const fetch = (async () => @@ -24,10 +30,40 @@ describe("checkServerHealth", () => { expect(result).toEqual({ healthy: false }) }) + test("uses timeout fallback when AbortSignal.timeout is unavailable", async () => { + const timeout = Object.getOwnPropertyDescriptor(AbortSignal, "timeout") + Object.defineProperty(AbortSignal, "timeout", { + configurable: true, + value: undefined, + }) + + let aborted = false + const fetch = ((input: RequestInfo | URL, init?: RequestInit) => + new Promise((_resolve, reject) => { + const signal = abortFromInput(input, init) + signal?.addEventListener( + "abort", + () => { + aborted = true + reject(new DOMException("Aborted", "AbortError")) + }, + { once: true }, + ) + })) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch, { timeoutMs: 10 }).finally(() => { + if (timeout) Object.defineProperty(AbortSignal, "timeout", timeout) + if (!timeout) Reflect.deleteProperty(AbortSignal, "timeout") + }) + + expect(aborted).toBe(true) + expect(result).toEqual({ healthy: false }) + }) + test("uses provided abort signal", async () => { let signal: AbortSignal | undefined const fetch = (async (input: RequestInfo | URL, init?: RequestInit) => { - signal = init?.signal ?? (input instanceof Request ? input.signal : undefined) + signal = abortFromInput(input, init) return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, @@ -39,4 +75,40 @@ describe("checkServerHealth", () => { expect(signal).toBe(abort.signal) }) + + test("retries transient failures and eventually succeeds", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + if (count < 3) throw new TypeError("network") + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + status: 200, + headers: { "content-type": "application/json" }, + }) + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: true, version: "1.2.3" }) + }) + + test("returns unhealthy when retries are exhausted", async () => { + let count = 0 + const fetch = (async () => { + count += 1 + throw new TypeError("network") + }) as unknown as typeof globalThis.fetch + + const result = await checkServerHealth("http://localhost:4096", fetch, { + retryCount: 2, + retryDelayMs: 1, + }) + + expect(count).toBe(3) + expect(result).toEqual({ healthy: false }) + }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index ab33460b2b5e..929826d0dea4 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -5,10 +5,50 @@ export type ServerHealth = { healthy: boolean; version?: string } interface CheckServerHealthOptions { timeoutMs?: number signal?: AbortSignal + retryCount?: number + retryDelayMs?: number } +const defaultTimeoutMs = 3000 +const defaultRetryCount = 2 +const defaultRetryDelayMs = 100 + function timeoutSignal(timeoutMs: number) { - return (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout?.(timeoutMs) + const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout + if (timeout) { + try { + return { signal: timeout.call(AbortSignal, timeoutMs), clear: undefined as (() => void) | undefined } + } catch {} + } + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), timeoutMs) + return { signal: controller.signal, clear: () => clearTimeout(timer) } +} + +function wait(ms: number, signal?: AbortSignal) { + return new Promise((resolve, reject) => { + if (signal?.aborted) { + reject(new DOMException("Aborted", "AbortError")) + return + } + const timer = setTimeout(() => { + signal?.removeEventListener("abort", onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timer) + reject(new DOMException("Aborted", "AbortError")) + } + signal?.addEventListener("abort", onAbort, { once: true }) + }) +} + +function retryable(error: unknown, signal?: AbortSignal) { + if (signal?.aborted) return false + if (!(error instanceof Error)) return false + if (error.name === "AbortError" || error.name === "TimeoutError") return false + if (error instanceof TypeError) return true + return /network|fetch|econnreset|econnrefused|enotfound|timedout/i.test(error.message) } export async function checkServerHealth( @@ -16,14 +56,24 @@ export async function checkServerHealth( fetch: typeof globalThis.fetch, opts?: CheckServerHealthOptions, ): Promise { - const signal = opts?.signal ?? timeoutSignal(opts?.timeoutMs ?? 3000) - const sdk = createOpencodeClient({ - baseUrl: url, - fetch, - signal, - }) - return sdk.global - .health() - .then((x) => ({ healthy: x.data?.healthy === true, version: x.data?.version })) - .catch(() => ({ healthy: false })) + const timeout = opts?.signal ? undefined : timeoutSignal(opts?.timeoutMs ?? defaultTimeoutMs) + const signal = opts?.signal ?? timeout?.signal + const retryCount = opts?.retryCount ?? defaultRetryCount + const retryDelayMs = opts?.retryDelayMs ?? defaultRetryDelayMs + const next = (count: number, error: unknown) => { + if (count >= retryCount || !retryable(error, signal)) return Promise.resolve({ healthy: false } as const) + return wait(retryDelayMs * (count + 1), signal) + .then(() => attempt(count + 1)) + .catch(() => ({ healthy: false })) + } + const attempt = (count: number): Promise => + createOpencodeClient({ + baseUrl: url, + fetch, + signal, + }) + .global.health() + .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) + .catch((error) => next(count, error)) + return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 8ba7c37e2dfb..4e3206715c20 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -351,8 +351,8 @@ export const dict = { "changelog.empty": "\u0644\u0645 \u064a\u062a\u0645 \u0627\u0644\u0639\u062b\u0648\u0631 \u0639\u0644\u0649 \u0623\u064a \u0625\u062f\u062e\u0627\u0644\u0627\u062a \u0641\u064a \u0633\u062c\u0644 \u0627\u0644\u062a\u063a\u064a\u064a\u0631\u0627\u062a.", "changelog.viewJson": "\u0639\u0631\u0636 JSON", - "workspace.nav.zen": "زين", - "workspace.nav.apiKeys": "API المفاتيح", + "workspace.nav.zen": "Zen", + "workspace.nav.apiKeys": "مفاتيح API", "workspace.nav.members": "أعضاء", "workspace.nav.billing": "الفواتير", "workspace.nav.settings": "إعدادات", @@ -365,14 +365,14 @@ export const dict = { "workspace.newUser.feature.quality.title": "أعلى جودة", "workspace.newUser.feature.quality.body": "الوصول إلى النماذج التي تم تكوينها لتحقيق الأداء الأمثل - لا يوجد تخفيضات أو توجيه إلى موفري الخدمة الأرخص.", - "workspace.newUser.feature.lockin.title": "لا يوجد قفل", + "workspace.newUser.feature.lockin.title": "بدون احتجاز بمزوّد واحد", "workspace.newUser.feature.lockin.body": "استخدم Zen مع أي وكيل ترميز، واستمر في استخدام موفري الخدمات الآخرين مع opencode وقتما تشاء.", "workspace.newUser.copyApiKey": "انسخ مفتاح API", "workspace.newUser.copyKey": "نسخ المفتاح", "workspace.newUser.copied": "منسوخ!", "workspace.newUser.step.enableBilling": "تمكين الفوترة", - "workspace.newUser.step.login.before": "يجري", + "workspace.newUser.step.login.before": "شغّل", "workspace.newUser.step.login.after": "وحدد opencode", "workspace.newUser.step.pasteKey": "الصق مفتاح API الخاص بك", "workspace.newUser.step.models.before": "ابدأ opencode ثم قم بالتشغيل", @@ -390,7 +390,7 @@ export const dict = { "workspace.providers.saving": "توفير...", "workspace.providers.save": "يحفظ", "workspace.providers.table.provider": "مزود", - "workspace.providers.table.apiKey": "API المفتاح", + "workspace.providers.table.apiKey": "مفتاح API", "workspace.usage.title": "تاريخ الاستخدام", "workspace.usage.subtitle": "استخدام وتكاليف API الأخيرة.", "workspace.usage.empty": "قم بإجراء أول مكالمة API للبدء.", @@ -398,25 +398,25 @@ export const dict = { "workspace.usage.table.model": "نموذج", "workspace.usage.table.input": "مدخل", "workspace.usage.table.output": "الإخراج", - "workspace.usage.table.cost": "يكلف", + "workspace.usage.table.cost": "التكلفة", "workspace.usage.breakdown.input": "مدخل", "workspace.usage.breakdown.cacheRead": "قراءة ذاكرة التخزين المؤقت", "workspace.usage.breakdown.cacheWrite": "كتابة ذاكرة التخزين المؤقت", "workspace.usage.breakdown.output": "الإخراج", "workspace.usage.breakdown.reasoning": "المنطق", "workspace.usage.subscription": "الاشتراك (${{amount}})", - "workspace.cost.title": "يكلف", + "workspace.cost.title": "التكلفة", "workspace.cost.subtitle": "تكاليف الاستخدام مقسمة حسب النموذج.", - "workspace.cost.allModels": "جميع الموديلات", + "workspace.cost.allModels": "جميع النماذج", "workspace.cost.allKeys": "جميع المفاتيح", "workspace.cost.deletedSuffix": "(محذوف)", "workspace.cost.empty": "لا توجد بيانات استخدام متاحة للفترة المحددة.", - "workspace.cost.subscriptionShort": "الفرعية", - "workspace.keys.title": "API المفاتيح", + "workspace.cost.subscriptionShort": "اشتراك", + "workspace.keys.title": "مفاتيح API", "workspace.keys.subtitle": "إدارة مفاتيح API الخاصة بك للوصول إلى خدمات opencode.", "workspace.keys.create": "قم بإنشاء مفتاح API", "workspace.keys.placeholder": "أدخل اسم المفتاح", - "workspace.keys.empty": "قم بإنشاء مفتاح opencode للبوابة API", + "workspace.keys.empty": "أنشئ مفتاح API لبوابة opencode", "workspace.keys.table.name": "اسم", "workspace.keys.table.key": "مفتاح", "workspace.keys.table.createdBy": "تم الإنشاء بواسطة", @@ -442,14 +442,14 @@ export const dict = { "workspace.members.table.email": "بريد إلكتروني", "workspace.members.table.role": "دور", "workspace.members.table.monthLimit": "حد الشهر", - "workspace.members.role.admin": "مسؤل", + "workspace.members.role.admin": "مسؤول", "workspace.members.role.adminDescription": "يمكن إدارة النماذج، والأعضاء، والفواتير", "workspace.members.role.member": "عضو", "workspace.members.role.memberDescription": "يمكنهم فقط إنشاء مفاتيح API لأنفسهم", "workspace.settings.title": "إعدادات", "workspace.settings.subtitle": "قم بتحديث اسم مساحة العمل الخاصة بك وتفضيلاتك.", "workspace.settings.workspaceName": "اسم مساحة العمل", - "workspace.settings.defaultName": "تقصير", + "workspace.settings.defaultName": "الافتراضي", "workspace.settings.updating": "جارٍ التحديث...", "workspace.settings.save": "يحفظ", "workspace.settings.edit": "يحرر", @@ -461,37 +461,37 @@ export const dict = { "workspace.billing.add": "أضف $", "workspace.billing.enterAmount": "أدخل المبلغ", "workspace.billing.loading": "تحميل...", - "workspace.billing.addAction": "يضيف", + "workspace.billing.addAction": "إضافة", "workspace.billing.addBalance": "إضافة الرصيد", - "workspace.billing.linkedToStripe": "مرتبطة بالشريط", - "workspace.billing.manage": "يدير", + "workspace.billing.linkedToStripe": "مرتبط بـ Stripe", + "workspace.billing.manage": "إدارة", "workspace.billing.enable": "تمكين الفوترة", "workspace.monthlyLimit.title": "الحد الشهري", "workspace.monthlyLimit.subtitle": "قم بتعيين حد الاستخدام الشهري لحسابك.", "workspace.monthlyLimit.placeholder": "50", - "workspace.monthlyLimit.setting": "جلسة...", + "workspace.monthlyLimit.setting": "جارٍ التعيين...", "workspace.monthlyLimit.set": "تعيين", "workspace.monthlyLimit.edit": "تحرير الحد", "workspace.monthlyLimit.noLimit": "لم يتم تعيين حد الاستخدام.", "workspace.monthlyLimit.currentUsage.beforeMonth": "الاستخدام الحالي ل", "workspace.monthlyLimit.currentUsage.beforeAmount": "هو $", - "workspace.reload.title": "إعادة التحميل التلقائي", - "workspace.reload.disabled.before": "إعادة التحميل التلقائي هو", - "workspace.reload.disabled.state": "عاجز", - "workspace.reload.disabled.after": "تمكين إعادة التحميل تلقائيًا عندما يكون الرصيد منخفضًا.", - "workspace.reload.enabled.before": "إعادة التحميل التلقائي هو", + "workspace.reload.title": "إعادة الشحن التلقائي", + "workspace.reload.disabled.before": "إعادة الشحن التلقائي", + "workspace.reload.disabled.state": "معطّل", + "workspace.reload.disabled.after": "فعّلها لإعادة شحن الرصيد تلقائيًا عندما يكون منخفضًا.", + "workspace.reload.enabled.before": "إعادة الشحن التلقائي", "workspace.reload.enabled.state": "ممكّن", - "workspace.reload.enabled.middle": "سنقوم بإعادة التحميل", + "workspace.reload.enabled.middle": "سنعيد شحن رصيدك بمبلغ", "workspace.reload.processingFee": "رسوم المعالجة", - "workspace.reload.enabled.after": "عندما يصل التوازن", + "workspace.reload.enabled.after": "عندما يصل الرصيد إلى", "workspace.reload.edit": "يحرر", - "workspace.reload.enable": "يُمكَِن", - "workspace.reload.enableAutoReload": "تمكين إعادة التحميل التلقائي", - "workspace.reload.reloadAmount": "إعادة تحميل $", + "workspace.reload.enable": "تفعيل", + "workspace.reload.enableAutoReload": "تفعيل إعادة الشحن التلقائي", + "workspace.reload.reloadAmount": "مبلغ إعادة الشحن $", "workspace.reload.whenBalanceReaches": "عندما يصل الرصيد إلى $", "workspace.reload.saving": "توفير...", "workspace.reload.save": "يحفظ", - "workspace.reload.failedAt": "فشلت عملية إعادة التحميل عند", + "workspace.reload.failedAt": "فشلت إعادة الشحن في", "workspace.reload.reason": "سبب:", "workspace.reload.updatePaymentMethod": "يرجى تحديث طريقة الدفع الخاصة بك والمحاولة مرة أخرى.", "workspace.reload.retrying": "جارٍ إعادة المحاولة...", @@ -500,11 +500,11 @@ export const dict = { "workspace.payments.subtitle": "معاملات الدفع الأخيرة.", "workspace.payments.table.date": "تاريخ", "workspace.payments.table.paymentId": "معرف الدفع", - "workspace.payments.table.amount": "كمية", + "workspace.payments.table.amount": "المبلغ", "workspace.payments.table.receipt": "إيصال", "workspace.payments.type.credit": "ائتمان", "workspace.payments.type.subscription": "الاشتراك", - "workspace.payments.view": "منظر", + "workspace.payments.view": "عرض", "workspace.black.loading": "تحميل...", "workspace.black.time.day": "يوم", "workspace.black.time.days": "أيام", @@ -521,8 +521,8 @@ export const dict = { "workspace.black.subscription.resetsIn": "إعادة تعيين في", "workspace.black.subscription.useBalance": "استخدم رصيدك المتوفر بعد الوصول إلى حدود الاستخدام", "workspace.black.waitlist.title": "قائمة الانتظار", - "workspace.black.waitlist.joined": "أنت على قائمة الانتظار للخطة السوداء {{plan}} دولار شهريًا OpenCode.", - "workspace.black.waitlist.ready": "نحن على استعداد لتسجيلك في خطة Black {{plan}} الشهرية OpenCode.", + "workspace.black.waitlist.joined": "أنت على قائمة الانتظار لخطة OpenCode Black بقيمة ${{plan}} شهريًا.", + "workspace.black.waitlist.ready": "نحن مستعدون لتسجيلك في خطة OpenCode Black بقيمة ${{plan}} شهريًا.", "workspace.black.waitlist.leave": "ترك قائمة الانتظار", "workspace.black.waitlist.leaving": "مغادرة...", "workspace.black.waitlist.left": "غادر", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index 908d4b605fc1..719c2b96150a 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -294,18 +294,18 @@ export const dict = { "workspace.home.billing.currentBalance": "Nuværende saldo", "workspace.newUser.feature.tested.title": "Testede og verificerede modeller", "workspace.newUser.feature.tested.body": - "Vi har benchmarket og testet modeller specifikt til kodningsmidler for at sikre den bedste ydeevne.", + "Vi har benchmarket og testet modeller specifikt til kodningsagenter for at sikre den bedste ydeevne.", "workspace.newUser.feature.quality.title": "Højeste kvalitet", "workspace.newUser.feature.quality.body": "Få adgang til modeller konfigureret til optimal ydeevne - ingen nedgraderinger eller routing til billigere udbydere.", "workspace.newUser.feature.lockin.title": "Ingen indlåsning", "workspace.newUser.feature.lockin.body": "Brug Zen med en hvilken som helst kodningsagent, og fortsæt med at bruge andre udbydere med opencode, når du vil.", - "workspace.newUser.copyApiKey": "Kopiér nøglen API", + "workspace.newUser.copyApiKey": "Kopiér API-nøgle", "workspace.newUser.copyKey": "Kopier nøgle", "workspace.newUser.copied": "Kopieret!", "workspace.newUser.step.enableBilling": "Aktiver fakturering", - "workspace.newUser.step.login.before": "Løbe", + "workspace.newUser.step.login.before": "Kør", "workspace.newUser.step.login.after": "og vælg opencode", "workspace.newUser.step.pasteKey": "Indsæt din API nøgle", "workspace.newUser.step.models.before": "Start opencode og kør", @@ -316,12 +316,12 @@ export const dict = { "workspace.models.table.enabled": "Aktiveret", "workspace.providers.title": "Medbring din egen nøgle", "workspace.providers.subtitle": "Konfigurer dine egne API nøgler fra AI-udbydere.", - "workspace.providers.placeholder": "Indtast nøglen {{provider}} API ({{prefix}}...)", + "workspace.providers.placeholder": "Indtast {{provider}} API-nøgle ({{prefix}}...)", "workspace.providers.configure": "Konfigurer", - "workspace.providers.edit": "Redigere", + "workspace.providers.edit": "Rediger", "workspace.providers.delete": "Slet", "workspace.providers.saving": "Gemmer...", - "workspace.providers.save": "Spare", + "workspace.providers.save": "Gem", "workspace.providers.table.provider": "Udbyder", "workspace.providers.table.apiKey": "API Nøgle", "workspace.usage.title": "Brugshistorik", @@ -330,15 +330,15 @@ export const dict = { "workspace.usage.table.date": "Dato", "workspace.usage.table.model": "Model", "workspace.usage.table.input": "Input", - "workspace.usage.table.output": "Produktion", - "workspace.usage.table.cost": "Koste", + "workspace.usage.table.output": "Output", + "workspace.usage.table.cost": "Omkostning", "workspace.usage.breakdown.input": "Input", "workspace.usage.breakdown.cacheRead": "Cache læst", "workspace.usage.breakdown.cacheWrite": "Cache skriv", - "workspace.usage.breakdown.output": "Produktion", + "workspace.usage.breakdown.output": "Output", "workspace.usage.breakdown.reasoning": "Ræsonnement", "workspace.usage.subscription": "abonnement (${{amount}})", - "workspace.cost.title": "Koste", + "workspace.cost.title": "Omkostninger", "workspace.cost.subtitle": "Brugsomkostninger opdelt efter model.", "workspace.cost.allModels": "Alle modeller", "workspace.cost.allKeys": "Alle nøgler", @@ -354,7 +354,7 @@ export const dict = { "workspace.keys.table.key": "Nøgle", "workspace.keys.table.createdBy": "Skabt af", "workspace.keys.table.lastUsed": "Sidst brugt", - "workspace.keys.copyApiKey": "Kopiér nøglen API", + "workspace.keys.copyApiKey": "Kopiér API-nøgle", "workspace.keys.delete": "Slet", "workspace.members.title": "Medlemmer", "workspace.members.subtitle": "Administrer arbejdsområdemedlemmer og deres tilladelser.", @@ -368,10 +368,10 @@ export const dict = { "workspace.members.noLimit": "Ingen grænse", "workspace.members.noLimitLowercase": "ingen grænse", "workspace.members.invited": "inviteret", - "workspace.members.edit": "Redigere", + "workspace.members.edit": "Rediger", "workspace.members.delete": "Slet", "workspace.members.saving": "Gemmer...", - "workspace.members.save": "Spare", + "workspace.members.save": "Gem", "workspace.members.table.email": "E-mail", "workspace.members.table.role": "Rolle", "workspace.members.table.monthLimit": "Månedsgrænse", @@ -382,10 +382,10 @@ export const dict = { "workspace.settings.title": "Indstillinger", "workspace.settings.subtitle": "Opdater dit arbejdsområdes navn og præferencer.", "workspace.settings.workspaceName": "Arbejdsområdets navn", - "workspace.settings.defaultName": "Misligholdelse", + "workspace.settings.defaultName": "Standard", "workspace.settings.updating": "Opdaterer...", - "workspace.settings.save": "Spare", - "workspace.settings.edit": "Redigere", + "workspace.settings.save": "Gem", + "workspace.settings.edit": "Rediger", "workspace.billing.title": "Fakturering", "workspace.billing.subtitle.beforeLink": "Administrer betalingsmetoder.", "workspace.billing.contactUs": "Kontakt os", @@ -394,10 +394,10 @@ export const dict = { "workspace.billing.add": "Tilføj $", "workspace.billing.enterAmount": "Indtast beløb", "workspace.billing.loading": "Indlæser...", - "workspace.billing.addAction": "Tilføje", + "workspace.billing.addAction": "Tilføj", "workspace.billing.addBalance": "Tilføj balance", "workspace.billing.linkedToStripe": "Forbundet til Stripe", - "workspace.billing.manage": "Styre", + "workspace.billing.manage": "Administrer", "workspace.billing.enable": "Aktiver fakturering", "workspace.monthlyLimit.title": "Månedlig grænse", "workspace.monthlyLimit.subtitle": "Indstil en månedlig forbrugsgrænse for din konto.", @@ -408,23 +408,23 @@ export const dict = { "workspace.monthlyLimit.noLimit": "Ingen forbrugsgrænse angivet.", "workspace.monthlyLimit.currentUsage.beforeMonth": "Nuværende brug for", "workspace.monthlyLimit.currentUsage.beforeAmount": "er $", - "workspace.reload.title": "Automatisk genindlæsning", - "workspace.reload.disabled.before": "Automatisk genindlæsning er", - "workspace.reload.disabled.state": "handicappet", - "workspace.reload.disabled.after": "Aktiver for automatisk at genindlæse, når balancen er lav.", - "workspace.reload.enabled.before": "Automatisk genindlæsning er", + "workspace.reload.title": "Automatisk genopfyldning", + "workspace.reload.disabled.before": "Automatisk genopfyldning er", + "workspace.reload.disabled.state": "deaktiveret", + "workspace.reload.disabled.after": "Aktiver for automatisk at genopfylde, når saldoen er lav.", + "workspace.reload.enabled.before": "Automatisk genopfyldning er", "workspace.reload.enabled.state": "aktiveret", - "workspace.reload.enabled.middle": "Vi genindlæser", + "workspace.reload.enabled.middle": "Vi genopfylder", "workspace.reload.processingFee": "ekspeditionsgebyr", "workspace.reload.enabled.after": "når balancen er nået", - "workspace.reload.edit": "Redigere", + "workspace.reload.edit": "Rediger", "workspace.reload.enable": "Aktiver", - "workspace.reload.enableAutoReload": "Aktiver automatisk genindlæsning", - "workspace.reload.reloadAmount": "Genindlæs $", + "workspace.reload.enableAutoReload": "Aktiver automatisk genopfyldning", + "workspace.reload.reloadAmount": "Genopfyld $", "workspace.reload.whenBalanceReaches": "Når saldoen når $", "workspace.reload.saving": "Gemmer...", - "workspace.reload.save": "Spare", - "workspace.reload.failedAt": "Genindlæsning mislykkedes kl", + "workspace.reload.save": "Gem", + "workspace.reload.failedAt": "Genopfyldning mislykkedes kl", "workspace.reload.reason": "Årsag:", "workspace.reload.updatePaymentMethod": "Opdater din betalingsmetode, og prøv igen.", "workspace.reload.retrying": "Prøver igen...", @@ -434,10 +434,10 @@ export const dict = { "workspace.payments.table.date": "Dato", "workspace.payments.table.paymentId": "Betalings-id", "workspace.payments.table.amount": "Beløb", - "workspace.payments.table.receipt": "Modtagelse", + "workspace.payments.table.receipt": "Kvittering", "workspace.payments.type.credit": "kredit", "workspace.payments.type.subscription": "abonnement", - "workspace.payments.view": "Udsigt", + "workspace.payments.view": "Vis", "workspace.black.loading": "Indlæser...", "workspace.black.time.day": "dag", "workspace.black.time.days": "dage", @@ -458,8 +458,8 @@ export const dict = { "workspace.black.waitlist.ready": "Vi er klar til at tilmelde dig ${{plan}} per måned OpenCode Black plan.", "workspace.black.waitlist.leave": "Forlad venteliste", "workspace.black.waitlist.leaving": "Forlader...", - "workspace.black.waitlist.left": "Venstre", - "workspace.black.waitlist.enroll": "Indskrive", + "workspace.black.waitlist.left": "Forladt", + "workspace.black.waitlist.enroll": "Tilmeld", "workspace.black.waitlist.enrolling": "Tilmelder...", "workspace.black.waitlist.enrolled": "Tilmeldt", "workspace.black.waitlist.enrollNote": diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index f37cbc91fc85..ea20893a62dc 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -306,27 +306,27 @@ export const dict = { "workspace.newUser.feature.lockin.title": "Kein Lock-in", "workspace.newUser.feature.lockin.body": "Verwenden Sie Zen mit einem beliebigen Codierungsagenten und nutzen Sie weiterhin andere Anbieter mit opencode, wann immer Sie möchten.", - "workspace.newUser.copyApiKey": "Kopieren Sie den Schlüssel API", + "workspace.newUser.copyApiKey": "API-Schlüssel kopieren", "workspace.newUser.copyKey": "Schlüssel kopieren", "workspace.newUser.copied": "Kopiert!", "workspace.newUser.step.enableBilling": "Abrechnung aktivieren", - "workspace.newUser.step.login.before": "Laufen", + "workspace.newUser.step.login.before": "Führe", "workspace.newUser.step.login.after": "und wählen Sie opencode", "workspace.newUser.step.pasteKey": "Fügen Sie Ihren API-Schlüssel ein", - "workspace.newUser.step.models.before": "Starten Sie opencode und führen Sie es aus", + "workspace.newUser.step.models.before": "Starte opencode und führe", "workspace.newUser.step.models.after": "um ein Modell auszuwählen", "workspace.models.title": "Modelle", "workspace.models.subtitle.beforeLink": "Verwalten Sie, auf welche Modelle Arbeitsbereichsmitglieder zugreifen können.", "workspace.models.table.model": "Modell", - "workspace.models.table.enabled": "Ermöglicht", + "workspace.models.table.enabled": "Aktiviert", "workspace.providers.title": "Bringen Sie Ihren eigenen Schlüssel mit", "workspace.providers.subtitle": "Konfigurieren Sie Ihre eigenen API-Schlüssel von KI-Anbietern.", "workspace.providers.placeholder": "Geben Sie den Schlüssel {{provider}} API ein ({{prefix}}...)", "workspace.providers.configure": "Konfigurieren", "workspace.providers.edit": "Bearbeiten", "workspace.providers.delete": "Löschen", - "workspace.providers.saving": "Sparen...", + "workspace.providers.saving": "Wird gespeichert...", "workspace.providers.save": "Speichern", "workspace.providers.table.provider": "Anbieter", "workspace.providers.table.apiKey": "API-Schlüssel", @@ -335,14 +335,14 @@ export const dict = { "workspace.usage.empty": "Machen Sie Ihren ersten API-Aufruf, um loszulegen.", "workspace.usage.table.date": "Datum", "workspace.usage.table.model": "Modell", - "workspace.usage.table.input": "Eingang", - "workspace.usage.table.output": "Ausgabe", + "workspace.usage.table.input": "Input", + "workspace.usage.table.output": "Output", "workspace.usage.table.cost": "Kosten", - "workspace.usage.breakdown.input": "Eingang", + "workspace.usage.breakdown.input": "Input", "workspace.usage.breakdown.cacheRead": "Cache-Lesen", "workspace.usage.breakdown.cacheWrite": "Cache-Schreiben", - "workspace.usage.breakdown.output": "Ausgabe", - "workspace.usage.breakdown.reasoning": "Argumentation", + "workspace.usage.breakdown.output": "Output", + "workspace.usage.breakdown.reasoning": "Reasoning", "workspace.usage.subscription": "Abonnement (${{amount}})", "workspace.cost.title": "Kosten", "workspace.cost.subtitle": "Nutzungskosten aufgeschlüsselt nach Modell.", @@ -360,12 +360,12 @@ export const dict = { "workspace.keys.table.key": "Schlüssel", "workspace.keys.table.createdBy": "Erstellt von", "workspace.keys.table.lastUsed": "Zuletzt verwendet", - "workspace.keys.copyApiKey": "Kopieren Sie den Schlüssel API", + "workspace.keys.copyApiKey": "API-Schlüssel kopieren", "workspace.keys.delete": "Löschen", "workspace.members.title": "Mitglieder", "workspace.members.subtitle": "Verwalten Sie Arbeitsbereichsmitglieder und ihre Berechtigungen.", "workspace.members.invite": "Mitglied einladen", - "workspace.members.inviting": "Einladend...", + "workspace.members.inviting": "Wird eingeladen...", "workspace.members.beta.beforeLink": "Während der Betaversion sind Arbeitsbereiche für Teams kostenlos.", "workspace.members.form.invitee": "Eingeladen", "workspace.members.form.emailPlaceholder": "Geben Sie Ihre E-Mail-Adresse ein", @@ -376,7 +376,7 @@ export const dict = { "workspace.members.invited": "eingeladen", "workspace.members.edit": "Bearbeiten", "workspace.members.delete": "Löschen", - "workspace.members.saving": "Sparen...", + "workspace.members.saving": "Wird gespeichert...", "workspace.members.save": "Speichern", "workspace.members.table.email": "E-Mail", "workspace.members.table.role": "Rolle", @@ -408,30 +408,30 @@ export const dict = { "workspace.monthlyLimit.title": "Monatliches Limit", "workspace.monthlyLimit.subtitle": "Legen Sie ein monatliches Nutzungslimit für Ihr Konto fest.", "workspace.monthlyLimit.placeholder": "50", - "workspace.monthlyLimit.setting": "Einstellung...", - "workspace.monthlyLimit.set": "Satz", + "workspace.monthlyLimit.setting": "Wird gesetzt...", + "workspace.monthlyLimit.set": "Festlegen", "workspace.monthlyLimit.edit": "Limit bearbeiten", "workspace.monthlyLimit.noLimit": "Kein Nutzungslimit festgelegt.", "workspace.monthlyLimit.currentUsage.beforeMonth": "Aktuelle Nutzung für", "workspace.monthlyLimit.currentUsage.beforeAmount": "ist $", - "workspace.reload.title": "Automatisches Neuladen", - "workspace.reload.disabled.before": "Automatisches Nachladen ist", + "workspace.reload.title": "Automatische Aufladung", + "workspace.reload.disabled.before": "Automatische Aufladung ist", "workspace.reload.disabled.state": "deaktiviert", "workspace.reload.disabled.after": - "Aktivieren Sie diese Option, um das Guthaben automatisch neu zu laden, wenn das Guthaben niedrig ist.", - "workspace.reload.enabled.before": "Automatisches Nachladen ist", - "workspace.reload.enabled.state": "ermöglicht", - "workspace.reload.enabled.middle": "Wir laden nach", + "Aktivieren Sie diese Option, damit bei niedrigem Kontostand automatisch aufgeladen wird.", + "workspace.reload.enabled.before": "Automatische Aufladung ist", + "workspace.reload.enabled.state": "aktiviert", + "workspace.reload.enabled.middle": "Wir laden auf", "workspace.reload.processingFee": "Bearbeitungsgebühr", - "workspace.reload.enabled.after": "wenn das Gleichgewicht erreicht ist", + "workspace.reload.enabled.after": "sobald der Kontostand", "workspace.reload.edit": "Bearbeiten", "workspace.reload.enable": "Aktivieren", - "workspace.reload.enableAutoReload": "Aktivieren Sie das automatische Neuladen", - "workspace.reload.reloadAmount": "$ neu laden", - "workspace.reload.whenBalanceReaches": "Wenn der Saldo $ erreicht", - "workspace.reload.saving": "Sparen...", + "workspace.reload.enableAutoReload": "Automatische Aufladung aktivieren", + "workspace.reload.reloadAmount": "Aufladebetrag $", + "workspace.reload.whenBalanceReaches": "Wenn der Kontostand $ erreicht", + "workspace.reload.saving": "Wird gespeichert...", "workspace.reload.save": "Speichern", - "workspace.reload.failedAt": "Neuladen fehlgeschlagen bei", + "workspace.reload.failedAt": "Aufladung fehlgeschlagen am", "workspace.reload.reason": "Grund:", "workspace.reload.updatePaymentMethod": "Bitte aktualisieren Sie Ihre Zahlungsmethode und versuchen Sie es erneut.", "workspace.reload.retrying": "Erneuter Versuch...", @@ -440,11 +440,11 @@ export const dict = { "workspace.payments.subtitle": "Letzte Zahlungsvorgänge.", "workspace.payments.table.date": "Datum", "workspace.payments.table.paymentId": "Zahlungs-ID", - "workspace.payments.table.amount": "Menge", + "workspace.payments.table.amount": "Betrag", "workspace.payments.table.receipt": "Quittung", "workspace.payments.type.credit": "Kredit", "workspace.payments.type.subscription": "Abonnement", - "workspace.payments.view": "Sicht", + "workspace.payments.view": "Anzeigen", "workspace.black.loading": "Laden...", "workspace.black.time.day": "Tag", "workspace.black.time.days": "Tage", @@ -454,21 +454,21 @@ export const dict = { "workspace.black.time.minutes": "Minuten", "workspace.black.time.fewSeconds": "ein paar Sekunden", "workspace.black.subscription.title": "Abonnement", - "workspace.black.subscription.message": "Sie haben OpenCode Black für {{plan}} pro Monat abonniert.", + "workspace.black.subscription.message": "Sie haben OpenCode Black für ${{plan}} pro Monat abonniert.", "workspace.black.subscription.manage": "Abonnement verwalten", "workspace.black.subscription.rollingUsage": "5-stündige Nutzung", "workspace.black.subscription.weeklyUsage": "Wöchentliche Nutzung", - "workspace.black.subscription.resetsIn": "Wird zurückgesetzt", + "workspace.black.subscription.resetsIn": "Zurückgesetzt in", "workspace.black.subscription.useBalance": "Nutzen Sie Ihr verfügbares Guthaben, nachdem Sie die Nutzungslimits erreicht haben", "workspace.black.waitlist.title": "Warteliste", "workspace.black.waitlist.joined": - "Sie stehen auf der Warteliste für den Black-Plan im Wert von ${{plan}} pro Monat OpenCode.", + "Sie stehen auf der Warteliste für den OpenCode Black Tarif für ${{plan}} pro Monat.", "workspace.black.waitlist.ready": - "Wir sind bereit, Sie für den Black-Plan im Wert von ${{plan}} pro Monat OpenCode anzumelden.", + "Wir können Sie jetzt in den OpenCode Black Tarif für ${{plan}} pro Monat aufnehmen.", "workspace.black.waitlist.leave": "Warteliste verlassen", "workspace.black.waitlist.leaving": "Verlassen...", - "workspace.black.waitlist.left": "Links", + "workspace.black.waitlist.left": "Verlassen", "workspace.black.waitlist.enroll": "Einschreiben", "workspace.black.waitlist.enrolling": "Anmeldung...", "workspace.black.waitlist.enrolled": "Eingeschrieben", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index fb03a01e321d..daf9e5625c60 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -284,8 +284,8 @@ export const dict = { "changelog.hero.subtitle": "Nuovi aggiornamenti e miglioramenti per OpenCode", "changelog.empty": "Nessuna voce di changelog trovata.", "changelog.viewJson": "Visualizza JSON", - "workspace.nav.zen": "zen", - "workspace.nav.apiKeys": "API Chiavi", + "workspace.nav.zen": "Zen", + "workspace.nav.apiKeys": "Chiavi API", "workspace.nav.members": "Membri", "workspace.nav.billing": "Fatturazione", "workspace.nav.settings": "Impostazioni", @@ -299,14 +299,14 @@ export const dict = { "workspace.newUser.feature.quality.title": "Massima qualità", "workspace.newUser.feature.quality.body": "Modelli di accesso configurati per prestazioni ottimali: senza downgrade o instradamento verso fornitori più economici.", - "workspace.newUser.feature.lockin.title": "Nessun blocco", + "workspace.newUser.feature.lockin.title": "Nessun lock-in", "workspace.newUser.feature.lockin.body": "Utilizza Zen con qualsiasi agente di codifica e continua a utilizzare altri provider con opencode ogni volta che vuoi.", "workspace.newUser.copyApiKey": "Copia la chiave API", "workspace.newUser.copyKey": "Copia chiave", "workspace.newUser.copied": "Copiato!", "workspace.newUser.step.enableBilling": "Abilita fatturazione", - "workspace.newUser.step.login.before": "Correre", + "workspace.newUser.step.login.before": "Esegui", "workspace.newUser.step.login.after": "e seleziona opencode", "workspace.newUser.step.pasteKey": "Incolla la tua chiave API", "workspace.newUser.step.models.before": "Avvia opencode ed esegui", @@ -315,16 +315,16 @@ export const dict = { "workspace.models.subtitle.beforeLink": "Gestire i modelli a cui possono accedere i membri dell'area di lavoro.", "workspace.models.table.model": "Modello", "workspace.models.table.enabled": "Abilitato", - "workspace.providers.title": "Porta la tua chiave", + "workspace.providers.title": "Bring Your Own Key (BYOK)", "workspace.providers.subtitle": "Configura le tue chiavi API dai fornitori di intelligenza artificiale.", "workspace.providers.placeholder": "Inserisci la chiave {{provider}} API ({{prefix}}...)", "workspace.providers.configure": "Configura", "workspace.providers.edit": "Modificare", "workspace.providers.delete": "Eliminare", - "workspace.providers.saving": "Risparmio...", + "workspace.providers.saving": "Salvataggio in corso...", "workspace.providers.save": "Salva", "workspace.providers.table.provider": "Fornitore", - "workspace.providers.table.apiKey": "API Chiave", + "workspace.providers.table.apiKey": "Chiave API", "workspace.usage.title": "Cronologia dell'utilizzo", "workspace.usage.subtitle": "Utilizzo e costi recenti di API.", "workspace.usage.empty": "Effettua la tua prima chiamata API per iniziare.", @@ -346,7 +346,7 @@ export const dict = { "workspace.cost.deletedSuffix": "(eliminato)", "workspace.cost.empty": "Nessun dato di utilizzo disponibile per il periodo selezionato.", "workspace.cost.subscriptionShort": "sub", - "workspace.keys.title": "API Chiavi", + "workspace.keys.title": "Chiavi API", "workspace.keys.subtitle": "Gestisci le tue chiavi API per accedere ai servizi opencode.", "workspace.keys.create": "Crea chiave API", "workspace.keys.placeholder": "Inserisci il nome della chiave", @@ -360,7 +360,7 @@ export const dict = { "workspace.members.title": "Membri", "workspace.members.subtitle": "Gestire i membri dell'area di lavoro e le relative autorizzazioni.", "workspace.members.invite": "Invita membro", - "workspace.members.inviting": "Invitante...", + "workspace.members.inviting": "Invito in corso...", "workspace.members.beta.beforeLink": "Gli spazi di lavoro sono gratuiti per i team durante la beta.", "workspace.members.form.invitee": "Invitato", "workspace.members.form.emailPlaceholder": "Inserisci l'e-mail", @@ -371,12 +371,12 @@ export const dict = { "workspace.members.invited": "invitato", "workspace.members.edit": "Modificare", "workspace.members.delete": "Eliminare", - "workspace.members.saving": "Risparmio...", + "workspace.members.saving": "Salvataggio in corso...", "workspace.members.save": "Salva", "workspace.members.table.email": "E-mail", "workspace.members.table.role": "Ruolo", "workspace.members.table.monthLimit": "Limite mensile", - "workspace.members.role.admin": "Ammin", + "workspace.members.role.admin": "Admin", "workspace.members.role.adminDescription": "Può gestire modelli, membri e fatturazione", "workspace.members.role.member": "Membro", "workspace.members.role.memberDescription": "Possono generare chiavi API solo per se stessi", @@ -388,42 +388,42 @@ export const dict = { "workspace.settings.save": "Salva", "workspace.settings.edit": "Modificare", "workspace.billing.title": "Fatturazione", - "workspace.billing.subtitle.beforeLink": "Gestire i metodi di pagamento.", + "workspace.billing.subtitle.beforeLink": "Gestisci i metodi di pagamento.", "workspace.billing.contactUs": "Contattaci", "workspace.billing.subtitle.afterLink": "se hai qualche domanda", "workspace.billing.currentBalance": "Saldo attuale", "workspace.billing.add": "Aggiungi $", "workspace.billing.enterAmount": "Inserisci l'importo", "workspace.billing.loading": "Caricamento...", - "workspace.billing.addAction": "Aggiungere", + "workspace.billing.addAction": "Aggiungi", "workspace.billing.addBalance": "Aggiungi saldo", "workspace.billing.linkedToStripe": "Collegato a Stripe", - "workspace.billing.manage": "Maneggio", + "workspace.billing.manage": "Gestisci", "workspace.billing.enable": "Abilita fatturazione", "workspace.monthlyLimit.title": "Limite mensile", "workspace.monthlyLimit.subtitle": "Imposta un limite di utilizzo mensile per il tuo account.", "workspace.monthlyLimit.placeholder": "50", - "workspace.monthlyLimit.setting": "Collocamento...", + "workspace.monthlyLimit.setting": "Impostazione in corso...", "workspace.monthlyLimit.set": "Impostato", "workspace.monthlyLimit.edit": "Modifica limite", "workspace.monthlyLimit.noLimit": "Nessun limite di utilizzo impostato.", "workspace.monthlyLimit.currentUsage.beforeMonth": "Utilizzo attuale per", "workspace.monthlyLimit.currentUsage.beforeAmount": "è $", "workspace.reload.title": "Ricarica automatica", - "workspace.reload.disabled.before": "La ricarica automatica lo è", + "workspace.reload.disabled.before": "La ricarica automatica è", "workspace.reload.disabled.state": "disabilitato", "workspace.reload.disabled.after": "Abilita la ricarica automatica quando il saldo è basso.", - "workspace.reload.enabled.before": "La ricarica automatica lo è", + "workspace.reload.enabled.before": "La ricarica automatica è", "workspace.reload.enabled.state": "abilitato", "workspace.reload.enabled.middle": "Ricaricheremo", "workspace.reload.processingFee": "tassa di elaborazione", - "workspace.reload.enabled.after": "quando l'equilibrio raggiunge", + "workspace.reload.enabled.after": "quando il saldo raggiunge", "workspace.reload.edit": "Modificare", "workspace.reload.enable": "Abilitare", "workspace.reload.enableAutoReload": "Abilita ricarica automatica", "workspace.reload.reloadAmount": "Ricarica $", "workspace.reload.whenBalanceReaches": "Quando il saldo raggiunge $", - "workspace.reload.saving": "Risparmio...", + "workspace.reload.saving": "Salvataggio in corso...", "workspace.reload.save": "Salva", "workspace.reload.failedAt": "Ricarica non riuscita a", "workspace.reload.reason": "Motivo:", @@ -434,11 +434,11 @@ export const dict = { "workspace.payments.subtitle": "Transazioni di pagamento recenti.", "workspace.payments.table.date": "Data", "workspace.payments.table.paymentId": "ID pagamento", - "workspace.payments.table.amount": "Quantità", + "workspace.payments.table.amount": "Importo", "workspace.payments.table.receipt": "Ricevuta", "workspace.payments.type.credit": "credito", "workspace.payments.type.subscription": "sottoscrizione", - "workspace.payments.view": "Visualizzazione", + "workspace.payments.view": "Visualizza", "workspace.black.loading": "Caricamento...", "workspace.black.time.day": "giorno", "workspace.black.time.days": "giorni", @@ -452,14 +452,14 @@ export const dict = { "workspace.black.subscription.manage": "Gestisci abbonamento", "workspace.black.subscription.rollingUsage": "Utilizzo di 5 ore", "workspace.black.subscription.weeklyUsage": "Utilizzo settimanale", - "workspace.black.subscription.resetsIn": "Si reimposta", + "workspace.black.subscription.resetsIn": "Si reimposta tra", "workspace.black.subscription.useBalance": "Utilizza il saldo disponibile dopo aver raggiunto i limiti di utilizzo", "workspace.black.waitlist.title": "Lista d'attesa", - "workspace.black.waitlist.joined": "Sei in lista d'attesa per il piano nero ${{plan}} al mese OpenCode.", + "workspace.black.waitlist.joined": "Sei in lista d'attesa per il piano OpenCode Black da ${{plan}} al mese.", "workspace.black.waitlist.ready": "Siamo pronti per iscriverti al piano OpenCode Black da ${{plan}} al mese.", "workspace.black.waitlist.leave": "Lascia la lista d'attesa", - "workspace.black.waitlist.leaving": "In partenza...", - "workspace.black.waitlist.left": "Sinistra", + "workspace.black.waitlist.leaving": "Uscita in corso...", + "workspace.black.waitlist.left": "Uscito dalla lista d'attesa", "workspace.black.waitlist.enroll": "Iscriversi", "workspace.black.waitlist.enrolling": "Iscrizione...", "workspace.black.waitlist.enrolled": "Iscritto", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index 4a5553429f2e..b24ad2684c34 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -293,9 +293,9 @@ export const dict = { "changelog.hero.subtitle": "OpenCode \u7684\u65b0\u66f4\u65b0\u4e0e\u6539\u8fdb", "changelog.empty": "\u672a\u627e\u5230\u66f4\u65b0\u65e5\u5fd7\u6761\u76ee\u3002", "changelog.viewJson": "\u67e5\u770b JSON", - "workspace.nav.zen": "禅", + "workspace.nav.zen": "Zen", "workspace.nav.apiKeys": "API 键", - "workspace.nav.members": "会员", + "workspace.nav.members": "成员", "workspace.nav.billing": "计费", "workspace.nav.settings": "设置", "workspace.home.banner.beforeLink": "编码代理的可靠优化模型。", @@ -310,26 +310,26 @@ export const dict = { "workspace.newUser.feature.lockin.body": "将 Zen 与任何编码代理结合使用,并在需要时继续将其他提供程序与 opencode 结合使用。", "workspace.newUser.copyApiKey": "复制 API 密钥", - "workspace.newUser.copyKey": "复制钥匙", - "workspace.newUser.copied": "复制了!", + "workspace.newUser.copyKey": "复制密钥", + "workspace.newUser.copied": "已复制!", "workspace.newUser.step.enableBilling": "启用计费", - "workspace.newUser.step.login.before": "跑步", + "workspace.newUser.step.login.before": "运行", "workspace.newUser.step.login.after": "并选择 opencode", "workspace.newUser.step.pasteKey": "粘贴您的 API 密钥", "workspace.newUser.step.models.before": "启动 opencode 并运行", - "workspace.newUser.step.models.after": "选择型号", - "workspace.models.title": "型号", + "workspace.newUser.step.models.after": "选择模型", + "workspace.models.title": "模型", "workspace.models.subtitle.beforeLink": "管理工作区成员可以访问哪些模型。", "workspace.models.table.model": "模型", "workspace.models.table.enabled": "启用", - "workspace.providers.title": "带上你自己的钥匙", + "workspace.providers.title": "自带密钥", "workspace.providers.subtitle": "从 AI 提供商处配置您自己的 API 密钥。", "workspace.providers.placeholder": "输入 {{provider}} API 密钥({{prefix}}...)", "workspace.providers.configure": "配置", "workspace.providers.edit": "编辑", "workspace.providers.delete": "删除", "workspace.providers.saving": "保存...", - "workspace.providers.save": "节省", + "workspace.providers.save": "保存", "workspace.providers.table.provider": "提供者", "workspace.providers.table.apiKey": "API 密钥", "workspace.usage.title": "使用历史", @@ -348,25 +348,25 @@ export const dict = { "workspace.usage.subscription": "订阅 (${{amount}})", "workspace.cost.title": "成本", "workspace.cost.subtitle": "按型号细分的使用成本。", - "workspace.cost.allModels": "所有型号", - "workspace.cost.allKeys": "所有按键", + "workspace.cost.allModels": "所有模型", + "workspace.cost.allKeys": "所有密钥", "workspace.cost.deletedSuffix": "(已删除)", "workspace.cost.empty": "所选期间没有可用的使用数据。", - "workspace.cost.subscriptionShort": "子", + "workspace.cost.subscriptionShort": "订", "workspace.keys.title": "API 键", "workspace.keys.subtitle": "管理您的 API 密钥以访问 opencode 服务。", "workspace.keys.create": "创建 API 密钥", - "workspace.keys.placeholder": "输入按键名称", + "workspace.keys.placeholder": "输入密钥名称", "workspace.keys.empty": "创建 opencode 网关 API 密钥", - "workspace.keys.table.name": "姓名", - "workspace.keys.table.key": "钥匙", + "workspace.keys.table.name": "名称", + "workspace.keys.table.key": "密钥", "workspace.keys.table.createdBy": "创建者", "workspace.keys.table.lastUsed": "最后使用", "workspace.keys.copyApiKey": "复制 API 密钥", "workspace.keys.delete": "删除", - "workspace.members.title": "会员", + "workspace.members.title": "成员", "workspace.members.subtitle": "管理工作区成员及其权限。", - "workspace.members.invite": "邀请会员", + "workspace.members.invite": "邀请成员", "workspace.members.inviting": "邀请...", "workspace.members.beta.beforeLink": "测试期间,工作空间对团队免费。", "workspace.members.form.invitee": "受邀者", @@ -379,11 +379,11 @@ export const dict = { "workspace.members.edit": "编辑", "workspace.members.delete": "删除", "workspace.members.saving": "保存...", - "workspace.members.save": "节省", + "workspace.members.save": "保存", "workspace.members.table.email": "电子邮件", "workspace.members.table.role": "角色", - "workspace.members.table.monthLimit": "月份限制", - "workspace.members.role.admin": "行政", + "workspace.members.table.monthLimit": "月限额", + "workspace.members.role.admin": "管理员", "workspace.members.role.adminDescription": "可以管理模型、成员和计费", "workspace.members.role.member": "成员", "workspace.members.role.memberDescription": "只能为自己生成 API 密钥", @@ -392,7 +392,7 @@ export const dict = { "workspace.settings.workspaceName": "工作区名称", "workspace.settings.defaultName": "默认", "workspace.settings.updating": "更新中...", - "workspace.settings.save": "节省", + "workspace.settings.save": "保存", "workspace.settings.edit": "编辑", "workspace.billing.title": "计费", "workspace.billing.subtitle.beforeLink": "管理付款方式。", @@ -404,35 +404,35 @@ export const dict = { "workspace.billing.loading": "加载中...", "workspace.billing.addAction": "添加", "workspace.billing.addBalance": "添加余额", - "workspace.billing.linkedToStripe": "链接到条纹", + "workspace.billing.linkedToStripe": "已绑定 Stripe", "workspace.billing.manage": "管理", "workspace.billing.enable": "启用计费", "workspace.monthlyLimit.title": "每月限额", "workspace.monthlyLimit.subtitle": "为您的帐户设置每月使用限额。", "workspace.monthlyLimit.placeholder": "50", - "workspace.monthlyLimit.setting": "环境...", - "workspace.monthlyLimit.set": "放", + "workspace.monthlyLimit.setting": "设置中...", + "workspace.monthlyLimit.set": "设置", "workspace.monthlyLimit.edit": "编辑限制", "workspace.monthlyLimit.noLimit": "没有设置使用限制。", - "workspace.monthlyLimit.currentUsage.beforeMonth": "当前使用情况为", - "workspace.monthlyLimit.currentUsage.beforeAmount": "是 $", - "workspace.reload.title": "自动重新加载", - "workspace.reload.disabled.before": "自动重新加载是", - "workspace.reload.disabled.state": "残疾人", - "workspace.reload.disabled.after": "启用余额不足时自动充值。", - "workspace.reload.enabled.before": "自动重新加载是", + "workspace.monthlyLimit.currentUsage.beforeMonth": "当前", + "workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量为 $", + "workspace.reload.title": "自动充值", + "workspace.reload.disabled.before": "自动充值已", + "workspace.reload.disabled.state": "停用", + "workspace.reload.disabled.after": "启用后将在余额较低时自动充值。", + "workspace.reload.enabled.before": "自动充值已", "workspace.reload.enabled.state": "已启用", - "workspace.reload.enabled.middle": "我们将重新加载", - "workspace.reload.processingFee": "加工费", + "workspace.reload.enabled.middle": "我们将自动充值", + "workspace.reload.processingFee": "手续费", "workspace.reload.enabled.after": "当余额达到", "workspace.reload.edit": "编辑", - "workspace.reload.enable": "使能够", - "workspace.reload.enableAutoReload": "启用自动重新加载", - "workspace.reload.reloadAmount": "重新加载 $", + "workspace.reload.enable": "启用", + "workspace.reload.enableAutoReload": "启用自动充值", + "workspace.reload.reloadAmount": "充值 $", "workspace.reload.whenBalanceReaches": "当余额达到 $", "workspace.reload.saving": "保存...", - "workspace.reload.save": "节省", - "workspace.reload.failedAt": "重新加载失败于", + "workspace.reload.save": "保存", + "workspace.reload.failedAt": "充值失败于", "workspace.reload.reason": "原因:", "workspace.reload.updatePaymentMethod": "请更新您的付款方式并重试。", "workspace.reload.retrying": "正在重试...", @@ -441,11 +441,11 @@ export const dict = { "workspace.payments.subtitle": "最近的付款交易。", "workspace.payments.table.date": "日期", "workspace.payments.table.paymentId": "付款ID", - "workspace.payments.table.amount": "数量", + "workspace.payments.table.amount": "金额", "workspace.payments.table.receipt": "收据", "workspace.payments.type.credit": "信用", "workspace.payments.type.subscription": "订阅", - "workspace.payments.view": "看法", + "workspace.payments.view": "查看", "workspace.black.loading": "加载中...", "workspace.black.time.day": "天", "workspace.black.time.days": "天", @@ -455,20 +455,20 @@ export const dict = { "workspace.black.time.minutes": "分钟", "workspace.black.time.fewSeconds": "几秒钟", "workspace.black.subscription.title": "订阅", - "workspace.black.subscription.message": "您已订阅 OpenCode Black,每月费用为 {{plan}} 美元。", + "workspace.black.subscription.message": "您已订阅 OpenCode Black,费用为每月 ${{plan}}。", "workspace.black.subscription.manage": "管理订阅", "workspace.black.subscription.rollingUsage": "5小时使用", "workspace.black.subscription.weeklyUsage": "每周使用量", "workspace.black.subscription.resetsIn": "重置于", "workspace.black.subscription.useBalance": "达到使用限额后使用您的可用余额", "workspace.black.waitlist.title": "候补名单", - "workspace.black.waitlist.joined": "您正在等待每月 ${{plan}} OpenCode 黑色计划。", - "workspace.black.waitlist.ready": "我们已准备好让您加入每月 {{plan}} 美元的 OpenCode 黑色计划。", + "workspace.black.waitlist.joined": "您已加入每月 ${{plan}} 的 OpenCode Black 方案候补名单。", + "workspace.black.waitlist.ready": "我们已准备好将您加入每月 ${{plan}} 的 OpenCode Black 方案。", "workspace.black.waitlist.leave": "离开候补名单", "workspace.black.waitlist.leaving": "离开...", - "workspace.black.waitlist.left": "左边", - "workspace.black.waitlist.enroll": "注册", - "workspace.black.waitlist.enrolling": "正在报名...", - "workspace.black.waitlist.enrolled": "已注册", + "workspace.black.waitlist.left": "已退出", + "workspace.black.waitlist.enroll": "加入", + "workspace.black.waitlist.enrolling": "加入中...", + "workspace.black.waitlist.enrolled": "已加入", "workspace.black.waitlist.enrollNote": "单击“注册”后,您的订阅将立即开始,并且将从您的卡中扣费。", } satisfies Dict diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 38bd448e303c..2de7c3996015 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -293,9 +293,9 @@ export const dict = { "changelog.hero.subtitle": "OpenCode \u7684\u65b0\u66f4\u65b0\u8207\u6539\u5584", "changelog.empty": "\u627e\u4e0d\u5230\u66f4\u65b0\u65e5\u8a8c\u9805\u76ee\u3002", "changelog.viewJson": "\u6aa2\u8996 JSON", - "workspace.nav.zen": "禪", + "workspace.nav.zen": "Zen", "workspace.nav.apiKeys": "API 鍵", - "workspace.nav.members": "會員", + "workspace.nav.members": "成員", "workspace.nav.billing": "計費", "workspace.nav.settings": "設定", "workspace.home.banner.beforeLink": "編碼代理的可靠優化模型。", @@ -310,26 +310,26 @@ export const dict = { "workspace.newUser.feature.lockin.body": "將 Zen 與任何編碼代理結合使用,並在需要時繼續將其他提供程序與 opencode 結合使用。", "workspace.newUser.copyApiKey": "複製 API 密鑰", - "workspace.newUser.copyKey": "複製鑰匙", - "workspace.newUser.copied": "複製了!", + "workspace.newUser.copyKey": "複製密鑰", + "workspace.newUser.copied": "已複製!", "workspace.newUser.step.enableBilling": "啟用計費", - "workspace.newUser.step.login.before": "跑步", + "workspace.newUser.step.login.before": "執行", "workspace.newUser.step.login.after": "並選擇 opencode", "workspace.newUser.step.pasteKey": "粘貼您的 API 密鑰", "workspace.newUser.step.models.before": "啟動 opencode 並運行", - "workspace.newUser.step.models.after": "選擇型號", - "workspace.models.title": "型號", + "workspace.newUser.step.models.after": "選擇模型", + "workspace.models.title": "模型", "workspace.models.subtitle.beforeLink": "管理工作區成員可以訪問哪些模型。", "workspace.models.table.model": "模型", "workspace.models.table.enabled": "啟用", - "workspace.providers.title": "帶上你自己的鑰匙", + "workspace.providers.title": "自帶密鑰", "workspace.providers.subtitle": "從 AI 提供商處配置您自己的 API 密鑰。", "workspace.providers.placeholder": "輸入 {{provider}} API 密鑰({{prefix}}...)", "workspace.providers.configure": "配置", "workspace.providers.edit": "編輯", "workspace.providers.delete": "刪除", "workspace.providers.saving": "保存...", - "workspace.providers.save": "節省", + "workspace.providers.save": "儲存", "workspace.providers.table.provider": "提供者", "workspace.providers.table.apiKey": "API 密鑰", "workspace.usage.title": "使用歷史", @@ -348,25 +348,25 @@ export const dict = { "workspace.usage.subscription": "訂閱 (${{amount}})", "workspace.cost.title": "成本", "workspace.cost.subtitle": "按型號細分的使用成本。", - "workspace.cost.allModels": "所有型號", - "workspace.cost.allKeys": "所有按鍵", + "workspace.cost.allModels": "所有模型", + "workspace.cost.allKeys": "所有密鑰", "workspace.cost.deletedSuffix": "(已刪除)", "workspace.cost.empty": "所選期間沒有可用的使用數據。", - "workspace.cost.subscriptionShort": "子", + "workspace.cost.subscriptionShort": "訂", "workspace.keys.title": "API 鍵", "workspace.keys.subtitle": "管理您的 API 密鑰以訪問 opencode 服務。", "workspace.keys.create": "創建 API 密鑰", - "workspace.keys.placeholder": "輸入按鍵名稱", + "workspace.keys.placeholder": "輸入密鑰名稱", "workspace.keys.empty": "創建 opencode 網關 API 密鑰", - "workspace.keys.table.name": "姓名", - "workspace.keys.table.key": "鑰匙", + "workspace.keys.table.name": "名稱", + "workspace.keys.table.key": "密鑰", "workspace.keys.table.createdBy": "創建者", "workspace.keys.table.lastUsed": "最後使用", "workspace.keys.copyApiKey": "複製 API 密鑰", "workspace.keys.delete": "刪除", - "workspace.members.title": "會員", + "workspace.members.title": "成員", "workspace.members.subtitle": "管理工作區成員及其權限。", - "workspace.members.invite": "邀請會員", + "workspace.members.invite": "邀請成員", "workspace.members.inviting": "邀請...", "workspace.members.beta.beforeLink": "測試期間,工作空間對團隊免費。", "workspace.members.form.invitee": "受邀者", @@ -379,11 +379,11 @@ export const dict = { "workspace.members.edit": "編輯", "workspace.members.delete": "刪除", "workspace.members.saving": "保存...", - "workspace.members.save": "節省", + "workspace.members.save": "儲存", "workspace.members.table.email": "電子郵件", "workspace.members.table.role": "角色", - "workspace.members.table.monthLimit": "月份限制", - "workspace.members.role.admin": "行政", + "workspace.members.table.monthLimit": "月限額", + "workspace.members.role.admin": "管理員", "workspace.members.role.adminDescription": "可以管理模型、成員和計費", "workspace.members.role.member": "成員", "workspace.members.role.memberDescription": "只能為自己生成 API 密鑰", @@ -392,7 +392,7 @@ export const dict = { "workspace.settings.workspaceName": "工作區名稱", "workspace.settings.defaultName": "預設", "workspace.settings.updating": "更新中...", - "workspace.settings.save": "節省", + "workspace.settings.save": "儲存", "workspace.settings.edit": "編輯", "workspace.billing.title": "計費", "workspace.billing.subtitle.beforeLink": "管理付款方式。", @@ -404,35 +404,35 @@ export const dict = { "workspace.billing.loading": "載入中...", "workspace.billing.addAction": "添加", "workspace.billing.addBalance": "添加餘額", - "workspace.billing.linkedToStripe": "鏈接到條紋", + "workspace.billing.linkedToStripe": "已連結 Stripe", "workspace.billing.manage": "管理", "workspace.billing.enable": "啟用計費", "workspace.monthlyLimit.title": "每月限額", "workspace.monthlyLimit.subtitle": "為您的帳戶設置每月使用限額。", "workspace.monthlyLimit.placeholder": "50", - "workspace.monthlyLimit.setting": "環境...", - "workspace.monthlyLimit.set": "放", + "workspace.monthlyLimit.setting": "設定中...", + "workspace.monthlyLimit.set": "設定", "workspace.monthlyLimit.edit": "編輯限制", "workspace.monthlyLimit.noLimit": "沒有設置使用限制。", - "workspace.monthlyLimit.currentUsage.beforeMonth": "當前使用情況為", - "workspace.monthlyLimit.currentUsage.beforeAmount": "是 $", - "workspace.reload.title": "自動重新加載", - "workspace.reload.disabled.before": "自動重新加載是", - "workspace.reload.disabled.state": "殘疾人", - "workspace.reload.disabled.after": "啟用餘額不足時自動充值。", - "workspace.reload.enabled.before": "自動重新加載是", + "workspace.monthlyLimit.currentUsage.beforeMonth": "當前", + "workspace.monthlyLimit.currentUsage.beforeAmount": "的使用量為 $", + "workspace.reload.title": "自動儲值", + "workspace.reload.disabled.before": "自動儲值已", + "workspace.reload.disabled.state": "停用", + "workspace.reload.disabled.after": "啟用後會在餘額偏低時自動儲值。", + "workspace.reload.enabled.before": "自動儲值已", "workspace.reload.enabled.state": "已啟用", - "workspace.reload.enabled.middle": "我們將重新加載", - "workspace.reload.processingFee": "加工費", + "workspace.reload.enabled.middle": "我們將自動儲值", + "workspace.reload.processingFee": "手續費", "workspace.reload.enabled.after": "當餘額達到", "workspace.reload.edit": "編輯", - "workspace.reload.enable": "使能夠", - "workspace.reload.enableAutoReload": "啟用自動重新加載", - "workspace.reload.reloadAmount": "重新加載 $", + "workspace.reload.enable": "啟用", + "workspace.reload.enableAutoReload": "啟用自動儲值", + "workspace.reload.reloadAmount": "儲值 $", "workspace.reload.whenBalanceReaches": "當餘額達到 $", "workspace.reload.saving": "保存...", - "workspace.reload.save": "節省", - "workspace.reload.failedAt": "重新加載失敗於", + "workspace.reload.save": "儲存", + "workspace.reload.failedAt": "儲值失敗於", "workspace.reload.reason": "原因:", "workspace.reload.updatePaymentMethod": "請更新您的付款方式並重試。", "workspace.reload.retrying": "正在重試...", @@ -441,11 +441,11 @@ export const dict = { "workspace.payments.subtitle": "最近的付款交易。", "workspace.payments.table.date": "日期", "workspace.payments.table.paymentId": "付款ID", - "workspace.payments.table.amount": "數量", + "workspace.payments.table.amount": "金額", "workspace.payments.table.receipt": "收據", "workspace.payments.type.credit": "信用", "workspace.payments.type.subscription": "訂閱", - "workspace.payments.view": "看法", + "workspace.payments.view": "查看", "workspace.black.loading": "載入中...", "workspace.black.time.day": "天", "workspace.black.time.days": "天", @@ -455,20 +455,20 @@ export const dict = { "workspace.black.time.minutes": "分鐘", "workspace.black.time.fewSeconds": "幾秒鐘", "workspace.black.subscription.title": "訂閱", - "workspace.black.subscription.message": "您已訂閱 OpenCode Black,每月費用為 {{plan}} 美元。", + "workspace.black.subscription.message": "您已訂閱 OpenCode Black,費用為每月 ${{plan}}。", "workspace.black.subscription.manage": "管理訂閱", "workspace.black.subscription.rollingUsage": "5小時使用", "workspace.black.subscription.weeklyUsage": "每週使用量", "workspace.black.subscription.resetsIn": "重置於", "workspace.black.subscription.useBalance": "達到使用限額後使用您的可用餘額", "workspace.black.waitlist.title": "候補名單", - "workspace.black.waitlist.joined": "您正在等待每月 ${{plan}} OpenCode 黑色計劃。", - "workspace.black.waitlist.ready": "我們已準備好讓您加入每月 {{plan}} 美元的 OpenCode 黑色計劃。", + "workspace.black.waitlist.joined": "您已加入每月 ${{plan}} 的 OpenCode Black 方案候補名單。", + "workspace.black.waitlist.ready": "我們已準備好將您加入每月 ${{plan}} 的 OpenCode Black 方案。", "workspace.black.waitlist.leave": "離開候補名單", "workspace.black.waitlist.leaving": "離開...", - "workspace.black.waitlist.left": "左邊", - "workspace.black.waitlist.enroll": "註冊", - "workspace.black.waitlist.enrolling": "正在報名...", - "workspace.black.waitlist.enrolled": "已註冊", + "workspace.black.waitlist.left": "已退出", + "workspace.black.waitlist.enroll": "加入", + "workspace.black.waitlist.enrolling": "加入中...", + "workspace.black.waitlist.enrolled": "已加入", "workspace.black.waitlist.enrollNote": "單擊“註冊”後,您的訂閱將立即開始,並且將從您的卡中扣費。", } satisfies Dict diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 91fa306af45a..af2a8c3e60b0 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -119,6 +119,9 @@ export async function handler( Object.entries(providerInfo.headerMappings ?? {}).forEach(([k, v]) => { headers.set(k, headers.get(v)!) }) + Object.entries(providerInfo.headers ?? {}).forEach(([k, v]) => { + headers.set(k, v) + }) headers.delete("host") headers.delete("content-length") headers.delete("x-opencode-request") @@ -250,13 +253,18 @@ export async function handler( part = part.trim() usageParser.parse(part) - if (providerInfo.format !== opts.format) { + if (providerInfo.bodyModifier) { + for (const [k, v] of Object.entries(providerInfo.bodyModifier)) { + part = part.replace(k, v) + } + c.enqueue(encoder.encode(part + "\n\n")) + } else if (providerInfo.format !== opts.format) { part = streamConverter(part) c.enqueue(encoder.encode(part + "\n\n")) } } - if (providerInfo.format === opts.format) { + if (!providerInfo.bodyModifier && providerInfo.format === opts.format) { c.enqueue(value) } diff --git a/packages/console/core/script/promote-models.ts b/packages/console/core/script/promote-models.ts index 62d9bd6ff903..17715bedfb8a 100755 --- a/packages/console/core/script/promote-models.ts +++ b/packages/console/core/script/promote-models.ts @@ -9,7 +9,7 @@ const stage = process.argv[2] if (!stage) throw new Error("Stage is required") const root = path.resolve(process.cwd(), "..", "..", "..") -const PARTS = 10 +const PARTS = 20 // read the secret const ret = await $`bun sst secret list`.cwd(root).text() diff --git a/packages/console/core/script/pull-models.ts b/packages/console/core/script/pull-models.ts index 01240a71553f..4c376210ff66 100755 --- a/packages/console/core/script/pull-models.ts +++ b/packages/console/core/script/pull-models.ts @@ -9,7 +9,7 @@ const stage = process.argv[2] if (!stage) throw new Error("Stage is required") const root = path.resolve(process.cwd(), "..", "..", "..") -const PARTS = 10 +const PARTS = 20 // read the secret const ret = await $`bun sst secret list --stage ${stage}`.cwd(root).text() diff --git a/packages/console/core/script/update-models.ts b/packages/console/core/script/update-models.ts index 7d1fa99c3d16..9025a6526e5e 100755 --- a/packages/console/core/script/update-models.ts +++ b/packages/console/core/script/update-models.ts @@ -7,7 +7,7 @@ import { ZenData } from "../src/model" const root = path.resolve(process.cwd(), "..", "..", "..") const models = await $`bun sst secret list`.cwd(root).text() -const PARTS = 10 +const PARTS = 20 // read the line starting with "ZEN_MODELS" const lines = models.split("\n") diff --git a/packages/console/core/src/model.ts b/packages/console/core/src/model.ts index 831b3c5fc9cc..e1e540fb7a44 100644 --- a/packages/console/core/src/model.ts +++ b/packages/console/core/src/model.ts @@ -53,7 +53,8 @@ export namespace ZenData { weight: z.number().optional(), disabled: z.boolean().optional(), storeModel: z.string().optional(), - headerMappings: z.record(z.string(), z.string()).optional(), + headers: z.record(z.string(), z.string()).optional(), + bodyModifier: z.record(z.string(), z.string()).optional(), }), ), }) @@ -85,7 +86,17 @@ export namespace ZenData { Resource.ZEN_MODELS7.value + Resource.ZEN_MODELS8.value + Resource.ZEN_MODELS9.value + - Resource.ZEN_MODELS10.value, + Resource.ZEN_MODELS10.value + + Resource.ZEN_MODELS11.value + + Resource.ZEN_MODELS12.value + + Resource.ZEN_MODELS13.value + + Resource.ZEN_MODELS14.value + + Resource.ZEN_MODELS15.value + + Resource.ZEN_MODELS16.value + + Resource.ZEN_MODELS17.value + + Resource.ZEN_MODELS18.value + + Resource.ZEN_MODELS19.value + + Resource.ZEN_MODELS20.value, ) return ModelsSchema.parse(json) }) diff --git a/packages/console/core/sst-env.d.ts b/packages/console/core/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/console/core/sst-env.d.ts +++ b/packages/console/core/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/function/sst-env.d.ts b/packages/console/function/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/console/function/sst-env.d.ts +++ b/packages/console/function/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/console/resource/sst-env.d.ts b/packages/console/resource/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/console/resource/sst-env.d.ts +++ b/packages/console/resource/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 36bbe7bc2c12..8f09ed169ff5 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -18,6 +18,7 @@ "@solid-primitives/i18n": "2.2.1", "@solid-primitives/storage": "catalog:", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-clipboard-manager": "~2", "@tauri-apps/plugin-deep-link": "~2", "@tauri-apps/plugin-dialog": "~2", "@tauri-apps/plugin-opener": "^2", diff --git a/packages/desktop/src-tauri/capabilities/default.json b/packages/desktop/src-tauri/capabilities/default.json index 2d38d49a985e..4d0276c832ea 100644 --- a/packages/desktop/src-tauri/capabilities/default.json +++ b/packages/desktop/src-tauri/capabilities/default.json @@ -46,6 +46,7 @@ { "identifier": "http:default", "allow": [{ "url": "http://*" }, { "url": "https://*" }, { "url": "http://*:*/*" }] - } + }, + "clipboard-manager:allow-read-image" ] } diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index d3377e95aa3e..dd78224e345c 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -16,6 +16,7 @@ import { fetch as tauriFetch } from "@tauri-apps/plugin-http" import { Store } from "@tauri-apps/plugin-store" import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" +import { readImage } from "@tauri-apps/plugin-clipboard-manager" import { UPDATER_ENABLED } from "./updater" import { initI18n, t } from "./i18n" @@ -344,6 +345,29 @@ const createPlatform = (password: Accessor): Platform => ({ checkAppExists: async (appName: string) => { return commands.checkAppExists(appName) }, + + async readClipboardImage() { + const image = await readImage().catch(() => null) + if (!image) return null + const bytes = await image.rgba().catch(() => null) + if (!bytes || bytes.length === 0) return null + const size = await image.size().catch(() => null) + if (!size) return null + const canvas = document.createElement("canvas") + canvas.width = size.width + canvas.height = size.height + const ctx = canvas.getContext("2d") + if (!ctx) return null + const imageData = ctx.createImageData(size.width, size.height) + imageData.data.set(bytes) + ctx.putImageData(imageData, 0, 0) + return new Promise((resolve) => { + canvas.toBlob((blob) => { + if (!blob) return resolve(null) + resolve(new File([blob], `pasted-image-${Date.now()}.png`, { type: "image/png" })) + }, "image/png") + }) + }, }) let menuTrigger = null as null | ((id: string) => void) diff --git a/packages/enterprise/sst-env.d.ts b/packages/enterprise/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/enterprise/sst-env.d.ts +++ b/packages/enterprise/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/function/sst-env.d.ts b/packages/function/sst-env.d.ts index 0769c76335b1..fea908213dfc 100644 --- a/packages/function/sst-env.d.ts +++ b/packages/function/sst-env.d.ts @@ -137,10 +137,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string diff --git a/packages/opencode/script/build.ts b/packages/opencode/script/build.ts index b55f04d87048..f0b3fa828a78 100755 --- a/packages/opencode/script/build.ts +++ b/packages/opencode/script/build.ts @@ -1,6 +1,6 @@ #!/usr/bin/env bun -import solidPlugin from "../../../node_modules/@opentui/solid/scripts/solid-plugin" +import solidPlugin from "../node_modules/@opentui/solid/scripts/solid-plugin" import path from "path" import fs from "fs" import { $ } from "bun" diff --git a/packages/opencode/src/file/ripgrep.ts b/packages/opencode/src/file/ripgrep.ts index c1e5113bf89c..58f9af7cdbab 100644 --- a/packages/opencode/src/file/ripgrep.ts +++ b/packages/opencode/src/file/ripgrep.ts @@ -123,9 +123,13 @@ export namespace Ripgrep { ) const state = lazy(async () => { - let filepath = Bun.which("rg") - if (filepath) return { filepath } - filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) + const system = Bun.which("rg") + if (system) { + const stat = await fs.stat(system).catch(() => undefined) + if (stat?.isFile()) return { filepath: system } + log.warn("bun.which returned invalid rg path", { filepath: system }) + } + const filepath = path.join(Global.Path.bin, "rg" + (process.platform === "win32" ? ".exe" : "")) const file = Bun.file(filepath) if (!(await file.exists())) { diff --git a/packages/ui/src/components/text-field.tsx b/packages/ui/src/components/text-field.tsx index 56e849664f21..d10f5d6ace57 100644 --- a/packages/ui/src/components/text-field.tsx +++ b/packages/ui/src/components/text-field.tsx @@ -27,6 +27,7 @@ export interface TextFieldProps error?: string variant?: "normal" | "ghost" copyable?: boolean + copyKind?: "clipboard" | "link" multiline?: boolean } @@ -49,10 +50,23 @@ export function TextField(props: TextFieldProps) { "error", "variant", "copyable", + "copyKind", "multiline", ]) const [copied, setCopied] = createSignal(false) + const label = () => { + if (copied()) return i18n.t("ui.textField.copied") + if (local.copyKind === "link") return i18n.t("ui.textField.copyLink") + return i18n.t("ui.textField.copyToClipboard") + } + + const icon = () => { + if (copied()) return "check" + if (local.copyKind === "link") return "link" + return "copy" + } + async function handleCopy() { const value = local.value ?? local.defaultValue ?? "" await navigator.clipboard.writeText(value) @@ -92,21 +106,15 @@ export function TextField(props: TextFieldProps) { - + diff --git a/specs/01-persist-payload-limits.md b/specs/01-persist-payload-limits.md deleted file mode 100644 index 0e6d422f34ac..000000000000 --- a/specs/01-persist-payload-limits.md +++ /dev/null @@ -1,206 +0,0 @@ -## Payload limits - -Prevent blocking storage writes and runaway persisted size - ---- - -### Summary - -Large payloads (base64 images, terminal buffers) are currently persisted inside key-value stores: - -- web: `localStorage` (sync, blocks the main thread) -- desktop: Tauri Store-backed async storage files (still expensive when values are huge) - -We’ll introduce size-aware persistence policies plus a dedicated “blob store” for large/binary data (IndexedDB on web; separate files on desktop). Prompt/history state will persist only lightweight references to blobs and load them on demand. - ---- - -### Goals - -- Stop persisting image `dataUrl` blobs inside web `localStorage` -- Stop persisting image `dataUrl` blobs inside desktop store `.dat` files -- Store image payloads out-of-band (blob store) and load lazily when needed (e.g. when restoring a history item) -- Prevent terminal buffer persistence from exceeding safe size limits -- Keep persistence behavior predictable across web (sync) and desktop (async) -- Provide escape hatches via flags and per-key size caps - ---- - -### Non-goals - -- Cross-device sync of images or terminal buffers -- Lossless persistence of full terminal scrollback on web -- Perfect blob deduplication or a complex reference-counting system on day one - ---- - -### Current state - -- `packages/app/src/utils/persist.ts` uses `localStorage` (sync) on web and async storage only on desktop. -- Desktop storage is implemented via `@tauri-apps/plugin-store` and writes to named `.dat` files (see `packages/desktop/src/index.tsx`). Large values bloat these files and increase flush costs. -- Prompt history persists under `Persist.global("prompt-history")` (`packages/app/src/components/prompt-input.tsx`) and can include image parts (`dataUrl`). -- Prompt draft persistence uses `packages/app/src/context/prompt.tsx` and can also include image parts (`dataUrl`). -- Terminal buffer is serialized in `packages/app/src/components/terminal.tsx` and persisted in `packages/app/src/context/terminal.tsx`. - ---- - -### Proposed approach - -#### 1) Add per-key persistence policies (KV store guardrails) - -In `packages/app/src/utils/persist.ts`, add policy hooks for each persisted key: - -- `warnBytes` (soft warning threshold) -- `maxBytes` (hard cap) -- `transformIn` / `transformOut` for lossy persistence (e.g. strip or refactor fields) -- `onOversize` strategy: `drop`, `truncate`, or `migrateToBlobRef` - -This protects both: - -- web (`localStorage` is sync) -- desktop (async, but still expensive to store/flush giant values) - -#### 2) Add a dedicated blob store for large data - -Introduce a small blob-store abstraction used by the app layer: - -- web backend: IndexedDB (store `Blob` values keyed by `id`) -- desktop backend: filesystem directory under the app data directory (store one file per blob) - -Store _references_ to blobs inside the persisted JSON instead of the blob contents. - -#### 3) Persist image parts as references (not base64 payloads) - -Update the prompt image model so the in-memory shape can still use a `dataUrl` for UI, but the persisted representation is reference-based. - -Suggested approach: - -- Keep `ImageAttachmentPart` with: - - required: `id`, `filename`, `mime` - - optional/ephemeral: `dataUrl?: string` - - new: `blobID?: string` (or `ref: string`) - -Persistence rules: - -- When writing persisted prompt/history state: - - ensure each image part is stored in blob store (`blobID`) - - persist only metadata + `blobID` (no `dataUrl`) -- When reading persisted prompt/history state: - - do not eagerly load blob payloads - - hydrate `dataUrl` only when needed: - - when applying a history entry into the editor - - before submission (ensure all image parts have usable `dataUrl`) - - when rendering an attachment preview, if required - ---- - -### Phased implementation steps - -1. Add guardrails in `persist.ts` - -- Implement size estimation in `packages/app/src/utils/persist.ts` using `TextEncoder` byte length on JSON strings. -- Add a policy registry keyed by persist name (e.g. `"prompt-history"`, `"prompt"`, `"terminal"`). -- Add a feature flag (e.g. `persist.payloadLimits`) to enable enforcement gradually. - -2. Add blob-store abstraction + platform hooks - -- Add a new app-level module (e.g. `packages/app/src/utils/blob.ts`) defining: - - `put(id, bytes|Blob)` - - `get(id)` - - `remove(id)` -- Extend the `Platform` interface (`packages/app/src/context/platform.tsx`) with optional blob methods, or provide a default web implementation and override on desktop: - - web: implement via IndexedDB - - desktop: implement via filesystem files (requires adding a Tauri fs plugin or `invoke` wrappers) - -3. Update prompt history + prompt draft persistence to use blob refs - -- Update prompt/history serialization paths to ensure image parts are stored as blob refs: - - Prompt history: `packages/app/src/components/prompt-input.tsx` - - Prompt draft: `packages/app/src/context/prompt.tsx` -- Ensure “apply history prompt” hydrates image blobs only when applying the prompt (not during background load). - -4. One-time migration for existing persisted base64 images - -- On read, detect legacy persisted image parts that include `dataUrl`. -- If a `dataUrl` is found: - - write it into the blob store (convert dataUrl → bytes) - - replace persisted payload with `{ blobID, filename, mime, id }` only - - re-save the reduced version -- If migration fails (missing permissions, quota, etc.), fall back to: - - keep the prompt entry but drop the image payload and mark as unavailable - -5. Fix terminal persistence (bounded snapshot) - -- In `packages/app/src/context/terminal.tsx`, persist only: - - last `maxLines` and/or - - last `maxBytes` of combined text -- In `packages/app/src/components/terminal.tsx`, keep the full in-memory buffer unchanged. - -6. Add basic blob lifecycle cleanup - To avoid “blob directory grows forever”, add one of: - -- TTL-based cleanup: store `lastAccessed` per blob and delete blobs older than N days -- Reference scan cleanup: periodically scan prompt-history + prompt drafts, build a set of referenced `blobID`s, and delete unreferenced blobs - -Start with TTL-based cleanup (simpler, fewer cross-store dependencies), then consider scan-based cleanup if needed. - ---- - -### Data migration / backward compatibility - -- KV store data: - - policies should be tolerant of missing fields (e.g. `dataUrl` missing) -- Image parts: - - treat missing `dataUrl` as “not hydrated yet” - - treat missing `blobID` (legacy) as “not persisted” or “needs migration” -- Desktop: - - blob files should be namespaced (e.g. `opencode/blobs/`) to avoid collisions - ---- - -### Risk + mitigations - -- Risk: blob store is unavailable (IndexedDB disabled, desktop fs permissions). - - Mitigation: keep base state functional; persist prompts without image payloads and show a clear placeholder. -- Risk: lazy hydration introduces edge cases when submitting. - - Mitigation: add a pre-submit “ensure images hydrated” step; if hydration fails, block submission with a clear error or submit without images. -- Risk: dataUrl→bytes conversion cost during migration. - - Mitigation: migrate incrementally (only when reading an entry) and/or use `requestIdleCallback` on web. -- Risk: blob cleanup deletes blobs still needed. - - Mitigation: TTL default should be conservative; scan-based cleanup should only delete blobs unreferenced by current persisted state. - ---- - -### Validation plan - -- Unit-level: - - size estimation + policy enforcement in `persist.ts` - - blob store put/get/remove round trips (web + desktop backends) -- Manual scenarios: - - attach multiple images, reload, and confirm: - - KV store files do not balloon - - images can be restored when selecting history items - - open terminal with large output and confirm reload restores bounded snapshot quickly - - confirm prompt draft persistence still works in `packages/app/src/context/prompt.tsx` - ---- - -### Rollout plan - -- Phase 1: ship with `persist.payloadLimits` off; log oversize detections in dev. -- Phase 2: enable image blob refs behind `persist.imageBlobs` (web + desktop). -- Phase 3: enable terminal truncation and enforce hard caps for known hot keys. -- Phase 4: enable blob cleanup behind `persist.blobGc` (TTL first). -- Provide quick kill switches by disabling each flag independently. - ---- - -### Open questions - -- What should the canonical persisted image schema be (`blobID` field name, placeholder shape, etc.)? -- Desktop implementation detail: - - add `@tauri-apps/plugin-fs` vs custom `invoke()` commands for blob read/write? - - where should blob files live (appDataDir) and what retention policy is acceptable? -- Web implementation detail: - - do we store `Blob` directly in IndexedDB, or store base64 strings? -- Should prompt-history images be retained indefinitely, or only for the last `MAX_HISTORY` entries? diff --git a/specs/02-cache-eviction.md b/specs/02-cache-eviction.md deleted file mode 100644 index 08b1c34857cb..000000000000 --- a/specs/02-cache-eviction.md +++ /dev/null @@ -1,141 +0,0 @@ -## Cache eviction - -Add explicit bounds for long-lived in-memory state - ---- - -### Summary - -Several in-memory caches grow without limits during long sessions. We’ll introduce explicit eviction (LRU + TTL + size caps) for sessions/messages/file contents and global per-directory sync stores. - ---- - -### Goals - -- Prevent unbounded memory growth from caches that survive navigation -- Add consistent eviction primitives shared across contexts -- Keep UI responsive under heavy usage (many sessions, large files) - ---- - -### Non-goals - -- Perfect cache hit rates or prefetch strategies -- Changing server APIs or adding background jobs -- Persisting caches for offline use - ---- - -### Current state - -- Global sync uses per-directory child stores without eviction in `packages/app/src/context/global-sync.tsx`. -- File contents cached in `packages/app/src/context/file.tsx` with no cap. -- Session-heavy pages include `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx`. - ---- - -### Proposed approach - -- Introduce a shared cache utility that supports: - - `maxEntries`, `maxBytes` (approx), and `ttlMs` - - LRU ordering with explicit `touch(key)` on access - - deterministic `evict()` and `clear()` APIs -- Apply the utility to: - - global-sync per-directory child stores (cap number of directories kept “hot”) - - file contents cache (cap by entries + bytes, with TTL) - - session/message caches (cap by session count, and optionally message count) -- Add feature flags per cache domain to allow partial rollout (e.g. `cache.eviction.files`). - ---- - -### Phased implementation steps - -1. Add a generic cache helper - -- Create `packages/app/src/utils/cache.ts` with a small, dependency-free LRU+TTL. -- Keep it framework-agnostic and usable from Solid contexts. - -Sketch: - -```ts -type CacheOpts = { - maxEntries: number - ttlMs?: number - maxBytes?: number - sizeOf?: (value: unknown) => number -} - -function createLruCache(opts: CacheOpts) { - // get, set, delete, clear, evictExpired, stats -} -``` - -2. Apply eviction to file contents - -- In `packages/app/src/context/file.tsx`: - - wrap the existing file-content map in the LRU helper - - approximate size via `TextEncoder` length of content strings - - evict on `set` and periodically via `requestIdleCallback` when available -- Add a small TTL (e.g. 10–30 minutes) to discard stale contents. - -3. Apply eviction to global-sync child stores - -- In `packages/app/src/context/global-sync.tsx`: - - track child stores by directory key in an LRU with `maxEntries` - - call a `dispose()` hook on eviction to release subscriptions and listeners -- Ensure “currently active directory” is always `touch()`’d to avoid surprise evictions. - -4. Apply eviction to session/message caches - -- Identify the session/message caching touchpoints used by `packages/app/src/pages/session.tsx`. -- Add caps that reflect UI needs (e.g. last 10–20 sessions kept, last N messages per session if cached). - -5. Add developer tooling - -- Add a debug-only stats readout (console or dev panel) for cache sizes and eviction counts. -- Add a one-click “clear caches” action for troubleshooting. - ---- - -### Data migration / backward compatibility - -- No persisted schema changes are required since this targets in-memory caches. -- If any cache is currently mirrored into persistence, keep keys stable and only change in-memory retention. - ---- - -### Risk + mitigations - -- Risk: evicting content still needed causes extra refetches and flicker. - - Mitigation: always pin “active” entities and evict least-recently-used first. -- Risk: disposing global-sync child stores could leak listeners if not cleaned up correctly. - - Mitigation: require an explicit `dispose()` contract and add dev assertions for listener counts. -- Risk: approximate byte sizing is imprecise. - - Mitigation: combine entry caps with byte caps and keep thresholds conservative. - ---- - -### Validation plan - -- Add tests for `createLruCache` covering TTL expiry, LRU ordering, and eviction triggers. -- Manual scenarios: - - open many files and confirm memory stabilizes and UI remains responsive - - switch across many directories and confirm global-sync does not continuously grow - - long session navigation loop and confirm caches plateau - ---- - -### Rollout plan - -- Land cache utility first with flags default off. -- Enable file cache eviction first (lowest behavioral risk). -- Enable global-sync eviction next with conservative caps and strong logging in dev. -- Enable session/message eviction last after observing real usage patterns. - ---- - -### Open questions - -- What are the current session/message cache structures and their ownership boundaries? -- Which child stores in `global-sync.tsx` have resources that must be disposed explicitly? -- What caps are acceptable for typical workflows (files open, directories visited, sessions viewed)? diff --git a/specs/03-request-throttling.md b/specs/03-request-throttling.md deleted file mode 100644 index 0cd8c7fb0971..000000000000 --- a/specs/03-request-throttling.md +++ /dev/null @@ -1,145 +0,0 @@ -## Request throttling - -Debounce and cancel high-frequency server calls - ---- - -### Summary - -Some user interactions trigger bursts of server requests that can overlap and return out of order. We’ll debounce frequent triggers and cancel in-flight requests (or ignore stale results) for file search and LSP refresh. - ---- - -### Goals - -- Reduce redundant calls from file search and LSP refresh -- Prevent stale responses from overwriting newer UI state -- Preserve responsive typing and scrolling during high activity - ---- - -### Non-goals - -- Changing server-side behavior or adding new endpoints -- Implementing global request queues for all SDK calls -- Persisting search results across reloads - ---- - -### Current state - -- File search calls `sdk.client.find.files` via `files.searchFilesAndDirectories`. -- LSP refresh is triggered frequently (exact call sites vary, but the refresh behavior is high-frequency). -- Large UI modules involved include `packages/app/src/pages/layout.tsx` and `packages/app/src/components/prompt-input.tsx`. - ---- - -### Proposed approach - -- Add a small request coordinator utility: - - debounced triggering (leading/trailing configurable) - - cancellation via `AbortController` when supported - - stale-result protection via monotonic request ids when abort is not supported -- Integrate coordinator into: - - `files.searchFilesAndDirectories` (wrap `sdk.client.find.files`) - - LSP refresh call path (wrap refresh invocation and ensure only latest applies) - ---- - -### Phased implementation steps - -1. Add a debounced + cancellable helper - -- Create `packages/app/src/utils/requests.ts` with: - - `createDebouncedAsync(fn, delayMs)` - - `createLatestOnlyAsync(fn)` that drops stale responses -- Prefer explicit, readable primitives over a single complex abstraction. - -Sketch: - -```ts -function createLatestOnlyAsync( - fn: (args: { input: TArgs; signal?: AbortSignal }) => Promise, -) { - let id = 0 - let controller: AbortController | undefined - - return async (...input: TArgs) => { - id += 1 - const current = id - controller?.abort() - controller = new AbortController() - - const result = await fn({ input, signal: controller.signal }) - if (current !== id) return - return result - } -} -``` - -2. Apply to file search - -- Update `files.searchFilesAndDirectories` to: - - debounce input changes (e.g. 150–300 ms) - - abort prior request when a new query begins - - ignore results if they are stale -- Ensure “empty query” is handled locally without calling the server. - -3. Apply to LSP refresh - -- Identify the refresh trigger points used during typing and file switching. -- Add: - - debounce for rapid triggers (e.g. 250–500 ms) - - cancellation for in-flight refresh if supported - - last-write-wins behavior for applying diagnostics/results - -4. Add feature flags and metrics - -- Add flags: - - `requests.debounce.fileSearch` - - `requests.latestOnly.lspRefresh` -- Add simple dev-only counters for “requests started / aborted / applied”. - ---- - -### Data migration / backward compatibility - -- No persisted data changes. -- Behavior is compatible as long as UI state updates only when the “latest” request resolves. - ---- - -### Risk + mitigations - -- Risk: aggressive debounce makes UI feel laggy. - - Mitigation: keep delays small and tune separately for search vs refresh. -- Risk: aborting requests may surface as errors in logs. - - Mitigation: treat `AbortError` as expected and do not log it as a failure. -- Risk: SDK method may not accept `AbortSignal`. - - Mitigation: use request-id stale protection even without true cancellation. - ---- - -### Validation plan - -- Manual scenarios: - - type quickly in file search and confirm requests collapse and results stay correct - - trigger LSP refresh repeatedly and confirm diagnostics do not flicker backward -- Add a small unit test for latest-only behavior (stale results are ignored). - ---- - -### Rollout plan - -- Ship helpers behind flags default off. -- Enable file search debounce first (high impact, easy to validate). -- Enable LSP latest-only next, then add cancellation if SDK supports signals. -- Keep a quick rollback by disabling the flags. - ---- - -### Open questions - -- Does `sdk.client.find.files` accept an abort signal today, or do we need stale-result protection only? -- Where is LSP refresh initiated, and does it have a single chokepoint we can wrap? -- What debounce values feel best for common repos and slower machines? diff --git a/specs/04-scroll-spy-optimization.md b/specs/04-scroll-spy-optimization.md deleted file mode 100644 index 19f5a76ff88b..000000000000 --- a/specs/04-scroll-spy-optimization.md +++ /dev/null @@ -1,125 +0,0 @@ -## Spy acceleration - -Replace O(N) DOM scans in session view - ---- - -### Summary - -The session scroll-spy currently scans the DOM with `querySelectorAll` and walks message nodes, which becomes expensive as message count grows. We’ll replace the scan with an observer-based or indexed approach that scales smoothly. - ---- - -### Goals - -- Remove repeated full DOM scans during scroll in the session view -- Keep “current message” tracking accurate during streaming and layout shifts -- Provide a safe fallback path for older browsers and edge cases - ---- - -### Non-goals - -- Visual redesign of the session page -- Changing message rendering structure or IDs -- Perfect accuracy during extreme layout thrash - ---- - -### Current state - -- `packages/app/src/pages/session.tsx` uses `querySelectorAll('[data-message-id]')` for scroll-spy. -- The page is large and handles many responsibilities, increasing the chance of perf regressions. - ---- - -### Proposed approach - -Implement a two-tier scroll-spy: - -- Primary: `IntersectionObserver` to track which message elements are visible, updated incrementally. -- Secondary: binary search over precomputed offsets when observer is unavailable or insufficient. -- Use `ResizeObserver` (and a lightweight “dirty” flag) to refresh offsets only when layout changes. - ---- - -### Phased implementation steps - -1. Extract a dedicated scroll-spy module - -- Create `packages/app/src/pages/session/scroll-spy.ts` (or similar) that exposes: - - `register(el, id)` and `unregister(id)` - - `getActiveId()` signal/store -- Keep DOM operations centralized and easy to profile. - -2. Add IntersectionObserver tracking - -- Observe each `[data-message-id]` element once, on mount. -- Maintain a small map of `id -> intersectionRatio` (or visible boolean). -- Pick the active id by: - - highest intersection ratio, then - - nearest to top of viewport as a tiebreaker - -3. Add binary search fallback - -- Maintain an ordered list of `{ id, top }` positions. -- On scroll (throttled via `requestAnimationFrame`), compute target Y and binary search to find nearest message. -- Refresh the positions list on: - - message list mutations (new messages) - - container resize events (ResizeObserver) - - explicit “layout changed” events after streaming completes - -4. Remove `querySelectorAll` hot path - -- Keep a one-time initial query only as a bridge during rollout, then remove it. -- Ensure newly rendered messages are registered via refs rather than scanning the whole DOM. - -5. Add a feature flag and fallback - -- Add `session.scrollSpyOptimized` flag. -- If observer setup fails, fall back to the existing scan behavior temporarily. - ---- - -### Data migration / backward compatibility - -- No persisted data changes. -- IDs remain sourced from existing `data-message-id` attributes. - ---- - -### Risk + mitigations - -- Risk: observer ordering differs from previous “active message” logic. - - Mitigation: keep selection rules simple, document them, and add a small tolerance for tie cases. -- Risk: layout shifts cause incorrect offset indexing. - - Mitigation: refresh offsets with ResizeObserver and after message streaming batches. -- Risk: performance regressions from observing too many nodes. - - Mitigation: prefer one observer instance and avoid per-node observers. - ---- - -### Validation plan - -- Manual scenarios: - - very long sessions (hundreds of messages) and continuous scrolling - - streaming responses that append content and change heights - - resizing the window and toggling side panels -- Add a dev-only profiler hook to log time spent in scroll-spy updates per second. - ---- - -### Rollout plan - -- Land extracted module first, still using the old scan internally. -- Add observer implementation behind `session.scrollSpyOptimized` off by default. -- Enable flag for internal testing, then default on after stability. -- Keep fallback code for one release cycle, then remove scan path. - ---- - -### Open questions - -- What is the exact definition of “active” used elsewhere (URL hash, sidebar highlight, breadcrumb)? -- Are messages virtualized today, or are all DOM nodes mounted at once? -- Which container is the scroll root (window vs an inner div), and does it change by layout mode? diff --git a/specs/05-modularize-and-dedupe.md b/specs/05-modularize-and-dedupe.md deleted file mode 100644 index 85e83f312597..000000000000 --- a/specs/05-modularize-and-dedupe.md +++ /dev/null @@ -1,153 +0,0 @@ -## Component modularity - -Split mega-components and dedupe scoped caches - ---- - -### Summary - -Several large UI files combine rendering, state, persistence, and caching patterns, including repeated “scoped session cache” infrastructure. We’ll extract reusable primitives and break large components into smaller units without changing user-facing behavior. - ---- - -### Goals - -- Reduce complexity in: - - `packages/app/src/pages/session.tsx` - - `packages/app/src/pages/layout.tsx` - - `packages/app/src/components/prompt-input.tsx` -- Deduplicate “scoped session cache” logic into a shared utility -- Make performance fixes (eviction, throttling) easier to implement safely - ---- - -### Non-goals - -- Large redesign of routing or page structure -- Moving to a different state management approach -- Rewriting all contexts in one pass - ---- - -### Current state - -- Session page is large and mixes concerns (`packages/app/src/pages/session.tsx`). -- Layout is also large and likely coordinates multiple global concerns (`packages/app/src/pages/layout.tsx`). -- Prompt input is large and includes persistence and interaction logic (`packages/app/src/components/prompt-input.tsx`). -- Similar “scoped cache” patterns appear in multiple places (session-bound maps, per-session stores, ad hoc memoization). - ---- - -### Proposed approach - -- Introduce a shared “scoped store” utility to standardize session-bound caches: - - keyed by `sessionId` - - automatic cleanup via TTL or explicit `dispose(sessionId)` - - optional LRU cap for many sessions -- Break mega-components into focused modules with clear boundaries: - - “view” components (pure rendering) - - “controller” hooks (state + effects) - - “services” (SDK calls, persistence adapters) - ---- - -### Phased implementation steps - -1. Inventory and name the repeated pattern - -- Identify the repeated “scoped session cache” usage sites in: - - `packages/app/src/pages/session.tsx` - - `packages/app/src/pages/layout.tsx` - - `packages/app/src/components/prompt-input.tsx` -- Write down the common operations (get-or-create, clear-on-session-change, dispose). - -2. Add a shared scoped-cache utility - -- Create `packages/app/src/utils/scoped-cache.ts`: - - `createScopedCache(createValue, opts)` returning `get(key)`, `peek(key)`, `delete(key)`, `clear()` - - optional TTL + LRU caps to avoid leak-by-design -- Keep the API tiny and explicit so call sites stay readable. - -Sketch: - -```ts -type ScopedOpts = { maxEntries?: number; ttlMs?: number } - -function createScopedCache(createValue: (key: string) => T, opts: ScopedOpts) { - // store + eviction + dispose hooks -} -``` - -3. Extract session page submodules - -- Split `packages/app/src/pages/session.tsx` into: - - `session/view.tsx` for rendering layout - - `session/messages.tsx` for message list - - `session/composer.tsx` for input wiring - - `session/scroll-spy.ts` for active message tracking -- Keep exports stable so routing code changes minimally. - -4. Extract layout coordination logic - -- Split `packages/app/src/pages/layout.tsx` into: - - shell layout view - - navigation/controller logic - - global keyboard shortcuts (if present) -- Ensure each extracted piece has a narrow prop surface and no hidden globals. - -5. Extract prompt-input state machine - -- Split `packages/app/src/components/prompt-input.tsx` into: - - `usePromptComposer()` hook (draft, submission, attachments) - - presentational input component -- Route persistence through existing `packages/app/src/context/prompt.tsx`, but isolate wiring code. - -6. Replace ad hoc scoped caches with the shared utility - -- Swap one call site at a time and keep behavior identical. -- Add a flag `scopedCache.shared` to fall back to the old implementation if needed. - ---- - -### Data migration / backward compatibility - -- No persisted schema changes are required by modularization alone. -- If any cache keys change due to refactors, keep a compatibility reader for one release cycle. - ---- - -### Risk + mitigations - -- Risk: refactors cause subtle behavior changes (focus, keyboard shortcuts, scroll position). - - Mitigation: extract without logic changes first, then improve behavior in later diffs. -- Risk: new shared cache introduces lifecycle bugs. - - Mitigation: require explicit cleanup hooks and add dev assertions for retained keys. -- Risk: increased file count makes navigation harder temporarily. - - Mitigation: use consistent naming and keep the folder structure shallow. - ---- - -### Validation plan - -- Manual regression checklist: - - compose, attach images, submit, and reload draft - - navigate between sessions and confirm caches don’t bleed across IDs - - verify terminal, file search, and scroll-spy still behave normally -- Add lightweight unit tests for `createScopedCache` eviction and disposal behavior. - ---- - -### Rollout plan - -- Phase 1: introduce `createScopedCache` unused, then adopt in one low-risk area. -- Phase 2: extract session submodules with no behavior changes. -- Phase 3: flip remaining scoped caches to shared utility behind `scopedCache.shared`. -- Phase 4: remove old duplicated implementations after confidence. - ---- - -### Open questions - -- Where exactly is “scoped session cache” duplicated today, and what are the differing lifecycle rules? -- Which extracted modules must remain synchronous for Solid reactivity to behave correctly? -- Are there implicit dependencies in the large files (module-level state) that need special handling? diff --git a/specs/06-app-i18n-audit.md b/specs/06-app-i18n-audit.md deleted file mode 100644 index 0362ec212a17..000000000000 --- a/specs/06-app-i18n-audit.md +++ /dev/null @@ -1,237 +0,0 @@ -# App i18n Audit (Remaining Work) - -Scope: `packages/app/` - -Date: 2026-01-20 - -This report documents the remaining user-facing strings in `packages/app/src` that are still hardcoded (not routed through `useLanguage().t(...)` / translation keys), plus i18n-adjacent issues like locale-sensitive formatting. - -## Current State - -- The app uses `useLanguage().t("...")` with dictionaries in `packages/app/src/i18n/en.ts` and `packages/app/src/i18n/zh.ts`. -- Recent progress (already translated): `packages/app/src/pages/home.tsx`, `packages/app/src/pages/layout.tsx`, `packages/app/src/pages/session.tsx`, `packages/app/src/components/prompt-input.tsx`, `packages/app/src/components/dialog-connect-provider.tsx`, `packages/app/src/components/session/session-header.tsx`, `packages/app/src/pages/error.tsx`, `packages/app/src/components/session/session-new-view.tsx`, `packages/app/src/components/session-context-usage.tsx`, `packages/app/src/components/session/session-context-tab.tsx`, `packages/app/src/components/session-lsp-indicator.tsx`, `packages/app/src/components/session/session-sortable-tab.tsx`, `packages/app/src/components/titlebar.tsx`, `packages/app/src/components/dialog-select-model.tsx`, `packages/app/src/context/notification.tsx`, `packages/app/src/context/global-sync.tsx`, `packages/app/src/context/file.tsx`, `packages/app/src/context/local.tsx`, `packages/app/src/utils/prompt.ts`, `packages/app/src/context/terminal.tsx`, `packages/app/src/components/session/session-sortable-terminal-tab.tsx` (plus new keys added in both dictionaries). -- Dictionary parity check: `en.ts` and `zh.ts` currently contain the same key set (373 keys each; no missing or extra keys). - -## Methodology - -- Scanned `packages/app/src` (excluding `packages/app/src/i18n/*` and tests). -- Grepped for: - - Hardcoded JSX text nodes (e.g. `>Some text<`) - - Hardcoded prop strings (e.g. `title="..."`, `placeholder="..."`, `label="..."`, `description="..."`, `Tooltip value="..."`) - - Toast/notification strings, default fallbacks, and error message templates. -- Manually reviewed top hits to distinguish: - - User-facing UI copy (needs translation) - - Developer-only logs (`console.*`) (typically does not need translation) - - Technical identifiers (e.g. `MCP`, `LSP`, URLs) (may remain untranslated by choice). - -## Highest Priority: Pages - -### 1) Error Page - -File: `packages/app/src/pages/error.tsx` - -Completed (2026-01-20): - -- Localized page UI copy via `error.page.*` keys (title, description, buttons, report text, version label). -- Localized error chain framing and common init error templates via `error.chain.*` keys. -- Kept raw server/provider error messages as-is when provided (only localizing labels and structure). - -## Highest Priority: Components - -### 2) Prompt Input - -File: `packages/app/src/components/prompt-input.tsx` - -Completed (2026-01-20): - -- Localized placeholder examples by replacing the hardcoded `PLACEHOLDERS` list with `prompt.example.*` keys. -- Localized toast titles/descriptions via `prompt.toast.*` and reused `common.requestFailed` for fallback error text. -- Localized popover empty states and drag/drop overlay copy (`prompt.popover.*`, `prompt.dropzone.label`). -- Localized smaller labels (slash "custom" badge, attach button tooltip, Send/Stop tooltip labels). -- Kept the `ESC` keycap itself untranslated (key label). - -### 3) Provider Connection / Auth Flow - -File: `packages/app/src/components/dialog-connect-provider.tsx` - -Completed (2026-01-20): - -- Localized all user-visible copy via `provider.connect.*` keys (titles, statuses, validations, instructions, OpenCode Zen onboarding). -- Added `common.submit` and used it for both API + OAuth submit buttons. -- Localized the success toast via `provider.connect.toast.connected.*`. - -### 4) Session Header (Share/Publish UI) - -File: `packages/app/src/components/session/session-header.tsx` - -Completed (2026-01-20): - -- Localized search placeholder via `session.header.search.placeholder`. -- Localized share/publish UI via `session.share.*` keys (popover title/description, button states, copy tooltip). -- Reused existing command keys for toggle/share tooltips (`command.review.toggle`, `command.terminal.toggle`, `command.session.share`). - -## Medium Priority: Components - -### 5) New Session View - -File: `packages/app/src/components/session/session-new-view.tsx` - -Completed (2026-01-20): - -- Reused existing `command.session.new` for the heading. -- Localized worktree labels via `session.new.worktree.*` (main branch, main branch w/ branch name, create worktree). -- Localized "Last modified" via `session.new.lastModified` and used `language.locale()` for Luxon relative time. - -### 6) Context Usage Tooltip - -File: `packages/app/src/components/session-context-usage.tsx` - -Completed (2026-01-20): - -- Localized tooltip labels + CTA via `context.usage.*` keys. -- Switched currency and number formatting to the active locale (`language.locale()`). - -### 7) Session Context Tab (Formatting) - -File: `packages/app/src/components/session/session-context-tab.tsx` - -Completed (2026-01-20): - -- Switched currency formatting to the active locale (`language.locale()`). -- Also used `language.locale()` for number/date formatting. -- Note: "—" placeholders remain hardcoded; optional to localize. - -### 8) LSP Indicator - -File: `packages/app/src/components/session-lsp-indicator.tsx` - -Completed (2026-01-20): - -- Localized tooltip/label framing via `lsp.*` keys (kept the acronym itself). - -### 9) Session Tab Close Tooltip - -File: `packages/app/src/components/session/session-sortable-tab.tsx` - -Completed (2026-01-20): - -- Reused `common.closeTab` for the close tooltip. - -### 10) Titlebar Tooltip - -File: `packages/app/src/components/titlebar.tsx` - -Completed (2026-01-20): - -- Reused `command.sidebar.toggle` for the tooltip title. - -### 11) Model Selection "Recent" Group - -File: `packages/app/src/components/dialog-select-model.tsx` - -Completed (2026-01-20): - -- Removed the unused hardcoded "Recent" group comparisons to avoid locale-coupled sorting. - -### 12) Select Server Dialog Placeholder (Optional) - -File: `packages/app/src/components/dialog-select-server.tsx` - -Completed (2026-01-20): - -- Moved the placeholder example URL behind `dialog.server.add.placeholder` (value unchanged). - -## Medium Priority: Context Modules - -### 13) OS/Desktop Notifications - -File: `packages/app/src/context/notification.tsx` - -Completed (2026-01-20): - -- Localized OS notification titles/fallback copy via `notification.session.*` keys. - -### 14) Global Sync (Bootstrap Errors + Toast) - -File: `packages/app/src/context/global-sync.tsx` - -Completed (2026-01-20): - -- Localized the sessions list failure toast via `toast.session.listFailed.title`. -- Localized the bootstrap connection error via `error.globalSync.connectFailed`. - -### 15) File Load Failure Toast (Duplicate) - -Files: - -- `packages/app/src/context/file.tsx` -- `packages/app/src/context/local.tsx` - -Completed (2026-01-20): - -- Introduced `toast.file.loadFailed.title` and reused it in both contexts. - -### 16) Terminal Naming (Tricky) - -File: `packages/app/src/context/terminal.tsx` - -Completed (2026-01-20): - -- Terminal display labels are now rendered from a stable numeric `titleNumber` and localized via `terminal.title.*`. -- Added a one-time migration to backfill missing `titleNumber` by parsing the stored title string. - -## Low Priority: Utils / Dev-Only Copy - -### 17) Default Attachment Filename - -File: `packages/app/src/utils/prompt.ts` - -Completed (2026-01-20): - -- Added `common.attachment` and plumbed it into `extractPromptFromParts(...)` as `opts.attachmentName`. - -### 18) Dev-only Root Mount Error - -File: `packages/app/src/entry.tsx` - -Completed (2026-01-20): - -- Localized the DEV-only root mount error via `error.dev.rootNotFound`. -- Selected locale using `navigator.languages` to match the app’s default detection. - -## Prioritized Implementation Plan - -No remaining work in `packages/app/` as of 2026-01-20. - -## Suggested Key Naming Conventions - -To keep the dictionaries navigable, prefer grouping by surface: - -- `error.page.*`, `error.chain.*` -- `prompt.*` (including examples, tooltips, empty states, toasts) -- `provider.connect.*` (auth flow UI + validation + success) -- `session.share.*` (publish/unpublish/copy link) -- `context.usage.*` (Tokens/Usage/Cost + call to action) -- `lsp.*` (and potentially `mcp.*` if expanded) -- `notification.session.*` -- `toast.file.*`, `toast.session.*` - -Also reuse existing command keys for tooltip titles whenever possible (e.g. `command.sidebar.toggle`, `command.review.toggle`, `command.terminal.toggle`). - -## Appendix: Remaining Files At-a-Glance - -Pages: - -- (none) - -Components: - -- (none) - -Context: - -- (none) - -Utils: - -- (none) diff --git a/specs/07-ui-i18n-audit.md b/specs/07-ui-i18n-audit.md deleted file mode 100644 index e3c74686781e..000000000000 --- a/specs/07-ui-i18n-audit.md +++ /dev/null @@ -1,156 +0,0 @@ -# UI i18n Audit (Remaining Work) - -Scope: `packages/ui/` (and consumers: `packages/app/`, `packages/enterprise/`) - -Date: 2026-01-20 - -This report documents the remaining user-facing strings in `packages/ui/src` that are still hardcoded (not routed through a translation function), and proposes an i18n architecture that works long-term across multiple packages. - -## Current State - -- `packages/app/` already has i18n via `useLanguage().t("...")` with dictionaries in `packages/app/src/i18n/en.ts` and `packages/app/src/i18n/zh.ts`. -- `packages/ui/` is a shared component library used by: - - `packages/app/src/pages/session.tsx` (Session UI) - - `packages/enterprise/src/routes/share/[shareID].tsx` (shared session rendering) -- `packages/ui/` currently has **hardcoded English UI copy** in several components (notably `session-turn.tsx`, `session-review.tsx`, `message-part.tsx`). -- `packages/enterprise/` does not currently have an i18n system, so any i18n approach must be usable without depending on `packages/app/`. - -## Decision: How We Should Add i18n To `@opencode-ai/ui` - -Introduce a small, app-agnostic i18n interface in `packages/ui/` and keep UI-owned strings in UI-owned dictionaries. - -Why this is the best long-term shape: - -- Keeps dependency direction clean: `packages/enterprise/` (and any future consumer) can translate UI without importing `packages/app/` dictionaries. -- Avoids prop-drilling strings through shared components. -- Allows each package to own its strings while still rendering a single, coherent locale in the product. - -### Proposed Architecture - -1. **UI provides an i18n context (no persistence)** - -- Add `packages/ui/src/context/i18n.tsx`: - - Exports `I18nProvider` and `useI18n()`. - - Context value includes: - - `t(key, params?)` translation function (template interpolation supported by the consumer). - - `locale()` accessor for locale-sensitive formatting (Luxon/Intl). - - Context should have a safe default (English) so UI components can render even if a consumer forgets the provider. - -2. **UI owns UI strings (dictionaries live in UI)** - -- Add `packages/ui/src/i18n/en.ts` and `packages/ui/src/i18n/zh.ts`. -- Export them from `@opencode-ai/ui` via `packages/ui/package.json` exports (e.g. `"./i18n/*": "./src/i18n/*.ts"`). -- Use a clear namespace prefix for all UI keys to avoid collisions: - - Recommended: `ui.*` (e.g. `ui.sessionReview.title`). - -3. **Consumers merge dictionaries and provide `t`/`locale` once** - -- `packages/app/`: - - Keep `packages/app/src/context/language.tsx` as the source of truth for locale selection/persistence. - - Extend it to merge UI dictionaries into its translation table. - - Add a tiny bridge provider in `packages/app/src/app.tsx` to feed `useLanguage()` into `@opencode-ai/ui`'s `I18nProvider`. - -- `packages/enterprise/`: - - Add a lightweight locale detector (similar to `packages/app/src/context/language.tsx`), likely based on `Accept-Language` on the server and/or `navigator.languages` on the client. - - Merge `@opencode-ai/ui` dictionaries and (optionally) enterprise-local dictionaries. - - Wrap the share route in `I18nProvider`. - -### Key Naming Conventions (UI) - -- Prefer component + semantic grouping: - - `ui.sessionReview.title` - - `ui.sessionReview.diffStyle.unified` - - `ui.sessionReview.diffStyle.split` - - `ui.sessionReview.expandAll` - - `ui.sessionReview.collapseAll` - -- For `SessionTurn`: - - `ui.sessionTurn.steps.show` - - `ui.sessionTurn.steps.hide` - - `ui.sessionTurn.summary.response` - - `ui.sessionTurn.diff.more` (use templating: `Show more changes ({{count}})`) - - `ui.sessionTurn.retry.retrying` / `ui.sessionTurn.retry.inSeconds` / etc (avoid string concatenation that is English-order dependent) - - Status text: - - `ui.sessionTurn.status.delegating` - - `ui.sessionTurn.status.planning` - - `ui.sessionTurn.status.gatheringContext` - - `ui.sessionTurn.status.searchingCode` - - `ui.sessionTurn.status.searchingWeb` - - `ui.sessionTurn.status.makingEdits` - - `ui.sessionTurn.status.runningCommands` - - `ui.sessionTurn.status.thinking` - - `ui.sessionTurn.status.thinkingWithTopic` (template: `Thinking - {{topic}}`) - - `ui.sessionTurn.status.gatheringThoughts` - - `ui.sessionTurn.status.consideringNextSteps` (fallback) - -## Locale-Sensitive Formatting (UI) - -`SessionTurn` currently formats durations via Luxon `Interval.toDuration(...).toHuman(...)` without an explicit locale. - -When i18n is added: - -- Use `useI18n().locale()` and pass locale explicitly: - - Luxon: `duration.toHuman({ locale: locale(), ... })` (or set `.setLocale(locale())` where applicable). - - Intl numbers/currency (if added later): `new Intl.NumberFormat(locale(), ...)`. - -## Initial Hardcoded Strings (Audit Findings) - -These are the highest-impact UI surfaces to translate first. - -### 1) `packages/ui/src/components/session-review.tsx` - -- `Session changes` -- `Unified` / `Split` -- `Collapse all` / `Expand all` - -### 2) `packages/ui/src/components/session-turn.tsx` - -- Tool/task status strings (e.g. `Delegating work`, `Searching the codebase`) -- Steps toggle labels: `Show steps` / `Hide steps` -- Summary section title: `Response` -- Pagination CTA: `Show more changes ({{count}})` - -### 3) `packages/ui/src/components/message-part.tsx` - -Examples (non-exhaustive): - -- `Error` -- `Edit` -- `Write` -- `Type your own answer` -- `Review your answers` - -### 4) Additional Hardcoded Strings (Full Audit) - -Found during a full `packages/ui/src/components` + `packages/ui/src/context` sweep: - -- `packages/ui/src/components/list.tsx` - - `Loading` - - `No results` - - `No results for "{{filter}}"` -- `packages/ui/src/components/message-nav.tsx` - - `New message` -- `packages/ui/src/components/text-field.tsx` - - `Copied` - - `Copy to clipboard` -- `packages/ui/src/components/image-preview.tsx` - - `Image preview` (alt text) - -## Prioritized Implementation Plan - -1. Completed (2026-01-20): Add `@opencode-ai/ui` i18n context (`packages/ui/src/context/i18n.tsx`) + export it. -2. Completed (2026-01-20): Add UI dictionaries (`packages/ui/src/i18n/en.ts`, `packages/ui/src/i18n/zh.ts`) + export them. -3. Completed (2026-01-20): Wire `I18nProvider` into: - - `packages/app/src/app.tsx` - - `packages/enterprise/src/app.tsx` -4. Completed (2026-01-20): Convert `packages/ui/src/components/session-review.tsx` and `packages/ui/src/components/session-turn.tsx` to use `useI18n().t(...)`. -5. Completed (2026-01-20): Convert `packages/ui/src/components/message-part.tsx`. -6. Completed (2026-01-20): Do a full `packages/ui/src/components` + `packages/ui/src/context` audit for additional hardcoded copy. - -## Notes / Risks - -- **SSR:** Enterprise share pages render on the server. Ensure the i18n provider works in SSR and does not assume `window`/`navigator`. -- **Key collisions:** Use a consistent `ui.*` prefix to avoid clashing with app keys. -- **Fallback behavior:** Decide whether missing keys should: - - fall back to English, or - - render the key (useful for catching missing translations). diff --git a/specs/08-app-e2e-smoke-suite.md b/specs/08-app-e2e-smoke-suite.md deleted file mode 100644 index c034399a38c4..000000000000 --- a/specs/08-app-e2e-smoke-suite.md +++ /dev/null @@ -1,255 +0,0 @@ -## App E2E Smoke Suite (CI) - -Implement a small set of high-signal, low-flake Playwright tests to run in CI. - -These tests are intended to catch regressions in the “core shell” of the app (navigation, dialogs, prompt UX, file viewer, terminal), without relying on model output. - ---- - -### Summary - -Add 6 smoke tests to `packages/app/e2e/`: - -- Settings dialog: open, switch tabs, close -- Prompt slash command: `/open` opens the file picker dialog -- Prompt @mention: `@` inserts a file pill token -- Model picker: open model selection and choose a model -- File viewer: open a known file and assert contents render -- Terminal: open terminal, verify Ghostty mounts, create a second terminal - ---- - -### Progress - -- [x] 1. Settings dialog open / switch / close (`packages/app/e2e/settings.spec.ts`) -- [x] 2. Prompt slash command path: `/open` opens file picker (`packages/app/e2e/prompt-slash-open.spec.ts`) -- [x] 3. Prompt @mention inserts a file pill token (`packages/app/e2e/prompt-mention.spec.ts`) -- [x] 4. Model selection UI works end-to-end (`packages/app/e2e/model-picker.spec.ts`) -- [x] 5. File viewer renders real file content (`packages/app/e2e/file-viewer.spec.ts`) -- [x] 8. Terminal init + create new terminal (`packages/app/e2e/terminal-init.spec.ts`) - ---- - -### Goals - -- Tests run reliably in CI using the existing local runner (`packages/app/script/e2e-local.ts`). -- Cover “wiring” regressions across UI + backend APIs: - - dialogs + command routing - - prompt contenteditable parsing - - file search + file read + code viewer render - - terminal open + pty creation + Ghostty mount -- Avoid assertions that depend on LLM output. -- Keep runtime low (these should be “smoke”, not full workflows). - ---- - -### Non-goals - -- Verifying complex model behavior, streaming correctness, or tool call semantics. -- Testing provider auth flows (CI has no secrets). -- Testing share, MCP, or LSP download flows (disabled in the e2e runner). - ---- - -### Current State - -Existing tests in `packages/app/e2e/` already cover: - -- Home renders + server picker opens -- Directory route redirects to `/session` -- Sidebar collapse/expand -- Command palette opens/closes -- Basic session open + prompt input + (optional) prompt/reply flow -- File open via palette (but shallow assertion: tab exists) -- Terminal panel toggles (but doesn’t assert Ghostty mounted) -- Context panel open - -We want to add a focused smoke layer that increases coverage of the most regression-prone UI paths. - ---- - -### Proposed Tests - -All tests should use the shared fixtures in: - -- `packages/app/e2e/fixtures.ts` (for `sdk`, `directory`, `gotoSession`) -- `packages/app/e2e/utils.ts` (for `modKey`, `promptSelector`, `terminalToggleKey`) - -Prefer creating new spec files rather than overloading existing ones, so it’s easy to run these tests as a group via grep. - -Suggested file layout: - -- `packages/app/e2e/settings.spec.ts` -- `packages/app/e2e/prompt-slash-open.spec.ts` -- `packages/app/e2e/prompt-mention.spec.ts` -- `packages/app/e2e/model-picker.spec.ts` -- `packages/app/e2e/file-viewer.spec.ts` -- `packages/app/e2e/terminal-init.spec.ts` - -Name each test with a “smoke” prefix so CI can run only this suite if needed. - -#### 1) Settings dialog open / switch / close - -Purpose: catch regressions in dialog infra, settings rendering, tabs. - -Steps: - -1. `await gotoSession()`. -2. Open settings via keybind (preferred for stability): `await page.keyboard.press(`${modKey}+Comma`)`. -3. Assert dialog visible (`page.getByRole('dialog')`). -4. Click the "Shortcuts" tab (role `tab`, name "Shortcuts"). -5. Assert shortcuts view renders (e.g. the search field placeholder or reset button exists). -6. Close with `Escape` and assert dialog removed. - -Notes: - -- If `Meta+Comma` / `Control+Comma` key name is flaky, fall back to clicking the sidebar settings icon. -- Favor role-based selectors over brittle class selectors. -- If `Escape` doesn’t dismiss reliably (tooltips can intercept), fall back to clicking the dialog overlay. - -Implementation: `packages/app/e2e/settings.spec.ts` - -Acceptance criteria: - -- Settings dialog opens reliably. -- Switching to Shortcuts tab works. -- Escape closes the dialog. - -#### 2) Prompt slash command path: `/open` opens file picker - -Purpose: validate contenteditable parsing + slash popover + builtin command dispatch (distinct from `mod+p`). - -Steps: - -1. `await gotoSession()`. -2. Click prompt (`promptSelector`). -3. Type `/open`. -4. Press `Enter` (while slash popover is active). -5. Assert a dialog appears and contains a textbox (the file picker search input). -6. Close dialog with `Escape`. - -Acceptance criteria: - -- `/open` triggers `file.open` and opens `DialogSelectFile`. - -#### 3) Prompt @mention inserts a file pill token - -Purpose: validate the most fragile prompt behavior: structured tokens inside contenteditable. - -Steps: - -1. `await gotoSession()`. -2. Focus the prompt. -3. Type `@packages/app/package.json`. -4. Press `Tab` to accept the active @mention suggestion. -5. Assert a pill element is inserted: - - `page.locator('[data-component="prompt-input"] [data-type="file"][data-path="packages/app/package.json"]')` exists. - -Acceptance criteria: - -- A file pill is inserted and has the expected `data-*` attributes. -- Prompt editor remains interactable (e.g. typing a trailing space works). - -#### 4) Model selection UI works end-to-end - -Purpose: validate model list rendering, selection wiring, and prompt footer updating. - -Implementation approach: - -- Use `/model` to open the model selection dialog (builtin command). - -Steps: - -1. `await gotoSession()`. -2. Focus prompt, type `/model`, press `Enter`. -3. In the model dialog, pick a visible model that is not the current selection (if available). -4. Use the search field to filter to that model (use its id from the list item's `data-key` to avoid time-based model visibility drift). -5. Select the filtered model. -6. Assert dialog closed. -7. Assert the prompt footer now shows the chosen model name. - -Acceptance criteria: - -- A model can be selected without requiring provider auth. -- The prompt footer reflects the new selection. - -#### 5) File viewer renders real file content - -Purpose: ensure file search + open + file.read + code viewer render all work. - -Steps: - -1. `await gotoSession()`. -2. Open file picker (either `mod+p` or `/open`). -3. Search for `packages/app/package.json`. -4. Click the matching file result. -5. Ensure the new file tab is active (click the `package.json` tab if needed so the viewer mounts). -6. Assert the code viewer contains a known substring: - - `"name": "@opencode-ai/app"`. -7. Optionally assert the file tab is active and visible. - -Acceptance criteria: - -- Code view shows expected content (not just “tab exists”). - -#### 8) Terminal init + create new terminal - -Purpose: ensure terminal isn’t only “visible”, but actually mounted and functional. - -Steps: - -1. `await gotoSession()`. -2. Open terminal with `terminalToggleKey` (currently `Control+Backquote`). -3. Assert terminal container exists and is visible: `[data-component="terminal"]`. -4. Assert Ghostty textarea exists: `[data-component="terminal"] textarea`. -5. Create a new terminal via keybind (`terminal.new` is `ctrl+alt+t`). -6. Assert terminal tab count increases to 2. - -Acceptance criteria: - -- Ghostty mounts (textarea present). -- Creating a new terminal results in a second tab. - ---- - -### CI Stability + Flake Avoidance - -These tests run with `fullyParallel: true` in `packages/app/playwright.config.ts`. Keep them isolated and deterministic. - -- Avoid ordering-based assertions: never assume a “first” session/project/file is stable unless you filtered by unique text. -- Prefer deterministic targets: - - use `packages/app/package.json` rather than bare `package.json` (multiple hits possible) - - for models, avoid hardcoding a single model id; pick from the visible list and filter by its `data-key` instead -- Prefer robust selectors: - - role selectors: `getByRole('dialog')`, `getByRole('textbox')`, `getByRole('tab')` - - stable data attributes already present: `promptSelector`, `[data-component="terminal"]` -- Keep tests local and fast: - - do not submit prompts that require real model replies - - avoid `page.waitForTimeout`; use `expect(...).toBeVisible()` and `expect.poll` when needed -- Watch for silent UI failures: - - capture `page.on('pageerror')` and fail test if any are emitted - - optionally capture console errors (`page.on('console', ...)`) and fail on `type==='error'` -- Cleanup: - - these tests should not need to create sessions - - if a test ever creates sessions or PTYs directly, clean up with SDK calls in `finally` - ---- - -### Validation Plan - -Run locally: - -- `cd packages/app` -- `bun run test:e2e:local -- --grep smoke` - -Verify: - -- all new tests pass consistently across multiple runs -- overall e2e suite time does not increase significantly - ---- - -### Open Questions - -- Should we add a small helper in `packages/app/e2e/utils.ts` for “type into prompt contenteditable” to reduce duplication? -- Do we want to gate these smoke tests with a dedicated `@smoke` naming convention (or `test.describe('smoke', ...)`) so CI can target them explicitly? diff --git a/specs/09-session-page-decomposition.md b/specs/09-session-page-decomposition.md deleted file mode 100644 index 8f39edc94c44..000000000000 --- a/specs/09-session-page-decomposition.md +++ /dev/null @@ -1,113 +0,0 @@ -## Session page decomposition - -Split `pages/session.tsx` into focused modules without behavior changes. - ---- - -### Summary - -`packages/app/src/pages/session.tsx` is still a large (~3,655 LOC) route coordinator. Recent refactoring already extracted `packages/app/src/pages/session/helpers.ts` and `packages/app/src/pages/session/scroll-spy.ts`, but review-panel wiring, message timeline orchestration, file-tab rendering, and terminal coordination remain tightly coupled. This spec continues the decomposition from that updated baseline. - ---- - -### Goals - -- Reduce complexity in `packages/app/src/pages/session.tsx`. -- Isolate major concerns into dedicated modules under `packages/app/src/pages/session/`. -- Keep behavior and route/API contracts unchanged. -- Preserve current keyboard, scroll, hash, and review interactions. - ---- - -### Non-goals - -- No redesign of session UX. -- No changes to SDK contracts. -- No refactor of `context/global-sync.tsx`, `context/file.tsx`, or `components/prompt-input.tsx` in this workstream. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/pages/session.tsx` -- New files under `packages/app/src/pages/session/**` - -This workstream must not edit: - -- `packages/app/src/pages/layout.tsx` (owned by spec 10) -- `packages/app/src/components/prompt-input.tsx` (owned by spec 11) -- `packages/app/src/context/global-sync.tsx` (owned by spec 12) -- `packages/app/src/context/file.tsx` (owned by spec 13) - ---- - -### Current state - -- File size: ~3,655 LOC. -- Existing extracted modules: - - `packages/app/src/pages/session/helpers.ts` (terminal focus and shared handlers) - - `packages/app/src/pages/session/scroll-spy.ts` (message visibility + active-section tracking) -- High effect density (`createEffect`) and local-state density (`createStore` + `createSignal`) remain in `session.tsx`. -- Remaining interleaved responsibilities: - - review panel state + scrolling integration - - message timeline + hash navigation wiring - - file tab renderers + per-tab scroll sync - - terminal panel and tab coordination - ---- - -### Proposed module split - -Build on the existing `packages/app/src/pages/session/` directory and keep current extracted helpers in place. Add modules such as: - -- `review-panel.tsx` - review tab rendering and focused diff logic. -- `message-timeline.tsx` - session turn rendering and active message tracking UI wiring. -- `file-tabs.tsx` - file tab content rendering, file scroll persistence, and line-comment overlays. -- `terminal-panel.tsx` - terminal tabs and focus behavior. -- `use-session-page-state.ts` - page-level derived state and imperative handlers. - -`packages/app/src/pages/session.tsx` remains the route entry and orchestrator only. - ---- - -### Phased steps - -1. Keep `helpers.ts` and `scroll-spy.ts` as baseline; extract any additional pure helpers first (no behavior changes). -2. Extract review panel subtree and related handlers. -3. Extract file-tab subtree and scroll synchronization logic. -4. Extract terminal panel subtree. -5. Move page-level state/effects into `use-session-page-state.ts`. -6. Reduce `session.tsx` to composition and routing glue. - ---- - -### Acceptance criteria - -- `packages/app/src/pages/session.tsx` is reduced substantially (target: under 1,400 LOC). -- No user-facing behavior changes in session, review, file tabs, or terminal tabs. -- Event listeners and observers are still correctly cleaned up. -- New modules have clear prop boundaries and minimal hidden coupling. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/session/session.spec.ts` - - `e2e/files/file-viewer.spec.ts` - - `e2e/terminal/terminal.spec.ts` -- Manual checks: - - message hash navigation - - review diff focus + open-file action - - terminal tab create/reorder/focus behavior - ---- - -### Handoff notes - -- Keep module interfaces narrow and data-oriented. -- Prefer extracting code unchanged before doing any cleanup refactors. -- If a helper is useful to other specs, place it under `pages/session/` for now; cross-spec shared utilities can be unified later. diff --git a/specs/09-session-page-hot-paths.md b/specs/09-session-page-hot-paths.md deleted file mode 100644 index cc15106fbab7..000000000000 --- a/specs/09-session-page-hot-paths.md +++ /dev/null @@ -1,105 +0,0 @@ -## Session hot paths - -Reduce render work and duplication in `session.tsx` - ---- - -### Summary - -`packages/app/src/pages/session.tsx` mixes routing, commands, tab rendering, review panel wiring, terminal focus logic, and message scrolling. This spec targets hot-path performance + local code quality improvements that can ship together in one session-page-focused PR. It should follow the keyed command-registration pattern introduced in `packages/app/src/context/command.tsx`. - ---- - -### Goals - -- Render heavy file-tab content only for the active tab -- Deduplicate review-panel wiring used in desktop and mobile paths -- Centralize terminal-focus DOM logic into one helper -- Reduce churn in command registration setup - ---- - -### Non-goals - -- Scroll-spy rewrite (covered by `specs/04-scroll-spy-optimization.md`) -- Large routing/layout redesign -- Behavior changes to prompt submission or session history - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/pages/session.tsx` -- New files under `packages/app/src/pages/session/*` (if extracted) - -This spec should not modify: - -- `packages/app/src/context/*` -- `packages/app/src/components/prompt-input.tsx` -- `packages/app/src/components/file-tree.tsx` - ---- - -### Implementation plan - -1. Add shared helpers for repeated session-page actions - -- Extract `openReviewFile(path)` helper to replace repeated inline `onViewFile` bodies. -- Extract `focusTerminalById(id)` helper and reuse in both: - - terminal active change effect - - terminal drag-end focus restoration - -2. Deduplicate review panel construction - -- Build a shared review props factory (or local render helper) so desktop/mobile paths do not duplicate comment wiring, `onViewFile`, and classes glue. -- Keep per-surface differences limited to layout classes and diff style. - -3. Gate heavy file-tab rendering by active tab - -- Keep tab trigger list rendered for all opened tabs. -- Render `Tabs.Content` body only for `activeTab()`, plus lightweight placeholders as needed. -- Ensure per-tab scroll state restore still works when reactivating a tab. - -4. Reduce command registry reallocation - -- Register session commands with a stable key (`command.register("session", ...)`) so remounts replace prior session command entries. -- Move large command-array construction into smaller memoized blocks: - - stable command definitions - - dynamic state fields (`disabled`, titles) as narrow computed closures - - Keep command IDs, keybinds, and behavior identical. - ---- - -### Acceptance criteria - -- File tab bodies are not all mounted at once for large open-tab sets. -- `onViewFile` review behavior is defined in one shared helper. -- Terminal focus query/dispatch logic lives in one function and is reused. -- Session command registration uses a stable key (`"session"`) and `command.register` no longer contains one monolithic inline array with repeated inline handlers for shared actions. -- Session UX remains unchanged for: - - opening files from review - - drag-reordering terminal tabs - - keyboard command execution - ---- - -### Validation plan - -- Manual: - - Open 12+ file tabs, switch quickly, verify active tab restore and no blank states. - - Open review panel (desktop and mobile), use "view file" from diffs, verify same behavior as before. - - Drag terminal tab, ensure terminal input focus is restored. - - Run key commands: `mod+p`, `mod+w`, `mod+shift+r`, `ctrl+``. -- Perf sanity: - - Compare CPU usage while switching tabs with many opened files before/after. - ---- - -### Risks and mitigations - -- Risk: unmounted tab content loses transient editor state. - - Mitigation: keep persisted scroll/selection restore path intact and verify reactivation behavior. -- Risk: command refactor subtly changes command ordering. - - Mitigation: keep IDs and registration order stable, diff against current command list in dev. diff --git a/specs/10-file-content-eviction-accounting.md b/specs/10-file-content-eviction-accounting.md deleted file mode 100644 index 2f7f1f8cc97d..000000000000 --- a/specs/10-file-content-eviction-accounting.md +++ /dev/null @@ -1,99 +0,0 @@ -## File cache accounting - -Make file-content eviction bookkeeping O(1) - ---- - -### Summary - -`packages/app/src/context/file.tsx` currently recomputes total cached bytes by reducing the entire LRU map inside the eviction loop. This creates avoidable overhead on large file sets. We will switch to incremental byte accounting while keeping LRU behavior unchanged. - ---- - -### Goals - -- Remove repeated full-map reductions from eviction path -- Maintain accurate total byte tracking incrementally -- Preserve existing eviction semantics (entry count + byte cap) - ---- - -### Non-goals - -- Changing cache limits -- Changing file loading API behavior -- Introducing cross-session shared caches - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/context/file.tsx` -- Optional tests in `packages/app/src/context/*file*.test.ts` - -This spec should not modify: - -- `packages/app/src/pages/session.tsx` -- `packages/app/src/components/file-tree.tsx` - ---- - -### Implementation plan - -1. Introduce incremental byte counters - -- Add module-level `contentBytesTotal`. -- Add helper(s): - - `setContentBytes(path, nextBytes)` - - `removeContentBytes(path)` - - `resetContentBytes()` - -2. Refactor LRU touch/update path - -- Keep `contentLru` as LRU order map. -- Update byte total only when a path is inserted/updated/removed. -- Ensure replacing existing byte value updates total correctly. - -3. Refactor eviction loop - -- Use `contentBytesTotal` in loop condition instead of `Array.from(...).reduce(...)`. -- On eviction, remove from both `contentLru` and byte counter. - -4. Keep scope reset correct - -- On directory scope change, clear inflight maps + `contentLru` + byte counter. - ---- - -### Acceptance criteria - -- `evictContent` performs no full-map reduction per iteration. -- Total bytes remain accurate after: - - loading file A - - loading file B - - force-reloading file A with a different size - - evicting entries - - scope reset -- Existing caps (`MAX_FILE_CONTENT_ENTRIES`, `MAX_FILE_CONTENT_BYTES`) continue to enforce correctly. - ---- - -### Validation plan - -- Manual: - - Open many files with mixed sizes and verify old files still evict as before. - - Switch directory scope and verify cache clears safely. -- Optional unit coverage: - - size counter updates on overwrite + delete. - - eviction condition uses count and bytes as expected. - ---- - -### Risks and mitigations - -- Risk: byte counter drifts from map contents. - - Mitigation: route all updates through centralized helpers. -- Risk: stale bytes retained on early returns. - - Mitigation: assert cleanup paths in `finally`/scope reset still execute. diff --git a/specs/10-layout-page-decomposition.md b/specs/10-layout-page-decomposition.md deleted file mode 100644 index d48002ea80d8..000000000000 --- a/specs/10-layout-page-decomposition.md +++ /dev/null @@ -1,109 +0,0 @@ -## Layout page decomposition - -Split `pages/layout.tsx` into composable layout modules with stable behavior. - ---- - -### Summary - -`packages/app/src/pages/layout.tsx` is a 3,000+ line coordinator for sidebar navigation, project/workspace controls, deep-link handling, dialogs, drag/drop overlays, and global shell interactions. This spec decomposes it into focused modules to improve maintainability and reduce merge risk for future features. - ---- - -### Goals - -- Break up `packages/app/src/pages/layout.tsx` into smaller units. -- Separate rendering concerns from orchestration/state concerns. -- Keep existing URL/navigation semantics and sidebar behavior. -- Preserve all current command and dialog entry points. - ---- - -### Non-goals - -- No major UX redesign of the sidebar or project/workspace UI. -- No changes to server/global-sync contracts. -- No refactor of `pages/session.tsx` in this workstream. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/pages/layout.tsx` -- New files under `packages/app/src/pages/layout/**` - -This workstream must not edit: - -- `packages/app/src/pages/session.tsx` (spec 09) -- `packages/app/src/components/prompt-input.tsx` (spec 11) -- `packages/app/src/context/global-sync.tsx` (spec 12) - ---- - -### Current state - -- File size: ~3,004 LOC. -- Contains mixed concerns: - - app-shell rendering - - sidebar/project/workspace UI + drag/drop - - deep-link handling and startup flows - - workspace reset/delete actions and toasts - ---- - -### Proposed module split - -Create `packages/app/src/pages/layout/` modules such as: - -- `use-layout-page-state.ts` - orchestration state and handlers. -- `sidebar-panel.tsx` - sidebar shell and root interactions. -- `project-item.tsx` - project-level row and actions. -- `workspace-item.tsx` - workspace row, sessions list, and workspace actions. -- `deep-links.ts` - deep-link parsing/draining/handler utilities. - -Keep `packages/app/src/pages/layout.tsx` as route-level composition and provider wiring. - ---- - -### Phased steps - -1. Extract pure helpers first (deep-link parse, shared label helpers, small utility functions). -2. Extract workspace subtree and action handlers. -3. Extract project subtree and menu actions. -4. Extract sidebar shell and drag overlay components. -5. Move orchestration logic into `use-layout-page-state.ts`. -6. Reduce `layout.tsx` to composition-only entry. - ---- - -### Acceptance criteria - -- `packages/app/src/pages/layout.tsx` is significantly smaller (target: under 1,200 LOC). -- Behavior parity for: - - project open/close/rename - - workspace expand/collapse/reset/delete - - deep-link handling - - drag/drop ordering -- No regressions in keyboard navigation and dialog actions. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/sidebar/sidebar.spec.ts` - - `e2e/projects/workspaces.spec.ts` - - `e2e/projects/project-edit.spec.ts` - - `e2e/app/navigation.spec.ts` -- Manual check: deep-link open-project flow still opens and navigates correctly. - ---- - -### Handoff notes - -- Keep action handlers close to their domain module. -- Do not merge in behavior cleanups during extraction; preserve semantics first. -- If shared components are needed, add them under `pages/layout/` for now to avoid cross-spec conflicts. diff --git a/specs/11-layout-view-tabs-reactivity.md b/specs/11-layout-view-tabs-reactivity.md deleted file mode 100644 index c7680440cb9f..000000000000 --- a/specs/11-layout-view-tabs-reactivity.md +++ /dev/null @@ -1,92 +0,0 @@ -## Layout reactivity - -Reduce per-call reactive overhead in `useLayout` - ---- - -### Summary - -`packages/app/src/context/layout.tsx` creates reactive effects inside `view(sessionKey)` and `tabs(sessionKey)` each time these helpers are called. Multiple consumers for the same key can accumulate duplicate watchers. This spec simplifies the API internals so calls stay lightweight while preserving behavior. - ---- - -### Goals - -- Remove avoidable per-call `createEffect` allocations in `view()` and `tabs()` -- Preserve scroll seeding, pruning, and touch semantics -- Keep external `useLayout` API stable - ---- - -### Non-goals - -- Persistence schema migration -- Session tab behavior redesign -- New layout features - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/context/layout.tsx` -- `packages/app/src/context/layout-scroll.test.ts` (if updates needed) - -This spec should not modify: - -- `packages/app/src/pages/session.tsx` -- `packages/app/src/components/session/*` - ---- - -### Implementation plan - -1. Consolidate key-touch logic - -- Introduce shared internal helper, e.g. `ensureSessionKey(key)` that performs: - - `touch(key)` - - `scroll.seed(key)` - -2. Remove per-call effects in `view()` / `tabs()` - -- Replace internal `createEffect(on(key, ...))` usage with lazy key reads inside accessors/memos. -- Ensure reads still invoke `ensureSessionKey` at safe points. - -3. Keep return API stable - -- Preserve current method names and behavior: - - `view(...).scroll`, `setScroll`, `terminal`, `reviewPanel`, `review` - - `tabs(...).active`, `all`, `open`, `close`, `move`, etc. - -4. Verify pruning behavior - -- Ensure session-key pruning still runs when key set grows and active key changes. - ---- - -### Acceptance criteria - -- `view()` and `tabs()` no longer instantiate per-call key-change effects. -- Existing callers do not require API changes. -- Scroll restore and tab persistence still work across session navigation. -- No regressions in handoff/pending-message behavior. - ---- - -### Validation plan - -- Manual: - - Navigate across multiple sessions; verify tabs + review open state + scroll positions restore. - - Toggle terminal/review panels and confirm persisted state remains consistent. -- Tests: - - Update/add targeted tests for key seeding/pruning if behavior changed. - ---- - -### Risks and mitigations - -- Risk: subtle key-touch ordering changes affect prune timing. - - Mitigation: keep `touch` and `seed` coupled through one helper and verify prune boundaries. -- Risk: removing effects misses updates for dynamic accessor keys. - - Mitigation: ensure every public accessor path reads current key and calls helper. diff --git a/specs/11-prompt-input-and-optimistic-state.md b/specs/11-prompt-input-and-optimistic-state.md deleted file mode 100644 index 47008fb1fa33..000000000000 --- a/specs/11-prompt-input-and-optimistic-state.md +++ /dev/null @@ -1,121 +0,0 @@ -## Prompt input and optimistic-state consolidation - -Decompose prompt-input and unify optimistic message mutations. - ---- - -### Summary - -`packages/app/src/components/prompt-input.tsx` has already been partially decomposed and is now ~1,391 LOC. Editor DOM helpers, attachments, history, and submit flow were extracted into `packages/app/src/components/prompt-input/*.ts`, but optimistic mutation ownership and some UI/controller responsibilities are still split across call sites. This spec continues from that refactored baseline. - ---- - -### Goals - -- Split `prompt-input.tsx` into modular UI + controller pieces. -- Centralize optimistic message add/remove behavior behind sync-context APIs. -- Remove unsafe cast path around optimistic parts (`as unknown as Part[]`). -- Keep existing prompt UX and submission semantics unchanged. - ---- - -### Non-goals - -- No redesign of prompt input visuals. -- No changes to session protocol or backend APIs. -- No changes to unrelated page modules (`pages/session.tsx`, `pages/layout.tsx`). - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/components/prompt-input.tsx` -- New files under `packages/app/src/components/prompt-input/**` -- `packages/app/src/context/sync.tsx` (optimistic API surface only) - -This workstream must not edit: - -- `packages/app/src/pages/session.tsx` (spec 09) -- `packages/app/src/pages/layout.tsx` (spec 10) -- `packages/app/src/context/global-sync.tsx` (spec 12) -- `packages/app/src/context/file.tsx` (spec 13) - ---- - -### Current state - -- File size: ~1,391 LOC for `prompt-input.tsx`. -- Existing extracted modules: - - `prompt-input/editor-dom.ts` - - `prompt-input/attachments.ts` - - `prompt-input/history.ts` - - `prompt-input/submit.ts` -- Optimistic mutation and request-part casting still need consolidation (including remaining `as unknown as Part[]` in submit path). -- Remaining concerns still tightly coupled in `prompt-input.tsx`: - - slash/mention UI rendering and keyboard orchestration - - context pill interactions and focus behavior - - composition glue across history/attachments/submit - ---- - -### Proposed structure - -Build on the existing `packages/app/src/components/prompt-input/` modules by adding/further splitting modules such as: - -- `use-prompt-composer.ts` - state machine for submit/abort/history. -- `build-request-parts.ts` - typed request-part construction. -- `slash-popover.tsx` - slash command list rendering. -- `context-items.tsx` - context pills and interactions. - -Keep existing lower-level modules (`attachments.ts`, `editor-dom.ts`, `history.ts`, `submit.ts`) and narrow their responsibilities where needed. - -Add sync-level optimistic APIs (in `context/sync.tsx` or `context/sync-optimistic.ts`): - -- `session.optimistic.add(...)` -- `session.optimistic.remove(...)` - -Prompt input should call these APIs instead of directly mutating message/part stores. - ---- - -### Phased steps - -1. Extract typed request-part builder (likely from `prompt-input/submit.ts`) to remove ad hoc casting. -2. Introduce sync optimistic APIs with current behavior. -3. Replace remaining direct `produce(...)` optimistic mutations with optimistic APIs. -4. Extract remaining UI subtrees (slash popover, context items, toolbar controls). -5. Extract controller hook and keep route component as composition shell. - ---- - -### Acceptance criteria - -- Optimistic update logic exists in one place only. -- `prompt-input.tsx` is significantly smaller (target: under 1,200 LOC). -- Prompt submit/abort/history behavior remains unchanged. -- No `as unknown as Part[]` in optimistic request construction path. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/prompt/prompt.spec.ts` - - `e2e/prompt/context.spec.ts` - - `e2e/prompt/prompt-slash-open.spec.ts` - - `e2e/prompt/prompt-mention.spec.ts` -- Manual check: - - submit with file/image/context attachments - - abort in-flight turn - - history up/down restore behavior - ---- - -### Handoff notes - -- Preserve sequence semantics around optimistic insert, worktree wait, send, and rollback. -- Keep sync optimistic API data-oriented and reusable by future callers. -- Do not mix this with broader sync/global-sync refactors in the same diff. diff --git a/specs/12-global-sync-domain-split.md b/specs/12-global-sync-domain-split.md deleted file mode 100644 index a0b600a19e5b..000000000000 --- a/specs/12-global-sync-domain-split.md +++ /dev/null @@ -1,105 +0,0 @@ -## Global sync domain split - -Refactor `context/global-sync.tsx` into domain modules while preserving behavior. - ---- - -### Summary - -`packages/app/src/context/global-sync.tsx` is a large multi-domain module (1,000+ LOC) that currently owns queue scheduling, bootstrap, child store creation, persistence bridges, session trimming, and event reduction. This workstream splits it into clear domains without changing runtime behavior. - ---- - -### Goals - -- Decompose global sync internals into maintainable modules. -- Keep `useGlobalSync()` public API unchanged. -- Isolate pure logic (session trimming, ordering, grouping) from side effects. -- Keep event handling deterministic and easier to test. - ---- - -### Non-goals - -- No protocol/API changes to server events. -- No behavior changes in session ordering, trimming, or cache semantics. -- No changes to page-level UI logic. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/context/global-sync.tsx` -- New files under `packages/app/src/context/global-sync/**` - -This workstream must not edit: - -- `packages/app/src/context/file.tsx` (spec 13) -- `packages/app/src/components/prompt-input.tsx` (spec 11) -- `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx` (specs 09/10) - ---- - -### Current state - -- Single large module with many responsibilities. -- Event reducer is embedded in component lifecycle code. -- Queue/scheduler, bootstrap, and child-store lifecycle are tightly interwoven. - ---- - -### Proposed module split - -Create `packages/app/src/context/global-sync/` modules like: - -- `types.ts` - shared types. -- `queue.ts` - refresh queue and drain scheduler. -- `child-store.ts` - child store creation, persistence wiring, cache maps. -- `session-trim.ts` - pure session sorting/trimming helpers. -- `bootstrap.ts` - global and per-directory bootstrap flows. -- `event-reducer.ts` - event handlers for SDK event stream. - -Keep `global-sync.tsx` as provider/composition entry point. - ---- - -### Phased steps - -1. Extract pure helpers (`cmp`, session trim/recent logic) first. -2. Extract queue/drain scheduler. -3. Extract child-store creation and persisted cache wiring. -4. Extract bootstrap flows. -5. Extract event reducer and wire into existing listener. -6. Keep API surface stable and documented. - ---- - -### Acceptance criteria - -- Public API of `useGlobalSync()` remains backward compatible. -- `global-sync.tsx` is substantially reduced (target: under 500 LOC). -- Event handling logic is isolated and easier to trace. -- No behavior regressions in project/session/provider sync. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/app/session.spec.ts` - - `e2e/sidebar/sidebar-session-links.spec.ts` - - `e2e/projects/projects-switch.spec.ts` -- Manual checks: - - switching directories/projects still hydrates child stores correctly - - session list/pagination behavior remains stable - ---- - -### Handoff notes - -- Favor function extraction with unchanged code first. -- Keep event handler ordering explicit; avoid implicit fallthrough behaviors. -- Add focused tests only for extracted pure helpers if practical, but avoid broad test-suite changes here. diff --git a/specs/12-session-context-metrics-shared.md b/specs/12-session-context-metrics-shared.md deleted file mode 100644 index ef534a815338..000000000000 --- a/specs/12-session-context-metrics-shared.md +++ /dev/null @@ -1,96 +0,0 @@ -## Context metrics shared - -Unify duplicate session usage calculations - ---- - -### Summary - -`session-context-tab.tsx` and `session-context-usage.tsx` both compute overlapping session metrics (cost, last assistant token totals, provider/model context usage). This creates duplicate loops and raises drift risk. We will centralize shared calculations in one helper module and have both components consume it. - ---- - -### Goals - -- Compute shared session usage metrics in one place -- Remove duplicate loops for cost and latest-token context usage -- Keep UI output unchanged in both components - ---- - -### Non-goals - -- Rewriting the detailed context breakdown estimator logic -- Changing translations or labels -- Moving metrics into backend API responses - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/components/session/session-context-tab.tsx` -- `packages/app/src/components/session-context-usage.tsx` -- New helper in `packages/app/src/components/session/*` or `packages/app/src/utils/*` - -This spec should not modify: - -- `packages/app/src/pages/session.tsx` -- `packages/app/src/context/sync.tsx` - ---- - -### Implementation plan - -1. Add shared metrics helper - -- Create helper for raw metrics from message list + provider map, e.g.: - - `totalCost` - - `lastAssistantWithTokens` - - `tokenTotal` - - `tokenUsagePercent` - - provider/model labels -- Return raw numeric values; keep locale formatting in consumers. - -2. Add memoization guard - -- Use reference-based memoization (e.g. by message-array identity) inside helper or component-level memo to avoid duplicate recalculation on unchanged arrays. - -3. Migrate both components - -- Replace duplicated loops in: - - `session-context-tab.tsx` - - `session-context-usage.tsx` -- Keep existing UI structure and i18n keys unchanged. - ---- - -### Acceptance criteria - -- Shared cost + token calculations are defined in one module. -- Both components read from the shared helper. -- Rendered values remain identical for: - - total cost - - token totals - - usage percentage - - provider/model fallback labels - ---- - -### Validation plan - -- Manual: - - Open session context tab and compare values with header/context indicator tooltip. - - Verify values update correctly while new assistant messages stream in. -- Regression: - - locale change still formats numbers/currency correctly. - ---- - -### Risks and mitigations - -- Risk: helper changes semantic edge cases (no provider, no model, missing token fields). - - Mitigation: preserve existing fallback behavior (`"—"`, null percent). -- Risk: memoization over-caches stale values. - - Mitigation: key cache by message-array reference and dependent IDs only. diff --git a/specs/13-file-context-domain-split.md b/specs/13-file-context-domain-split.md deleted file mode 100644 index 41a578e2f951..000000000000 --- a/specs/13-file-context-domain-split.md +++ /dev/null @@ -1,111 +0,0 @@ -## File context domain split - -Refactor `context/file.tsx` into focused modules with unchanged API. - ---- - -### Summary - -`packages/app/src/context/file.tsx` still combines path normalization, file-content caching/eviction, file-tree loading, watcher event handling, and file-view persistence orchestration. Recent refactoring extracted generic scoped-cache primitives to `packages/app/src/utils/scoped-cache.ts`, but most file-domain behavior remains in one module. This spec separates those concerns while preserving the existing `useFile()` interface. - ---- - -### Goals - -- Keep `useFile()` API stable for all callers. -- Extract independent domains into dedicated modules. -- Improve readability and lower risk for future file-tree/perf changes. -- Preserve current caching and watcher semantics. - ---- - -### Non-goals - -- No redesign of file tree UI. -- No change to backend file APIs. -- No simultaneous refactor of `components/file-tree.tsx` in this workstream. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/context/file.tsx` -- New files under `packages/app/src/context/file/**` -- `packages/app/src/utils/scoped-cache.ts` (only when required for file-view cache extraction) - -This workstream must not edit: - -- `packages/app/src/context/global-sync.tsx` (spec 12) -- `packages/app/src/pages/session.tsx` (spec 09) -- `packages/app/src/components/prompt-input.tsx` (spec 11) - ---- - -### Current state - -- File size: ~751 LOC. -- `packages/app/src/utils/scoped-cache.ts` now exists as a shared cache primitive used by file view persistence. -- Multiple domains in one module: - - path normalization/parsing - - LRU content memory management - - tree node/directory state management - - event-driven watcher invalidation - - per-session view cache bootstrapping - ---- - -### Proposed module split - -Create `packages/app/src/context/file/` modules such as: - -- `path.ts` - normalize/strip helpers. -- `content-cache.ts` - content LRU + byte caps. -- `view-cache.ts` - per-session file view persistence cache (building on `createScopedCache`). -- `tree-store.ts` - directory/node store and list/expand/collapse actions. -- `watcher.ts` - watcher event handling and invalidation routines. - -`file.tsx` remains the provider entry that composes these modules. - ---- - -### Phased steps - -1. Extract path helper functions with no behavior changes. -2. Extract content cache and eviction logic. -3. Extract file-specific view-cache loading/pruning logic on top of `createScopedCache`. -4. Extract tree-store list/refresh/toggle actions. -5. Extract watcher update handler and wire cleanup. -6. Keep `useFile()` return shape unchanged. - ---- - -### Acceptance criteria - -- `useFile()` API remains backward compatible. -- `context/file.tsx` is reduced significantly (target: under 350 LOC). -- Tree loading/refresh and content eviction behavior remain unchanged. -- Watcher-driven reload behavior still works for changed/added/deleted files. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/files/file-tree.spec.ts` - - `e2e/files/file-viewer.spec.ts` - - `e2e/files/file-open.spec.ts` -- Manual checks: - - directory expand/collapse and refresh - - large file navigation and cache reuse - - watcher-driven updates in active file tabs - ---- - -### Handoff notes - -- Keep tree/data stores colocated with their mutation helpers. -- Avoid changing persisted key names or cache key shapes in this pass. -- Save broader API cleanups for a follow-up once modules are stable. diff --git a/specs/13-file-tree-fetch-discipline.md b/specs/13-file-tree-fetch-discipline.md deleted file mode 100644 index 7fe71fc7abc3..000000000000 --- a/specs/13-file-tree-fetch-discipline.md +++ /dev/null @@ -1,88 +0,0 @@ -## File tree fetches - -Make directory listing triggers explicit and minimal - ---- - -### Summary - -`packages/app/src/components/file-tree.tsx` currently invokes `file.tree.list(path)` from a generic effect in each tree instance. Even with inflight guards, this pattern causes avoidable list calls and makes load behavior harder to reason about. This spec tightens fetch triggers. - ---- - -### Goals - -- Avoid redundant list invocations from passive rerenders -- Fetch directory data only when needed (mount + expansion + explicit refresh) -- Keep tree behavior unchanged for users - ---- - -### Non-goals - -- Replacing recursive tree rendering with virtualization -- Changing file-tree visual design -- Backend/API changes for file listing - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/components/file-tree.tsx` - -This spec should not modify: - -- `packages/app/src/context/file.tsx` -- `packages/app/src/pages/session.tsx` - ---- - -### Implementation plan - -1. Replace broad list effect with explicit triggers - -- Load root path on mount. -- For nested directories, list only when: - - node is expanded, or - - parent explicitly requests refresh. - -2. Guard expansion-driven fetches - -- Keep `file.tree.expand(path)` as the primary source of truth for expansion fetches. -- Ensure passive rerenders do not retrigger `list(path)` calls for already loaded dirs. - -3. Keep filter auto-expand behavior - -- Preserve existing "allowed filter" directory auto-expansion. -- Ensure auto-expanded directories still fetch exactly once unless force refresh occurs. - ---- - -### Acceptance criteria - -- `file-tree.tsx` no longer calls `file.tree.list(path)` from an unscoped rerender effect. -- Expanding a folder still loads its children correctly. -- Filtering by `allowed` still opens and shows required parent directories. -- No regressions in change/all tabs where `FileTree` is used. - ---- - -### Validation plan - -- Manual: - - Expand/collapse deep directory trees repeatedly. - - Switch between "changes" and "all" tree tabs. - - Open review, click files, verify tree stays responsive. -- Optional instrumentation: - - count list calls per user action and compare before/after. - ---- - -### Risks and mitigations - -- Risk: directories fail to load when expansion timing changes. - - Mitigation: rely on `expand()` path and verify for root + nested nodes. -- Risk: filter-driven auto-expand misses one level. - - Mitigation: keep existing auto-expand iteration and add regression checks. diff --git a/specs/14-comments-aggregation-index.md b/specs/14-comments-aggregation-index.md deleted file mode 100644 index 3afb1ecfefed..000000000000 --- a/specs/14-comments-aggregation-index.md +++ /dev/null @@ -1,87 +0,0 @@ -## Comments indexing - -Avoid repeated flatten+sort for comment aggregates - ---- - -### Summary - -`packages/app/src/context/comments.tsx` derives `all` by flattening all file comment arrays and sorting on every change. This is simple but can become expensive with many comments. We will maintain an indexed aggregate structure incrementally. - ---- - -### Goals - -- Keep `comments.list(file)` behavior unchanged -- Make `comments.all()` retrieval near O(1) for reads -- Preserve chronological ordering guarantees - ---- - -### Non-goals - -- Persisting comments in a new schema -- Adding new comment metadata fields -- UI changes for comment display - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/context/comments.tsx` -- Optional tests for comments context - -This spec should not modify: - -- `packages/app/src/pages/session.tsx` -- `packages/ui/src/components/line-comment.tsx` - ---- - -### Implementation plan - -1. Add aggregate index state - -- Maintain `commentsByFile` (existing) plus an `allComments` array in chronological order. -- Keep both updated through the same mutator paths. - -2. Update mutators - -- `add`: append new comment to file list and aggregate list. -- `remove`: remove from file list and aggregate list by id/file. -- `clear`: reset both structures and focus/active state. - -3. Simplify selectors - -- `list(file)` reads file list directly. -- `all()` returns pre-indexed aggregate list without per-read flatten+sort. - ---- - -### Acceptance criteria - -- `comments.all()` no longer flattens and sorts every reactive run. -- Comment order stays chronological by `time`. -- `add/remove/clear/focus/active` semantics remain unchanged. - ---- - -### Validation plan - -- Manual: - - Add multiple comments across different files. - - Remove one comment and verify both file-level and global views update correctly. - - Submit prompt (which clears comments) and verify reset behavior. -- Optional unit test: - - add/remove/clear keeps aggregate ordering and integrity. - ---- - -### Risks and mitigations - -- Risk: aggregate list and per-file lists diverge. - - Mitigation: funnel all writes through centralized mutators; avoid direct store writes elsewhere. -- Risk: ID collision edge cases. - - Mitigation: keep UUID creation unchanged and remove by `file + id` pair. diff --git a/specs/14-server-health-and-row-dedupe.md b/specs/14-server-health-and-row-dedupe.md deleted file mode 100644 index ded825180ae2..000000000000 --- a/specs/14-server-health-and-row-dedupe.md +++ /dev/null @@ -1,108 +0,0 @@ -## Server health and row dedupe - -Unify server health checks and deduplicate server-row UI logic. - ---- - -### Summary - -Server health logic is duplicated across multiple files, and server row rendering/truncation logic is repeated in both the status popover and server dialog. This creates drift risk and inconsistent behavior. This spec centralizes health checks and row rendering while preserving existing UX. - ---- - -### Goals - -- Introduce one shared server-health checker. -- Use consistent timeout and error semantics in all server health call sites. -- Deduplicate repeated server row truncation/tooltip behavior. -- Keep current polling interval and status semantics unless explicitly changed. - ---- - -### Non-goals - -- No redesign of the status popover or server dialog. -- No changes to server persistence model. -- No broad refactor of unrelated status tabs (MCP/LSP/plugins). - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/components/dialog-select-server.tsx` -- `packages/app/src/components/status-popover.tsx` -- `packages/app/src/context/server.tsx` -- New files under `packages/app/src/components/server/**` and/or `packages/app/src/utils/server-health.ts` - -This workstream must not edit: - -- `packages/app/src/components/terminal.tsx` (spec 15) -- `packages/app/src/pages/session.tsx` and `packages/app/src/pages/layout.tsx` (specs 09/10) - ---- - -### Current state - -- Duplicate `checkHealth` implementation in: - - `components/dialog-select-server.tsx` - - `components/status-popover.tsx` -- Similar health check logic in `context/server.tsx`. -- Duplicate row truncation + resize listener logic in status and dialog server lists. - ---- - -### Proposed approach - -1. Add shared health utility: - -- `checkServerHealth(url, fetch, opts)` -- one timeout strategy -- one return shape: `{ healthy: boolean, version?: string }` - -2. Add shared server row primitive: - -- common rendering for status dot, truncated name/version handling, tooltip content -- optional action slots for per-screen controls - -3. Adopt utility and row primitive in both consumers. - ---- - -### Phased steps - -1. Create `utils/server-health.ts` and migrate all health call sites. -2. Create shared row component (`components/server/server-row.tsx`). -3. Replace duplicated row logic in server dialog and status popover. -4. Confirm polling and active/default server behavior still match existing UX. - ---- - -### Acceptance criteria - -- Exactly one app-level server health check implementation remains. -- Server row truncation/tooltip behavior is shared, not duplicated. -- No regressions when switching active/default server. -- Existing status dot semantics are preserved. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/status/status-popover.spec.ts` - - `e2e/app/server-default.spec.ts` -- Manual checks: - - add/edit/remove server - - blocked unhealthy server behavior - - default server toggles and persistence - ---- - -### Handoff notes - -- Keep shared server row API minimal and composable. -- Avoid introducing new global state for this refactor. -- Prefer deterministic helper behavior over UI-specific branching inside the utility. diff --git a/specs/15-prompt-input-modularization.md b/specs/15-prompt-input-modularization.md deleted file mode 100644 index f80785f0ea00..000000000000 --- a/specs/15-prompt-input-modularization.md +++ /dev/null @@ -1,104 +0,0 @@ -## Prompt input split - -Modularize `prompt-input.tsx` without behavior changes - ---- - -### Summary - -`packages/app/src/components/prompt-input.tsx` is a very large component that combines editor DOM parsing, popovers, history, drag/drop + paste uploads, worktree/session creation, optimistic messages, and send/abort flow. This spec splits it into focused modules so future changes are safer. - ---- - -### Goals - -- Reduce `prompt-input.tsx` complexity and file size -- Extract cohesive logic into testable hooks/helpers -- Keep runtime behavior and UX unchanged - ---- - -### Non-goals - -- Replacing contenteditable editor approach -- Major UX redesign of composer controls -- API contract changes for prompt submission - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/components/prompt-input.tsx` -- New files under `packages/app/src/components/prompt-input/*` - -This spec should not modify: - -- `packages/app/src/pages/session.tsx` -- `packages/app/src/context/prompt.tsx` (except minor type-only imports if needed) - ---- - -### Implementation plan - -1. Extract editor DOM helpers - -- Move pure DOM/selection helpers into `prompt-input/editor-dom.ts`: - - `createTextFragment` - - `getNodeLength` - - `getTextLength` - - cursor get/set helpers - -2. Extract history controller - -- Move prompt history read/write/navigation logic into `prompt-input/history.ts` hook. -- Keep existing persisted keys and history semantics unchanged. - -3. Extract attachment interactions - -- Move image/file paste + drag/drop + file-input attachment flows to `prompt-input/attachments.ts` hook. - -4. Extract submit pipeline - -- Move send/abort/optimistic message pipeline to `prompt-input/submit.ts` service/hook. -- Keep existing error toasts, worktree handling, and rollback behavior. - -5. Keep composition shell stable - -- `PromptInput` component remains the integration shell that wires hooks + JSX. -- Preserve exported component API and props. - ---- - -### Acceptance criteria - -- `prompt-input.tsx` becomes primarily orchestration + view code. -- Extracted modules contain the heavy imperative logic. -- All existing behaviors remain intact: - - slash and @ popovers - - history up/down navigation - - image attach/paste/drag-drop - - shell mode submit/abort - - optimistic message + rollback on failure - ---- - -### Validation plan - -- Manual regression checklist: - - type prompt, submit, stop, retry - - use `/` command selection and `@` selector - - history navigation with arrows - - paste image, drag image, remove attachment - - start in new session + worktree create path - - failure path restores prompt and context comments - ---- - -### Risks and mitigations - -- Risk: subtle ordering changes in submit rollback logic. - - Mitigation: migrate logic mechanically first, then cleanup. -- Risk: editor selection bugs after helper extraction. - - Mitigation: keep existing cursor helpers unchanged and add focused manual checks. diff --git a/specs/15-runtime-adapter-type-safety.md b/specs/15-runtime-adapter-type-safety.md deleted file mode 100644 index 99c8f6d303bd..000000000000 --- a/specs/15-runtime-adapter-type-safety.md +++ /dev/null @@ -1,106 +0,0 @@ -## Runtime adapter type safety - -Reduce unsafe casts at browser and third-party integration boundaries. - ---- - -### Summary - -Several integration points rely on `as any` or `unknown as` casts (terminal internals, speech recognition, add-on internals, generic trigger props). This spec introduces typed adapters and narrow interfaces to improve maintainability and make type errors actionable. - ---- - -### Goals - -- Remove or significantly reduce unsafe casts in scoped files. -- Introduce explicit adapter interfaces around unstable third-party APIs. -- Preserve behavior with no UX changes. -- Improve maintainability of terminal and speech integrations. - ---- - -### Non-goals - -- No server health dedupe work (owned by spec 14). -- No large architectural changes to terminal or speech subsystems. -- No changes to business logic semantics. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/components/terminal.tsx` -- `packages/app/src/utils/speech.ts` -- `packages/app/src/addons/serialize.ts` -- `packages/app/src/components/dialog-select-model.tsx` -- New utility files under `packages/app/src/utils/**` related to adapter typing - -This workstream must not edit: - -- `components/dialog-select-server.tsx`, `components/status-popover.tsx`, `context/server.tsx` (spec 14) -- `components/prompt-input.tsx` (spec 11) - ---- - -### Current state - -- Explicit `as any` appears in `serialize.ts` and `speech.ts`. -- Multiple `unknown as` casts in `terminal.tsx` for option/disposable access. -- Generic trigger props in `dialog-select-model.tsx` use `as any` spread. - ---- - -### Proposed approach - -1. Add narrow adapter types for third-party internals: - -- terminal option setter/disposable handles -- speech recognition constructor on `window` -- serialize addon internal terminal buffer access - -2. Introduce tiny helper guards/utilities: - -- `isDisposable(value): value is { dispose(): void }` -- `hasSetOption(value): value is { setOption(...): void }` - -3. Replace broad casts with adapter functions and runtime checks. - ---- - -### Phased steps - -1. Refactor terminal helpers (`setOption`, disposal cleanups) to typed guards. -2. Refactor speech recognition window access to typed constructor lookup. -3. Replace `serialize.ts` `as any` internals with explicit local interface. -4. Remove `dialog-select-model.tsx` `as any` trigger props cast via stricter generic typing. - ---- - -### Acceptance criteria - -- No `as any` remains in the scoped files (or document unavoidable cases inline). -- `unknown as` usage in scoped files is minimized and justified. -- Typecheck passes with no new suppression comments. -- Runtime behavior remains unchanged. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Targeted e2e checks: - - `e2e/terminal/terminal.spec.ts` - - `e2e/models/model-picker.spec.ts` -- Manual checks: - - terminal open/connect/resize/cleanup - - speech start/stop and interim/final behavior - ---- - -### Handoff notes - -- Prefer small typed wrapper functions over inline complex narrowing. -- Keep adapter names explicit and local to their integration point. -- If a cast cannot be removed safely, add a short comment describing why. diff --git a/specs/16-i18n-hardening-and-parity.md b/specs/16-i18n-hardening-and-parity.md deleted file mode 100644 index 58cbeeaf8604..000000000000 --- a/specs/16-i18n-hardening-and-parity.md +++ /dev/null @@ -1,107 +0,0 @@ -## i18n hardening and parity - -Strengthen locale correctness and remove remaining hardcoded copy. - ---- - -### Summary - -The app has broad translation coverage but still has maintainability gaps: locale dictionaries are typed as `Partial`, some non-English dictionaries contain English values for specific keys, and a few user-facing strings are still hardcoded in components/pages. This spec hardens i18n guarantees and cleans up remaining drift. - ---- - -### Goals - -- Enforce stricter dictionary key parity across all app locales. -- Remove known English fallback strings from non-English locale files. -- Localize remaining hardcoded user-facing strings in scoped files. -- Keep existing localization architecture (`useLanguage().t(...)`) intact. - ---- - -### Non-goals - -- No translation quality rewrite for all strings. -- No locale expansion beyond existing languages. -- No changes to non-user-facing log/diagnostic strings. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/src/context/language.tsx` -- `packages/app/src/i18n/*.ts` -- `packages/app/src/components/dialog-custom-provider.tsx` -- `packages/app/src/pages/directory-layout.tsx` - -This workstream must not edit: - -- `pages/session.tsx`, `pages/layout.tsx`, `components/prompt-input.tsx` -- server/terminal integration files owned by specs 14/15 - ---- - -### Current state - -- Locale files are large and manually maintained. -- Non-English locales are typed with `Partial>`, which allows silent missing keys. -- Known untranslated strings exist for keys like: - - `command.session.previous.unseen` - - `command.session.next.unseen` -- Some user-facing strings remain hardcoded in scoped files. - ---- - -### Proposed approach - -1. Tighten locale typing: - -- Move from `Partial>` to stricter parity enforcement. -- Keep `en.ts` as source-of-truth key set. - -2. Fix known untranslated key values in non-English dictionaries. - -3. Localize scoped hardcoded strings by adding translation keys and using `language.t(...)`. - ---- - -### Phased steps - -1. Add/adjust shared locale typing pattern for parity safety. -2. Update all locale files to satisfy stricter typing. -3. Translate known English carry-over keys in non-English dictionaries. -4. Replace hardcoded copy in: - -- `components/dialog-custom-provider.tsx` -- `pages/directory-layout.tsx` - -5. Run typecheck and parity checks. - ---- - -### Acceptance criteria - -- Locale files enforce full key parity against `en` (compile-time). -- No known English carry-over values remain for the targeted keys in non-English locales. -- Scoped hardcoded user-facing strings are replaced with translation keys. -- Typecheck passes. - ---- - -### Validation plan - -- Typecheck: `bun run typecheck` (from `packages/app`). -- Grep sanity checks: - - targeted keys no longer English in non-English locales - - scoped files no longer contain hardcoded user-facing copy -- Manual spot checks in at least 2 locales (for example: `de`, `zh`). - ---- - -### Handoff notes - -- Keep key naming consistent with existing conventions. -- Avoid broad copy changes outside scoped files to reduce review surface. -- If translation wording is uncertain, keep it simple and literal for now; quality passes can follow. diff --git a/specs/16-terminal-cache-key-clarity.md b/specs/16-terminal-cache-key-clarity.md deleted file mode 100644 index 667f4f3c3cc3..000000000000 --- a/specs/16-terminal-cache-key-clarity.md +++ /dev/null @@ -1,82 +0,0 @@ -## Terminal cache scope - -Clarify workspace-only terminal cache semantics - ---- - -### Summary - -`packages/app/src/context/terminal.tsx` accepts `(dir, session)` but currently keys cache entries as `${dir}:${WORKSPACE_KEY}`. The behavior is workspace-scoped, but the API shape suggests session-scoped caching. This spec aligns naming and implementation to avoid confusion and future bugs. - ---- - -### Goals - -- Make terminal cache scope explicit (workspace-scoped) -- Remove misleading unused session-keying surface -- Preserve existing runtime behavior - ---- - -### Non-goals - -- Changing terminal persistence behavior -- Moving terminals to per-session isolation -- UI changes to terminal tabs - ---- - -### Parallel execution contract - -This spec owns: - -- `packages/app/src/context/terminal.tsx` - -This spec should not modify: - -- `packages/app/src/pages/session.tsx` -- `packages/app/src/components/session/session-sortable-terminal-tab.tsx` - ---- - -### Implementation plan - -1. Rename internals for clarity - -- Update internal function names/variables from session-oriented to workspace-oriented where applicable. - -2. Remove unused session cache-key parametering - -- Simplify `load`/factory signatures so keying intent is explicit. -- Keep key format workspace-only by directory. - -3. Add inline documentation - -- Add short comment near cache key creation clarifying why terminals are shared across sessions in the same workspace. - -4. Keep behavior stable - -- Ensure active terminal, tab order, clone/new/close behavior remain unchanged. - ---- - -### Acceptance criteria - -- No unused session-derived cache key logic remains. -- Code communicates workspace-scoped terminal lifecycle clearly. -- No functional changes to terminal operations. - ---- - -### Validation plan - -- Manual: - - Create multiple terminals, navigate between sessions in same workspace, confirm state continuity. - - Switch workspace directory, confirm separate terminal state. - ---- - -### Risks and mitigations - -- Risk: accidental behavior change to session-scoped terminals. - - Mitigation: keep cache key unchanged; refactor naming/signatures only. diff --git a/specs/17-unit-test-foundation.md b/specs/17-unit-test-foundation.md deleted file mode 100644 index 2b6a9394539e..000000000000 --- a/specs/17-unit-test-foundation.md +++ /dev/null @@ -1,101 +0,0 @@ -## Unit test foundation - -Establish reliable unit coverage for core app logic. - ---- - -### Summary - -`packages/app` is still e2e-first, but recent refactoring added a first wave of active source-unit tests (session helpers/scroll spy, prompt-input modules, file-tree, comments/layout/terminal/file context, and scoped-cache). This spec focuses on turning that momentum into a stable, explicit unit-test baseline in CI/local and unblocking the remaining skipped legacy suites. - ---- - -### Goals - -- Add a clear unit-test command for app source tests. -- Unskip and stabilize existing skipped unit tests. -- Add fast tests for high-value pure logic. -- Keep unit suite independent of full e2e environment. - ---- - -### Non-goals - -- No replacement of e2e tests. -- No broad product-code refactors unless required to make logic testable. -- No flaky browser-automation tests added here. - ---- - -### Parallel ownership (important) - -This workstream owns: - -- `packages/app/package.json` (test scripts only) -- `packages/app/happydom.ts` (if harness tweaks are needed) -- `packages/app/src/**/*.test.ts` -- `packages/app/src/**/*.test.tsx` - -This workstream should avoid editing product code files owned by other specs, unless a tiny testability export is strictly required. - ---- - -### Current state - -- Active unit coverage now exists across several `src/**/*.test.*` files (including context, pages/session, components/prompt-input, and utils). -- Remaining skipped legacy suites: - - `src/context/layout-scroll.test.ts` (`test.skip`) - - `src/addons/serialize.test.ts` (`describe.skip`) -- `package.json` scripts still focus on Playwright e2e and do not expose a dedicated `test:unit` entrypoint. - ---- - -### Proposed approach - -1. Add dedicated unit-test script(s), for example: - -- `test:unit` using Bun test + happydom preload where needed. - -2. Unskip and stabilize remaining skipped legacy tests: - -- make `layout-scroll.test.ts` deterministic -- enable a reliable subset of `serialize.test.ts` (or split smoke vs heavy integration cases) - -3. Add/expand fast unit tests for high-value pure logic not yet covered: - -- keybind parsing/formatting/matching (`context/command.tsx` exports) -- worktree state machine (`utils/worktree.ts`) - ---- - -### Phased steps - -1. Wire `test:unit` in `package.json`. -2. Make existing skipped tests runnable and stable. -3. Add at least 2 new unit test files for core pure logic. -4. Ensure unit suite can run standalone without Playwright server setup. - ---- - -### Acceptance criteria - -- `bun run test:unit` exists and passes locally. -- No full-file `describe.skip`/`test.skip` remains in `packages/app/src/**/*.test.*` (unless documented as intentionally quarantined with reason). -- Unit suite includes meaningful assertions for keybind + worktree logic. -- Runtime for unit suite remains fast (target: under 15 seconds locally, excluding first install). - ---- - -### Validation plan - -- Run: `bun run test:unit`. -- Run: `bun run typecheck`. -- Verify unit tests can execute without starting full app/backend servers. - ---- - -### Handoff notes - -- Keep tests implementation-focused, not duplicated business logic. -- Avoid mocks where practical; prefer real small-scope code paths. -- If integration-heavy serialize cases remain flaky, separate them into a clearly named non-default test target. diff --git a/specs/18-parallel-workstream-map.md b/specs/18-parallel-workstream-map.md deleted file mode 100644 index 8a769f673c9e..000000000000 --- a/specs/18-parallel-workstream-map.md +++ /dev/null @@ -1,51 +0,0 @@ -## Parallel workstream map - -Use this as the assignment sheet for running multiple agents at once. - ---- - -### Workstreams - -1. `specs/09-session-page-decomposition.md` -2. `specs/10-layout-page-decomposition.md` -3. `specs/11-prompt-input-and-optimistic-state.md` -4. `specs/12-global-sync-domain-split.md` -5. `specs/13-file-context-domain-split.md` -6. `specs/14-server-health-and-row-dedupe.md` -7. `specs/15-runtime-adapter-type-safety.md` -8. `specs/16-i18n-hardening-and-parity.md` -9. `specs/17-unit-test-foundation.md` - ---- - -### File-ownership matrix - -| Spec | Primary ownership | Avoid editing | -| ---- | ----------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ | -| 09 | `pages/session.tsx`, `pages/session/**` | `pages/layout.tsx`, `components/prompt-input.tsx`, `context/global-sync.tsx`, `context/file.tsx` | -| 10 | `pages/layout.tsx`, `pages/layout/**` | `pages/session.tsx`, `components/prompt-input.tsx`, `context/global-sync.tsx` | -| 11 | `components/prompt-input.tsx`, `components/prompt-input/**`, `context/sync.tsx` (optimistic API only) | `pages/session.tsx`, `pages/layout.tsx`, `context/global-sync.tsx`, `context/file.tsx` | -| 12 | `context/global-sync.tsx`, `context/global-sync/**` | `context/file.tsx`, `components/prompt-input.tsx`, page files | -| 13 | `context/file.tsx`, `context/file/**`, `utils/scoped-cache.ts` (only when file-view cache extraction needs it) | `context/global-sync.tsx`, `components/prompt-input.tsx`, page files | -| 14 | `components/dialog-select-server.tsx`, `components/status-popover.tsx`, `context/server.tsx`, shared server utility/component | terminal/speech/serialize files | -| 15 | `components/terminal.tsx`, `utils/speech.ts`, `addons/serialize.ts`, `components/dialog-select-model.tsx`, adapter utilities | server status/dialog/context files | -| 16 | `context/language.tsx`, `i18n/*.ts`, `components/dialog-custom-provider.tsx`, `pages/directory-layout.tsx` | major page/context refactors | -| 17 | `package.json` (test scripts), `happydom.ts`, `src/**/*.test.*` | product code files in other specs unless strictly needed | - ---- - -### Recommended execution order (if all start together) - -- Start all 9 in parallel. -- Merge low-conflict streams first: 12, 13, 14, 15, 16, 17. -- Then merge 09, 10, 11 (largest diff sizes and highest rebase probability). - ---- - -### Integration checkpoint - -After all streams merge, run a full verification pass in `packages/app`: - -- `bun run typecheck` -- `bun run test:unit` (from spec 17) -- targeted e2e smoke for session/layout/prompt/server/terminal flows diff --git a/specs/parallel-agent-plan.md b/specs/parallel-agent-plan.md deleted file mode 100644 index f7398c669072..000000000000 --- a/specs/parallel-agent-plan.md +++ /dev/null @@ -1,59 +0,0 @@ -## Parallel agent plan - -Execution map for session-page improvement concerns - ---- - -### New specs added - -- `specs/09-session-page-hot-paths.md` -- `specs/10-file-content-eviction-accounting.md` -- `specs/11-layout-view-tabs-reactivity.md` -- `specs/12-session-context-metrics-shared.md` -- `specs/13-file-tree-fetch-discipline.md` -- `specs/14-comments-aggregation-index.md` -- `specs/15-prompt-input-modularization.md` -- `specs/16-terminal-cache-key-clarity.md` - ---- - -### Existing related specs - -- `specs/04-scroll-spy-optimization.md` (session scroll-spy concern) -- `specs/05-modularize-and-dedupe.md` (broad modularization roadmap) - ---- - -### Parallel-safe batching - -Batch A (run one at a time, shared `session.tsx` surface): - -- `specs/09-session-page-hot-paths.md` -- `specs/04-scroll-spy-optimization.md` - -Batch B (parallel with each other and with Batch A): - -- `specs/10-file-content-eviction-accounting.md` -- `specs/11-layout-view-tabs-reactivity.md` -- `specs/12-session-context-metrics-shared.md` -- `specs/13-file-tree-fetch-discipline.md` -- `specs/14-comments-aggregation-index.md` -- `specs/15-prompt-input-modularization.md` -- `specs/16-terminal-cache-key-clarity.md` - -Batch C (broad follow-up after focused specs land): - -- `specs/05-modularize-and-dedupe.md` - ---- - -### Suggested assignment - -1. Agent A: `specs/09-session-page-hot-paths.md` -2. Agent B: `specs/10-file-content-eviction-accounting.md` -3. Agent C: `specs/11-layout-view-tabs-reactivity.md` -4. Agent D: `specs/12-session-context-metrics-shared.md` -5. Agent E: `specs/13-file-tree-fetch-discipline.md` -6. Agent F: `specs/14-comments-aggregation-index.md` -7. Agent G: `specs/15-prompt-input-modularization.md` -8. Agent H: `specs/16-terminal-cache-key-clarity.md` diff --git a/specs/perf-roadmap.md b/specs/perf-roadmap.md deleted file mode 100644 index 7d5ac93cbe54..000000000000 --- a/specs/perf-roadmap.md +++ /dev/null @@ -1,196 +0,0 @@ -## Performance roadmap - -Sequenced delivery plan for app scalability + maintainability - ---- - -### Objective - -Deliver the top 5 app improvements (performance + long-term flexibility) in a safe, incremental sequence that: - -- minimizes regression risk -- keeps changes reviewable (small PRs) -- provides escape hatches (flags / caps) -- validates improvements with targeted measurements - -This roadmap ties together: - -- `specs/01-persist-payload-limits.md` -- `specs/02-cache-eviction.md` -- `specs/03-request-throttling.md` -- `specs/04-scroll-spy-optimization.md` -- `specs/05-modularize-and-dedupe.md` - ---- - -### Guiding principles - -- Prefer “guardrails first”: add caps/limits and do no harm, then optimize. -- Always ship behind flags if behavior changes (especially persistence and eviction). -- Optimize at chokepoints (SDK call wrappers, storage wrappers, scroll-spy module) instead of fixing symptoms at every call site. -- Make “hot paths” explicitly measurable in dev (e.g. via `packages/app/src/utils/perf.ts`). - ---- - -### Phase 0 — Baseline + flags (prep) - -**Goal:** make later changes safe to land and easy to revert. - -**Deliverables** - -- Feature-flag plumbing for: - - persistence payload limits (`persist.payloadLimits`) - - request debouncing/latest-only (`requests.*`) - - cache eviction (`cache.eviction.*`) - - optimized scroll spy (`session.scrollSpyOptimized`) - - shared scoped cache (`scopedCache.shared`) -- Dev-only counters/logs for: - - persist oversize detections - - request aborts/stale drops - - eviction counts and retained sizes - - scroll-spy compute time per second - -**Exit criteria** - -- Flags exist but default “off” for behavior changes. -- No user-visible behavior changes. - -**Effort / risk**: `S–M` / low - ---- - -### Phase 1 — Stop the worst “jank generators” (storage + request storms) - -**Goal:** remove the highest-frequency sources of main-thread blocking and redundant work. - -**Work items** - -- Implement file search debounce + stale-result protection - - Spec: `specs/03-request-throttling.md` - - Start with file search only (lowest risk, easy to observe). -- Add persistence payload size checks + warnings (no enforcement yet) - - Spec: `specs/01-persist-payload-limits.md` - - Focus on detecting oversized keys and preventing repeated write attempts. -- Ship prompt-history “strip image dataUrl” behind a flag - - Spec: `specs/01-persist-payload-limits.md` - - Keep image metadata placeholders so UI remains coherent. - -**Exit criteria** - -- Fast typing in file search generates at most 1 request per debounce window. -- Oversize persisted keys are detected and do not cause repeated blocking writes. -- Prompt history reload does not attempt to restore base64 `dataUrl` on web when flag enabled. - -**Effort / risk**: `M` / low–med - ---- - -### Phase 2 — Bound memory growth (in-memory eviction) - -**Goal:** stabilize memory footprint for long-running sessions and “project hopping”. - -**Work items** - -- Introduce shared LRU/TTL cache helper - - Spec: `specs/02-cache-eviction.md` -- Apply eviction to file contents cache first - - Spec: `specs/02-cache-eviction.md` - - Pin open tabs / active file to prevent flicker. -- Add conservative eviction for global-sync per-directory child stores - - Spec: `specs/02-cache-eviction.md` - - Ensure evicted children are fully disposed. -- (Optional) session/message eviction if memory growth persists after the above - - Spec: `specs/02-cache-eviction.md` - -**Exit criteria** - -- Opening many files does not continuously increase JS heap without bound. -- Switching across many directories does not keep all directory stores alive indefinitely. -- Eviction never removes currently active session/file content. - -**Effort / risk**: `M–L` / med - ---- - -### Phase 3 — Large session scroll scalability (scroll spy) - -**Goal:** keep scrolling smooth as message count increases. - -**Work items** - -- Extract scroll-spy logic into a dedicated module (no behavior change) - - Spec: `specs/04-scroll-spy-optimization.md` -- Implement IntersectionObserver tracking behind flag - - Spec: `specs/04-scroll-spy-optimization.md` -- Add binary search fallback for non-observer environments - - Spec: `specs/04-scroll-spy-optimization.md` - -**Exit criteria** - -- Scroll handler no longer calls `querySelectorAll('[data-message-id]')` on every scroll tick. -- Long sessions (hundreds of messages) maintain smooth scrolling. -- Active message selection remains stable during streaming/layout shifts. - -**Effort / risk**: `M` / med - ---- - -### Phase 4 — “Make it easy to keep fast” (modularity + dedupe) - -**Goal:** reduce maintenance cost and make future perf work cheaper. - -**Work items** - -- Introduce shared scoped-cache utility and adopt in one low-risk area - - Spec: `specs/05-modularize-and-dedupe.md` -- Incrementally split mega-components (one PR per extraction) - - Spec: `specs/05-modularize-and-dedupe.md` - - Prioritize extracting: - - session scroll/backfill logic - - prompt editor model/history - - layout event/shortcut wiring -- Remove duplicated patterns after confidence + one release cycle - -**Exit criteria** - -- Each mega-file drops below a target size (suggestion): - - `session.tsx` < ~800 LOC - - `prompt-input.tsx` < ~900 LOC -- “Scoped cache” has a single implementation used across contexts. -- Future perf fixes land in isolated modules with minimal cross-cutting change. - -**Effort / risk**: `L` / med–high - ---- - -### Recommended PR slicing (keeps reviews safe) - -- PR A: add request helpers + file search debounce (flagged) -- PR B: persist size detection + logs (no behavior change) -- PR C: prompt history strip images (flagged) -- PR D: cache helper + file content eviction (flagged) -- PR E: global-sync child eviction (flagged) -- PR F: scroll-spy extraction (no behavior change) -- PR G: optimized scroll-spy implementation (flagged) -- PR H+: modularization PRs (small, mechanical refactors) - ---- - -### Rollout strategy - -- Keep defaults conservative and ship flags “off” first. -- Enable flags internally (dev builds) to gather confidence. -- Flip defaults in this order: - 1. file search debounce - 2. prompt-history image stripping - 3. file-content eviction - 4. global-sync child eviction - 5. optimized scroll-spy - ---- - -### Open questions - -- What are acceptable defaults for storage caps and cache sizes for typical OpenCode usage? -- Does the SDK support `AbortSignal` end-to-end for cancellation, or do we rely on stale-result dropping? -- Should web and desktop persistence semantics be aligned (even if desktop has async storage available)? diff --git a/sst-env.d.ts b/sst-env.d.ts index 2d60c83ecdd4..f87d4d603f6a 100644 --- a/sst-env.d.ts +++ b/sst-env.d.ts @@ -163,10 +163,50 @@ declare module "sst" { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS11": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS12": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS13": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS14": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS15": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS16": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS17": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS18": { + "type": "sst.sst.Secret" + "value": string + } + "ZEN_MODELS19": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS2": { "type": "sst.sst.Secret" "value": string } + "ZEN_MODELS20": { + "type": "sst.sst.Secret" + "value": string + } "ZEN_MODELS3": { "type": "sst.sst.Secret" "value": string