From 958a80cc052b9b342dfa2a92c0a4caf1c4418fa9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 24 Mar 2026 23:38:04 -0400 Subject: [PATCH 01/36] fix: increase operations-per-run to 1000 and pin stale action to v10.2.0 The stale-issues workflow was hitting the default 30 operations limit, preventing it from processing all 2900+ issues/PRs. Increased to 1000 to handle the full backlog. Also pinned to exact v10.2.0 for reproducibility. --- .github/workflows/stale-issues.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml index a4b8583f92..c265281203 100644 --- a/.github/workflows/stale-issues.yml +++ b/.github/workflows/stale-issues.yml @@ -15,8 +15,9 @@ jobs: permissions: issues: write steps: - - uses: actions/stale@v10 + - uses: actions/stale@v10.2.0 with: + operations-per-run: 1000 days-before-stale: ${{ env.DAYS_BEFORE_STALE }} days-before-close: ${{ env.DAYS_BEFORE_CLOSE }} stale-issue-label: "stale" From 79e9d19019e4edd43c6001545a2cbbbd37d8c14f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 24 Mar 2026 23:50:35 -0400 Subject: [PATCH 02/36] Add close-issues script and GitHub Action - Create script/github/close-issues.ts to close stale issues after 60 days - Add GitHub Action workflow to run daily at 2 AM - Remove old stale-issues workflow to avoid conflicts --- .github/workflows/close-issues.yml | 23 ++++++++ .github/workflows/stale-issues.yml | 34 ----------- script/github/close-issues.ts | 92 ++++++++++++++++++++++++++++++ 3 files changed, 115 insertions(+), 34 deletions(-) create mode 100644 .github/workflows/close-issues.yml delete mode 100644 .github/workflows/stale-issues.yml create mode 100755 script/github/close-issues.ts diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml new file mode 100644 index 0000000000..3ee97ee535 --- /dev/null +++ b/.github/workflows/close-issues.yml @@ -0,0 +1,23 @@ +name: close-issues + +on: + schedule: + - cron: "0 2 * * *" # Daily at 2:00 AM + workflow_dispatch: + +jobs: + close: + runs-on: ubuntu-latest + permissions: + issues: write + steps: + - uses: actions/checkout@v4 + + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Close stale issues + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + run: bun script/github/close-issues.ts diff --git a/.github/workflows/stale-issues.yml b/.github/workflows/stale-issues.yml deleted file mode 100644 index c265281203..0000000000 --- a/.github/workflows/stale-issues.yml +++ /dev/null @@ -1,34 +0,0 @@ -name: stale-issues - -on: - schedule: - - cron: "30 1 * * *" # Daily at 1:30 AM - workflow_dispatch: - -env: - DAYS_BEFORE_STALE: 90 - DAYS_BEFORE_CLOSE: 7 - -jobs: - stale: - runs-on: ubuntu-latest - permissions: - issues: write - steps: - - uses: actions/stale@v10.2.0 - with: - operations-per-run: 1000 - days-before-stale: ${{ env.DAYS_BEFORE_STALE }} - days-before-close: ${{ env.DAYS_BEFORE_CLOSE }} - stale-issue-label: "stale" - close-issue-message: | - [automated] Closing due to ${{ env.DAYS_BEFORE_STALE }}+ days of inactivity. - - Feel free to reopen if you still need this! - stale-issue-message: | - [automated] This issue has had no activity for ${{ env.DAYS_BEFORE_STALE }} days. - - It will be closed in ${{ env.DAYS_BEFORE_CLOSE }} days if there's no new activity. - remove-stale-when-updated: true - exempt-issue-labels: "pinned,security,feature-request,on-hold" - start-date: "2025-12-27" diff --git a/script/github/close-issues.ts b/script/github/close-issues.ts new file mode 100755 index 0000000000..d373b4ca15 --- /dev/null +++ b/script/github/close-issues.ts @@ -0,0 +1,92 @@ +#!/usr/bin/env bun + +const repo = "anomalyco/opencode" +const days = 60 +const msg = + "To stay organized issues are automatically closed after 90 days of no activity. If the issue is still relevant please open a new one." + +const token = process.env.GITHUB_TOKEN +if (!token) { + console.error("GITHUB_TOKEN environment variable is required") + process.exit(1) +} + +const cutoff = new Date(Date.now() - days * 24 * 60 * 60 * 1000) + +type Issue = { + number: number + updated_at: string +} + +const headers = { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + Accept: "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28", +} + +async function close(num: number) { + const base = `https://api.github.com/repos/${repo}/issues/${num}` + + const comment = await fetch(`${base}/comments`, { + method: "POST", + headers, + body: JSON.stringify({ body: msg }), + }) + if (!comment.ok) throw new Error(`Failed to comment #${num}: ${comment.status} ${comment.statusText}`) + + const patch = await fetch(base, { + method: "PATCH", + headers, + body: JSON.stringify({ state: "closed", state_reason: "not_planned" }), + }) + if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`) + + console.log(`Closed https://github.com/${repo}/issues/${num}`) +} + +async function main() { + let page = 1 + let closed = 0 + + while (true) { + const res = await fetch( + `https://api.github.com/repos/${repo}/issues?state=open&sort=updated&direction=asc&per_page=100&page=${page}`, + { headers }, + ) + if (!res.ok) throw new Error(res.statusText) + + const all = (await res.json()) as Issue[] + if (all.length === 0) break + + const stale: number[] = [] + for (const i of all) { + const updated = new Date(i.updated_at) + if (updated < cutoff) { + stale.push(i.number) + } else { + console.log(`\nFound fresh issue #${i.number}, stopping`) + if (stale.length > 0) { + await Promise.all(stale.map(close)) + closed += stale.length + } + console.log(`Closed ${closed} issues total`) + return + } + } + + if (stale.length > 0) { + await Promise.all(stale.map(close)) + closed += stale.length + } + + page++ + } + + console.log(`Closed ${closed} issues total`) +} + +main().catch((err) => { + console.error("Error:", err) + process.exit(1) +}) From 45c25739793f9154855e492106be2c7420c20f28 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 24 Mar 2026 23:51:46 -0400 Subject: [PATCH 03/36] Fix close-issues workflow permissions - Add contents: read permission for checkout - Use github.token instead of secrets.GITHUB_TOKEN --- .github/workflows/close-issues.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/close-issues.yml b/.github/workflows/close-issues.yml index 3ee97ee535..04b6ae7ac8 100644 --- a/.github/workflows/close-issues.yml +++ b/.github/workflows/close-issues.yml @@ -9,6 +9,7 @@ jobs: close: runs-on: ubuntu-latest permissions: + contents: read issues: write steps: - uses: actions/checkout@v4 @@ -19,5 +20,5 @@ jobs: - name: Close stale issues env: - GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + GITHUB_TOKEN: ${{ github.token }} run: bun script/github/close-issues.ts From be142b00bdca6f2cda069c7ddf315f0c96de84af Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 24 Mar 2026 23:54:27 -0400 Subject: [PATCH 04/36] Process issues sequentially to avoid rate limits --- script/github/close-issues.ts | 13 +++++++++---- 1 file changed, 9 insertions(+), 4 deletions(-) diff --git a/script/github/close-issues.ts b/script/github/close-issues.ts index d373b4ca15..f5470e2524 100755 --- a/script/github/close-issues.ts +++ b/script/github/close-issues.ts @@ -58,6 +58,7 @@ async function main() { const all = (await res.json()) as Issue[] if (all.length === 0) break + console.log(`Fetched page ${page} ${all.length} issues`) const stale: number[] = [] for (const i of all) { @@ -67,8 +68,10 @@ async function main() { } else { console.log(`\nFound fresh issue #${i.number}, stopping`) if (stale.length > 0) { - await Promise.all(stale.map(close)) - closed += stale.length + for (const num of stale) { + await close(num) + closed++ + } } console.log(`Closed ${closed} issues total`) return @@ -76,8 +79,10 @@ async function main() { } if (stale.length > 0) { - await Promise.all(stale.map(close)) - closed += stale.length + for (const num of stale) { + await close(num) + closed++ + } } page++ From 4f9667c4bb2fed7fdd87e7eceab3acfd248ccf9f Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Tue, 24 Mar 2026 23:55:10 -0400 Subject: [PATCH 05/36] Change issue close reason from not_planned to completed --- script/github/close-issues.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/script/github/close-issues.ts b/script/github/close-issues.ts index f5470e2524..7b38bf6758 100755 --- a/script/github/close-issues.ts +++ b/script/github/close-issues.ts @@ -38,7 +38,7 @@ async function close(num: number) { const patch = await fetch(base, { method: "PATCH", headers, - body: JSON.stringify({ state: "closed", state_reason: "not_planned" }), + body: JSON.stringify({ state: "closed", state_reason: "completed" }), }) if (!patch.ok) throw new Error(`Failed to close #${num}: ${patch.status} ${patch.statusText}`) From 0a80ef4278c252cb8dca72cae5d5c5748cec7e9a Mon Sep 17 00:00:00 2001 From: Dax Date: Wed, 25 Mar 2026 00:43:48 -0400 Subject: [PATCH 06/36] fix(opencode): avoid snapshotting files over 2MB (#19043) --- packages/opencode/src/snapshot/index.ts | 68 ++++++++++++++++--- .../opencode/test/snapshot/snapshot.test.ts | 19 +++++- 2 files changed, 77 insertions(+), 10 deletions(-) diff --git a/packages/opencode/src/snapshot/index.ts b/packages/opencode/src/snapshot/index.ts index 7068545d26..ec07173c83 100644 --- a/packages/opencode/src/snapshot/index.ts +++ b/packages/opencode/src/snapshot/index.ts @@ -34,6 +34,7 @@ export namespace Snapshot { const log = Log.create({ service: "snapshot" }) const prune = "7.days" + const limit = 2 * 1024 * 1024 const core = ["-c", "core.longpaths=true", "-c", "core.symlinks=true"] const cfg = ["-c", "core.autocrlf=false", ...core] const quote = [...cfg, "-c", "core.quotepath=false"] @@ -123,20 +124,69 @@ export namespace Snapshot { return file }) - const sync = Effect.fnUntraced(function* () { + const sync = Effect.fnUntraced(function* (list: string[] = []) { const file = yield* excludes() const target = path.join(state.gitdir, "info", "exclude") + const text = [ + file ? (yield* read(file)).trimEnd() : "", + ...list.map((item) => `/${item.replaceAll("\\", "/")}`), + ] + .filter(Boolean) + .join("\n") yield* fs.ensureDir(path.join(state.gitdir, "info")).pipe(Effect.orDie) - if (!file) { - yield* fs.writeFileString(target, "").pipe(Effect.orDie) - return - } - yield* fs.writeFileString(target, yield* read(file)).pipe(Effect.orDie) + yield* fs.writeFileString(target, text ? `${text}\n` : "").pipe(Effect.orDie) }) const add = Effect.fnUntraced(function* () { yield* sync() - yield* git([...cfg, ...args(["add", "."])], { cwd: state.directory }) + const [diff, other] = yield* Effect.all( + [ + git([...quote, ...args(["diff-files", "--name-only", "-z", "--", "."])], { + cwd: state.directory, + }), + git([...quote, ...args(["ls-files", "--others", "--exclude-standard", "-z", "--", "."])], { + cwd: state.directory, + }), + ], + { concurrency: 2 }, + ) + if (diff.code !== 0 || other.code !== 0) { + log.warn("failed to list snapshot files", { + diffCode: diff.code, + diffStderr: diff.stderr, + otherCode: other.code, + otherStderr: other.stderr, + }) + return + } + + const tracked = diff.text.split("\0").filter(Boolean) + const all = Array.from(new Set([...tracked, ...other.text.split("\0").filter(Boolean)])) + if (!all.length) return + + const large = (yield* Effect.all( + all.map((item) => + fs + .stat(path.join(state.directory, item)) + .pipe(Effect.catch(() => Effect.void)) + .pipe( + Effect.map((stat) => { + if (!stat || stat.type !== "File") return + const size = typeof stat.size === "bigint" ? Number(stat.size) : stat.size + return size > limit ? item : undefined + }), + ), + ), + { concurrency: 8 }, + )).filter((item): item is string => Boolean(item)) + yield* sync(large) + const result = yield* git([...cfg, ...args(["add", "--sparse", "."])], { cwd: state.directory }) + if (result.code !== 0) { + log.warn("failed to add snapshot files", { + exitCode: result.code, + stderr: result.stderr, + }) + } }) const cleanup = Effect.fnUntraced(function* () { @@ -177,7 +227,7 @@ export namespace Snapshot { const patch = Effect.fnUntraced(function* (hash: string) { yield* add() const result = yield* git( - [...quote, ...args(["diff", "--no-ext-diff", "--name-only", hash, "--", "."])], + [...quote, ...args(["diff", "--cached", "--no-ext-diff", "--name-only", hash, "--", "."])], { cwd: state.directory, }, @@ -245,7 +295,7 @@ export namespace Snapshot { const diff = Effect.fnUntraced(function* (hash: string) { yield* add() - const result = yield* git([...quote, ...args(["diff", "--no-ext-diff", hash, "--", "."])], { + const result = yield* git([...quote, ...args(["diff", "--cached", "--no-ext-diff", hash, "--", "."])], { cwd: state.worktree, }) if (result.code !== 0) { diff --git a/packages/opencode/test/snapshot/snapshot.test.ts b/packages/opencode/test/snapshot/snapshot.test.ts index bf54feb472..f42cec4fc7 100644 --- a/packages/opencode/test/snapshot/snapshot.test.ts +++ b/packages/opencode/test/snapshot/snapshot.test.ts @@ -181,7 +181,7 @@ test("symlink handling", async () => { }) }) -test("large file handling", async () => { +test("file under size limit handling", async () => { await using tmp = await bootstrap() await Instance.provide({ directory: tmp.path, @@ -196,6 +196,23 @@ test("large file handling", async () => { }) }) +test("large added files are skipped", async () => { + await using tmp = await bootstrap() + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const before = await Snapshot.track() + expect(before).toBeTruthy() + + await Filesystem.write(`${tmp.path}/huge.txt`, new Uint8Array(2 * 1024 * 1024 + 1)) + + expect((await Snapshot.patch(before!)).files).toEqual([]) + expect(await Snapshot.diff(before!)).toBe("") + expect(await Snapshot.track()).toBe(before) + }, + }) +}) + test("nested directory revert", async () => { await using tmp = await bootstrap() await Instance.provide({ From 700f57112ab6d2ced3add2021841e22b16f3b0cb Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 25 Mar 2026 14:45:37 +1000 Subject: [PATCH 07/36] fix: provide merge context to beta conflict resolver (#19055) --- script/beta.ts | 27 ++++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 61f9cf8620..2c3ed88b0c 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -50,11 +50,32 @@ async function cleanup() { } catch {} } -async function fix(pr: PR, files: string[]) { +async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) { console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) + + const done = + prs + .filter((x) => applied.includes(x.number)) + .map((x) => `- #${x.number}: ${x.title}`) + .join("\n") || "(none yet)" + + const next = + prs + .slice(idx + 1) + .map((x) => `- #${x.number}: ${x.title}`) + .join("\n") || "(none)" + const prompt = [ `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`, + `PR #${pr.number}: ${pr.title}`, `Only touch these files: ${files.join(", ")}.`, + `Merged PRs on HEAD:\n${done}`, + `Pending PRs after this one (context only):\n${next}`, + "IMPORTANT: The conflict resolution must be consistent with already-merged PRs.", + "Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.", + "Prefer already-merged PRs over the base branch when resolving stacked conflicts.", + "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.", + "If a PR already changed an import, keep that change.", "Keep the merge in progress, do not abort the merge, and do not create a commit.", "When done, leave the working tree with no unmerged files.", ].join("\n") @@ -99,7 +120,7 @@ async function main() { const applied: number[] = [] const failed: FailedPR[] = [] - for (const pr of prs) { + for (const [idx, pr] of prs.entries()) { console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") @@ -119,7 +140,7 @@ async function main() { const files = await conflicts() if (files.length > 0) { console.log(" Failed to merge (conflicts)") - if (!(await fix(pr, files))) { + if (!(await fix(pr, files, prs, applied, idx))) { await cleanup() failed.push({ number: pr.number, title: pr.title, reason: "Merge conflicts" }) await commentOnPR(pr.number, "Merge conflicts with dev branch") From 71693cc24b54fcff8407318f1e076fb20a13ba64 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 25 Mar 2026 00:31:29 -0500 Subject: [PATCH 08/36] tweak: only spawn lsp servers for files in current instance (or cwd if instance is global) (#19058) --- packages/opencode/src/lsp/index.ts | 6 +++ packages/opencode/test/lsp/index.test.ts | 55 ++++++++++++++++++++++++ 2 files changed, 61 insertions(+) create mode 100644 packages/opencode/test/lsp/index.test.ts diff --git a/packages/opencode/src/lsp/index.ts b/packages/opencode/src/lsp/index.ts index 2eb1ad93e9..41a650afd7 100644 --- a/packages/opencode/src/lsp/index.ts +++ b/packages/opencode/src/lsp/index.ts @@ -177,6 +177,12 @@ export namespace LSP { async function getClients(file: string) { const s = await state() + + // Only spawn LSP clients for files within the instance directory + if (!Instance.containsPath(file)) { + return [] + } + const extension = path.parse(file).ext || file const result: LSPClient.Info[] = [] diff --git a/packages/opencode/test/lsp/index.test.ts b/packages/opencode/test/lsp/index.test.ts new file mode 100644 index 0000000000..7e514e39b1 --- /dev/null +++ b/packages/opencode/test/lsp/index.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, spyOn, test } from "bun:test" +import path from "path" +import * as Lsp from "../../src/lsp/index" +import { LSPServer } from "../../src/lsp/server" +import { Instance } from "../../src/project/instance" +import { tmpdir } from "../fixture/fixture" + +describe("lsp.spawn", () => { + test("does not spawn builtin LSP for files outside instance", async () => { + await using tmp = await tmpdir() + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Lsp.LSP.touchFile(path.join(tmp.path, "..", "outside.ts")) + await Lsp.LSP.hover({ + file: path.join(tmp.path, "..", "hover.ts"), + line: 0, + character: 0, + }) + }, + }) + + expect(spy).toHaveBeenCalledTimes(0) + } finally { + spy.mockRestore() + await Instance.disposeAll() + } + }) + + test("would spawn builtin LSP for files inside instance", async () => { + await using tmp = await tmpdir() + const spy = spyOn(LSPServer.Typescript, "spawn").mockResolvedValue(undefined) + + try { + await Instance.provide({ + directory: tmp.path, + fn: async () => { + await Lsp.LSP.hover({ + file: path.join(tmp.path, "src", "inside.ts"), + line: 0, + character: 0, + }) + }, + }) + + expect(spy).toHaveBeenCalledTimes(1) + } finally { + spy.mockRestore() + await Instance.disposeAll() + } + }) +}) From 9a64bdb5397dc7c75eeb7053f0024e2c89636a2c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 25 Mar 2026 15:45:30 +1000 Subject: [PATCH 09/36] fix: beta resolver typecheck + build smoke check (#19060) --- script/beta.ts | 108 +++++++++++++++++++++++++++++++++++++++++++------ 1 file changed, 95 insertions(+), 13 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index 2c3ed88b0c..aef1fdf923 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -2,6 +2,8 @@ import { $ } from "bun" +const model = "opencode/gpt-5.3-codex" + interface PR { number: number title: string @@ -50,20 +52,35 @@ async function cleanup() { } catch {} } +function lines(prs: PR[]) { + return prs.map((x) => `- #${x.number}: ${x.title}`).join("\n") || "(none)" +} + +async function typecheck() { + try { + await $`bun typecheck`.cwd("packages/opencode") + return true + } catch (err) { + console.log(`Typecheck failed: ${err}`) + return false + } +} + +async function build() { + try { + await $`./script/build.ts --single`.cwd("packages/opencode") + return true + } catch (err) { + console.log(`Build failed: ${err}`) + return false + } +} + async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) { console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) - const done = - prs - .filter((x) => applied.includes(x.number)) - .map((x) => `- #${x.number}: ${x.title}`) - .join("\n") || "(none yet)" - - const next = - prs - .slice(idx + 1) - .map((x) => `- #${x.number}: ${x.title}`) - .join("\n") || "(none)" + const done = lines(prs.filter((x) => applied.includes(x.number))) + const next = lines(prs.slice(idx + 1)) const prompt = [ `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`, @@ -76,12 +93,14 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n "Prefer already-merged PRs over the base branch when resolving stacked conflicts.", "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.", "If a PR already changed an import, keep that change.", + "After resolving the conflicts, run `bun typecheck` in `packages/opencode`.", + "Fix any merge-caused typecheck errors before finishing.", "Keep the merge in progress, do not abort the merge, and do not create a commit.", - "When done, leave the working tree with no unmerged files.", + "When done, leave the working tree with no unmerged files and a passing typecheck.", ].join("\n") try { - await $`opencode run -m opencode/gpt-5.3-codex ${prompt}` + await $`opencode run -m ${model} ${prompt}` } catch (err) { console.log(` opencode failed: ${err}`) return false @@ -93,10 +112,66 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n return false } + if (!(await typecheck())) return false + console.log(" Conflicts resolved with opencode") return true } +async function smoke(prs: PR[], applied: number[]) { + console.log("\nRunning final smoke check with opencode...") + + const done = lines(prs.filter((x) => applied.includes(x.number))) + const prompt = [ + "The beta merge batch is complete.", + `Merged PRs on HEAD:\n${done}`, + "Run `bun typecheck` in `packages/opencode`.", + "Run `./script/build.ts --single` in `packages/opencode`.", + "Fix any merge-caused issues until both commands pass.", + "Do not create a commit.", + ].join("\n") + + try { + await $`opencode run -m ${model} ${prompt}` + } catch (err) { + console.log(`Smoke fix failed: ${err}`) + return false + } + + if (!(await typecheck())) { + return false + } + + if (!(await build())) { + return false + } + + const out = await $`git status --porcelain`.text() + if (!out.trim()) { + console.log("Smoke check passed") + return true + } + + try { + await $`git add -A` + await $`git commit -m "Fix beta integration"` + } catch (err) { + console.log(`Failed to commit smoke fixes: ${err}`) + return false + } + + if (!(await typecheck())) { + return false + } + + if (!(await build())) { + return false + } + + console.log("Smoke check passed") + return true +} + async function main() { console.log("Fetching open PRs with beta label...") @@ -195,6 +270,13 @@ async function main() { throw new Error(`${failed.length} PR(s) failed to merge`) } + if (applied.length > 0) { + const ok = await smoke(prs, applied) + if (!ok) { + throw new Error("Final smoke check failed") + } + } + console.log("\nChecking if beta branch has changes...") await $`git fetch origin beta` From aa11fa865d5a224bb1fea55fe6ea566c05c8befa Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 25 Mar 2026 16:14:38 +1000 Subject: [PATCH 10/36] fix: unblock beta conflict recovery (#19068) --- script/beta.ts | 28 ++++++++++++++++++++++++++-- 1 file changed, 26 insertions(+), 2 deletions(-) diff --git a/script/beta.ts b/script/beta.ts index aef1fdf923..6f4ff4ebf9 100755 --- a/script/beta.ts +++ b/script/beta.ts @@ -1,6 +1,7 @@ #!/usr/bin/env bun import { $ } from "bun" +import fs from "fs/promises" const model = "opencode/gpt-5.3-codex" @@ -57,6 +58,8 @@ function lines(prs: PR[]) { } async function typecheck() { + console.log(" Running typecheck...") + try { await $`bun typecheck`.cwd("packages/opencode") return true @@ -67,6 +70,8 @@ async function typecheck() { } async function build() { + console.log(" Running final build smoke check...") + try { await $`./script/build.ts --single`.cwd("packages/opencode") return true @@ -76,6 +81,20 @@ async function build() { } } +async function install() { + console.log(" Regenerating bun.lock...") + + try { + await fs.rm("bun.lock", { force: true }) + await $`bun install` + await $`git add bun.lock` + return true + } catch (err) { + console.log(`Install failed: ${err}`) + return false + } +} + async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: number) { console.log(` Trying to auto-resolve ${files.length} conflict(s) with opencode...`) @@ -85,15 +104,18 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n const prompt = [ `Resolve the current git merge conflicts while merging PR #${pr.number} into the beta branch.`, `PR #${pr.number}: ${pr.title}`, - `Only touch these files: ${files.join(", ")}.`, + `Start with these conflicted files: ${files.join(", ")}.`, `Merged PRs on HEAD:\n${done}`, `Pending PRs after this one (context only):\n${next}`, "IMPORTANT: The conflict resolution must be consistent with already-merged PRs.", "Pending PRs are context only; do not introduce their changes unless they are already present on HEAD.", "Prefer already-merged PRs over the base branch when resolving stacked conflicts.", + "If bun.lock is conflicted, do not hand-merge it. Delete bun.lock and run bun install after the code conflicts are resolved.", "If a PR already deleted a file/directory, do not re-add it, instead apply changes in the new semantic location.", "If a PR already changed an import, keep that change.", "After resolving the conflicts, run `bun typecheck` in `packages/opencode`.", + "If typecheck fails, you may also update any files reported by typecheck.", + "Keep any non-conflict edits narrowly scoped to restoring a valid merged state for the current PR batch.", "Fix any merge-caused typecheck errors before finishing.", "Keep the merge in progress, do not abort the merge, and do not create a commit.", "When done, leave the working tree with no unmerged files and a passing typecheck.", @@ -112,6 +134,8 @@ async function fix(pr: PR, files: string[], prs: PR[], applied: number[], idx: n return false } + if (files.includes("bun.lock") && !(await install())) return false + if (!(await typecheck())) return false console.log(" Conflicts resolved with opencode") @@ -196,7 +220,7 @@ async function main() { const failed: FailedPR[] = [] for (const [idx, pr] of prs.entries()) { - console.log(`\nProcessing PR #${pr.number}: ${pr.title}`) + console.log(`\nProcessing PR ${idx + 1}/${prs.length} #${pr.number}: ${pr.title}`) console.log(" Fetching PR head...") try { From 5d9e780029171df628ce56c0ded3be32bae48d49 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 25 Mar 2026 14:25:51 +0800 Subject: [PATCH 11/36] electron: add createDirectory to open directory picker (#19071) --- packages/desktop-electron/src/main/ipc.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/main/ipc.ts b/packages/desktop-electron/src/main/ipc.ts index 543f857a5e..d2cfc25241 100644 --- a/packages/desktop-electron/src/main/ipc.ts +++ b/packages/desktop-electron/src/main/ipc.ts @@ -88,7 +88,7 @@ export function registerIpcHandlers(deps: Deps) { "open-directory-picker", async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { const result = await dialog.showOpenDialog({ - properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : [])], + properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"], title: opts?.title ?? "Choose a folder", defaultPath: opts?.defaultPath, }) From 9717383823e22f0f081429b55a7c117d4d9aa9c4 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 25 Mar 2026 15:57:27 +0800 Subject: [PATCH 12/36] electron: remove file extension from electron-store wrapper (#19082) --- packages/desktop-electron/src/main/store.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/desktop-electron/src/main/store.ts b/packages/desktop-electron/src/main/store.ts index fa1c5682e2..cf2d25b110 100644 --- a/packages/desktop-electron/src/main/store.ts +++ b/packages/desktop-electron/src/main/store.ts @@ -7,7 +7,7 @@ const cache = new Map() export function getStore(name = SETTINGS_STORE) { const cached = cache.get(name) if (cached) return cached - const next = new Store({ name }) + const next = new Store({ name, fileExtension: "" }) cache.set(name, next) return next } From 3ea72aec21e6266a69cda23b5705c6e8e7e19186 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Wed, 25 Mar 2026 17:32:49 +0800 Subject: [PATCH 13/36] app: pre-warm project globalSync state when navigate project via keybind (#19088) --- packages/app/src/pages/layout.tsx | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 1300f88a80..01e151605d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -967,6 +967,8 @@ export default function Layout(props: ParentProps) { : projects[(index + offset + projects.length) % projects.length] if (!target) return + // warm up child store to prevent flicker + globalSync.child(target.worktree) openProject(target.worktree) } From d1c49ba210315900b7d21a7d4926b739d8021c6e Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Wed, 25 Mar 2026 15:54:55 +0530 Subject: [PATCH 14/36] fix(app): move message navigation off cmd+arrow (#18728) --- .../app/src/context/command-keybind.test.ts | 19 +++++++++++++++++++ .../pages/session/use-session-commands.tsx | 4 ++-- 2 files changed, 21 insertions(+), 2 deletions(-) diff --git a/packages/app/src/context/command-keybind.test.ts b/packages/app/src/context/command-keybind.test.ts index d804195c40..c8e2dbb5d0 100644 --- a/packages/app/src/context/command-keybind.test.ts +++ b/packages/app/src/context/command-keybind.test.ts @@ -32,6 +32,25 @@ describe("command keybind helpers", () => { expect(matchKeybind(keybinds, new KeyboardEvent("keydown", { key: ",", ctrlKey: true, altKey: true }))).toBe(false) }) + test("matchKeybind supports bracket keys", () => { + const keybinds = parseKeybind("mod+alt+[, mod+alt+]") + const prev = keybinds[0] + const next = keybinds[1] + + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "[", ctrlKey: prev?.ctrl, metaKey: prev?.meta, altKey: true }), + ), + ).toBe(true) + expect( + matchKeybind( + keybinds, + new KeyboardEvent("keydown", { key: "]", ctrlKey: next?.ctrl, metaKey: next?.meta, altKey: true }), + ), + ).toBe(true) + }) + test("formatKeybind returns human readable output", () => { const display = formatKeybind("ctrl+alt+arrowup") diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index f17e3f7a1f..7394765ae6 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -333,7 +333,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "message.previous", title: language.t("command.message.previous"), description: language.t("command.message.previous.description"), - keybind: "mod+arrowup", + keybind: "mod+alt+[", disabled: !params.id, onSelect: () => navigateMessageByOffset(-1), }), @@ -341,7 +341,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { id: "message.next", title: language.t("command.message.next"), description: language.t("command.message.next.description"), - keybind: "mod+arrowdown", + keybind: "mod+alt+]", disabled: !params.id, onSelect: () => navigateMessageByOffset(1), }), From 0dbfefa08088270a000496cfe94e11b5bf3ce821 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:49:02 -0500 Subject: [PATCH 15/36] Reapply "fix(app): startup efficiency (#18854)" This reverts commit a379eb38673aad097e1f178307865ec40a5ac3ea. --- packages/app/src/app.tsx | 8 +- .../components/dialog-connect-provider.tsx | 50 ++- .../app/src/components/settings-general.tsx | 50 ++- .../app/src/components/status-popover.tsx | 23 +- packages/app/src/components/terminal.tsx | 5 +- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/context/global-sync.tsx | 58 ++-- .../app/src/context/global-sync/bootstrap.ts | 189 +++++------ packages/app/src/context/language.tsx | 144 ++++----- packages/app/src/context/notification.tsx | 6 +- packages/app/src/context/settings.tsx | 8 + packages/app/src/context/terminal-title.ts | 51 +-- packages/app/src/entry.tsx | 11 +- packages/app/src/hooks/use-providers.ts | 2 +- packages/app/src/index.ts | 1 + packages/app/src/pages/directory-layout.tsx | 70 ++--- packages/app/src/pages/layout.tsx | 62 ++-- packages/app/src/utils/server-health.ts | 24 +- packages/app/src/utils/sound.ts | 177 +++++------ .../desktop-electron/src/renderer/index.tsx | 20 +- packages/desktop/src/index.tsx | 19 +- packages/ui/package.json | 1 + .../icons/provider/alibaba-coding-plan-cn.svg | 3 + .../icons/provider/alibaba-coding-plan.svg | 3 + .../ui/src/assets/icons/provider/clarifai.svg | 24 ++ .../src/assets/icons/provider/dinference.svg | 1 + .../ui/src/assets/icons/provider/drun.svg | 8 + .../icons/provider/perplexity-agent.svg | 3 + .../icons/provider/tencent-coding-plan.svg | 5 + .../ui/src/assets/icons/provider/zenmux.svg | 5 +- packages/ui/src/components/font.tsx | 119 +------ packages/ui/src/font-loader.ts | 133 ++++++++ packages/ui/src/theme/context.tsx | 294 +++++++++++++----- 33 files changed, 943 insertions(+), 636 deletions(-) create mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg create mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg create mode 100644 packages/ui/src/assets/icons/provider/clarifai.svg create mode 100644 packages/ui/src/assets/icons/provider/dinference.svg create mode 100644 packages/ui/src/assets/icons/provider/drun.svg create mode 100644 packages/ui/src/assets/icons/provider/perplexity-agent.svg create mode 100644 packages/ui/src/assets/icons/provider/tencent-coding-plan.svg create mode 100644 packages/ui/src/font-loader.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5247c951d3..0eb5b4e9e0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" -import { ThemeProvider } from "@opencode-ai/ui/theme" +import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file" import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" import { HighlightsProvider } from "@/context/highlights" -import { LanguageProvider, useLanguage } from "@/context/language" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps) { +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) { void window.api?.setTitlebar?.({ mode }) }} > - + }> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 734958dd58..e7eaa1fb29 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" @@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) - const methods = createMemo( - () => - globalSync.data.provider_auth[props.provider] ?? [ - { - type: "api", - label: language.t("provider.connect.method.apiKey"), - }, - ], + const fallback = createMemo(() => [ + { + type: "api" as const, + label: language.t("provider.connect.method.apiKey"), + }, + ]) + const [auth] = createResource( + () => props.provider, + async () => { + const cached = globalSync.data.provider_auth[props.provider] + if (cached) return cached + const res = await globalSDK.client.provider.auth() + if (!alive.value) return fallback() + globalSync.set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) + const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) { index: 0, }) - const prompts = createMemo(() => method()?.prompts ?? []) + const prompts = createMemo>(() => { + const value = method() + if (value?.type !== "oauth") return [] + return value.prompts ?? [] + }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] @@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) { listRef?.onKeyDown(e) } - onMount(() => { + let auto = false + createEffect(() => { + if (auto) return + if (loading()) return if (methods().length === 1) { + auto = true selectMethod(0) } }) @@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
+ +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca..f4b8198e7e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font } // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { + demoSoundState.run += 1 if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} + onHighlight={(option) => { + void loadFont().then((x) => x.ensureMonoFont(option?.value)) + }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 464522443f..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -54,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -162,6 +165,12 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) + let dialogRun = 0 + let dialogDead = false + onCleanup(() => { + dialogDead = true + dialogRun += 1 + }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -169,7 +178,7 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -300,7 +309,13 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { withAlpha } from "@opencode-ai/ui/theme/color" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" +import type { HexColor } from "@opencode-ai/ui/theme/types" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2d1e501353..cbd08e99f5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { - createContext, - getOwner, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + global: { + config: globalStore.config, + project: globalStore.project, + provider: globalStore.provider, + }, sdk, store: child[0], setStore: child[1], @@ -278,15 +275,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: queue.refresh, + refresh: () => { + if (recent) return + queue.refresh() + }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { + if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { - url: globalSDK.url, - }), - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) + bootingRoot = true + try { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + bootedAt = Date.now() + } finally { + bootingRoot = false + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..c795ab471c 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -33,27 +33,11 @@ type GlobalStore = { export async function bootstrapGlobal(input: { globalSDK: OpencodeClient - connectErrorTitle: string - connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const health = await input.globalSDK.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: input.connectErrorTitle, - description: input.connectErrorDescription, - }) - input.setGlobalStore("ready", true) - return - } - const tasks = [ retry(() => input.globalSDK.path.get().then((x) => { @@ -80,11 +64,6 @@ export async function bootstrapGlobal(input: { input.setGlobalStore("provider", normalizeProviderList(x.data!)) }), ), - retry(() => - input.globalSDK.provider.auth().then((x) => { - input.setGlobalStore("provider_auth", x.data ?? {}) - }), - ), ] const results = await Promise.allSettled(tasks) @@ -111,6 +90,10 @@ function groupBySession(input: T[]) }, {}) } +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -119,88 +102,112 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string + global: { + config: Config + project: Project[] + provider: ProviderListResponse + } }) { - if (input.store.status !== "complete") input.setStore("status", "loading") + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + if (seededProject) input.setStore("project", seededProject) + if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { + input.setStore("provider", input.global.provider) + } + if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { + input.setStore("config", input.global.config) + } + if (loading) input.setStore("status", "partial") - const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), - provider: () => + const results = await Promise.allSettled([ + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + retry(() => input.sdk.provider.list().then((x) => { input.setStore("provider", normalizeProviderList(x.data!)) }), - agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), - config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), - } + ), + retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + input.loadSessions(input.directory), + retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ]) - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + const errors = results + .filter((item): item is PromiseRejectedResult => item.status === "rejected") + .map((item) => item.reason) + if (errors.length > 0) { + console.error("Failed to bootstrap instance", errors[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(errors[0], input.translate), }) - input.setStore("status", "partial") return } - if (input.store.status !== "complete") input.setStore("status", "partial") - - Promise.all([ - input.sdk.path.get().then((x) => input.setStore("path", x.data!)), - input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), - input.loadSessions(input.directory), - input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), - input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - input.setStore("status", "complete") - }) + if (loading) input.setStore("status", "complete") } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..51dc09cd7d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,42 +1,10 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" -import { dict as ko } from "@/i18n/ko" -import { dict as de } from "@/i18n/de" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as da } from "@/i18n/da" -import { dict as ja } from "@/i18n/ja" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as ar } from "@/i18n/ar" -import { dict as no } from "@/i18n/no" -import { dict as br } from "@/i18n/br" -import { dict as th } from "@/i18n/th" -import { dict as bs } from "@/i18n/bs" -import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" -import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" -import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" -import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" -import { dict as uiDe } from "@opencode-ai/ui/i18n/de" -import { dict as uiEs } from "@opencode-ai/ui/i18n/es" -import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" -import { dict as uiDa } from "@opencode-ai/ui/i18n/da" -import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" -import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" -import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" -import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" -import { dict as uiNo } from "@opencode-ai/ui/i18n/no" -import { dict as uiBr } from "@opencode-ai/ui/i18n/br" -import { dict as uiTh } from "@opencode-ai/ui/i18n/th" -import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" -import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" export type Locale = | "en" @@ -59,6 +27,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -125,24 +94,43 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const DICT: Record = { - en: base, - zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, - zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, - ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, - de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, - es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, - fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, - da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, - ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, - pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, - ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, - ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, - no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, - br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, - th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, - bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, - tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] -type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" -const PARITY_CHECK: Record, Record> = { - zh, - zht, - ko, - de, - es, - fr, - da, - ja, - pl, - ru, - ar, - no, - br, - th, - bs, - tr, -} -void PARITY_CHECK - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -203,27 +170,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +if (warm !== "en") void loadDict(warm) + export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 04bc2fdaaa..281a1ef33d 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..247d36dd36 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,6 +118,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return + void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) }) diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -import { dict as ar } from "@/i18n/ar" -import { dict as br } from "@/i18n/br" -import { dict as bs } from "@/i18n/bs" -import { dict as da } from "@/i18n/da" -import { dict as de } from "@/i18n/de" -import { dict as en } from "@/i18n/en" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as ja } from "@/i18n/ja" -import { dict as ko } from "@/i18n/ko" -import { dict as no } from "@/i18n/no" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as th } from "@/i18n/th" -import { dict as tr } from "@/i18n/tr" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" +const template = "Terminal {{number}}" -const numbered = Array.from( - new Set([ - en["terminal.title.numbered"], - ar["terminal.title.numbered"], - br["terminal.title.numbered"], - bs["terminal.title.numbered"], - da["terminal.title.numbered"], - de["terminal.title.numbered"], - es["terminal.title.numbered"], - fr["terminal.title.numbered"], - ja["terminal.title.numbered"], - ko["terminal.title.numbered"], - no["terminal.title.numbered"], - pl["terminal.title.numbered"], - ru["terminal.title.numbered"], - th["terminal.title.numbered"], - tr["terminal.title.numbered"], - zh["terminal.title.numbered"], - zht["terminal.title.numbered"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75..da22c55523 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } +const localUrl = () => + `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + +const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) + const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + if (location.hostname.includes("opencode.ai")) return localUrl() + if (import.meta.env.DEV) return localUrl() + if (isLocalHost()) return localUrl() return location.origin } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a25f8b4b25..a8f2360bbf 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 53063f48f8..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" +export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + return ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 01e151605d..b5a96110f6 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined + let dialogRun = 0 + let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) - const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -1154,10 +1152,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1208,15 +1206,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1443,7 +1453,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1464,10 +1480,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 45a323c7be..a13fd34ef7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -14,6 +14,15 @@ interface CheckServerHealthOptions { const defaultTimeoutMs = 3000 const defaultRetryCount = 2 const defaultRetryDelayMs = 100 +const cacheMs = 750 +const healthCache = new Map< + string, + { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } +>() + +function cacheKey(server: ServerConnection.HttpBase) { + return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` +} function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout @@ -87,5 +96,18 @@ export function useCheckServerHealth() { const platform = usePlatform() const fetcher = platform.fetch ?? globalThis.fetch - return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) + return (http: ServerConnection.HttpBase) => { + const key = cacheKey(http) + const hit = healthCache.get(key) + const now = Date.now() + if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise + const promise = checkServerHealth(http, fetcher).finally(() => { + const next = healthCache.get(key) + if (!next || next.promise !== promise) return + next.done = true + next.at = Date.now() + }) + healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) + return promise + } } diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 6dea812ec8..78e5a0c565 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -1,106 +1,89 @@ -import alert01 from "@opencode-ai/ui/audio/alert-01.aac" -import alert02 from "@opencode-ai/ui/audio/alert-02.aac" -import alert03 from "@opencode-ai/ui/audio/alert-03.aac" -import alert04 from "@opencode-ai/ui/audio/alert-04.aac" -import alert05 from "@opencode-ai/ui/audio/alert-05.aac" -import alert06 from "@opencode-ai/ui/audio/alert-06.aac" -import alert07 from "@opencode-ai/ui/audio/alert-07.aac" -import alert08 from "@opencode-ai/ui/audio/alert-08.aac" -import alert09 from "@opencode-ai/ui/audio/alert-09.aac" -import alert10 from "@opencode-ai/ui/audio/alert-10.aac" -import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" -import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" -import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" -import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" -import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" -import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" -import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" -import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" -import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" -import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" -import nope01 from "@opencode-ai/ui/audio/nope-01.aac" -import nope02 from "@opencode-ai/ui/audio/nope-02.aac" -import nope03 from "@opencode-ai/ui/audio/nope-03.aac" -import nope04 from "@opencode-ai/ui/audio/nope-04.aac" -import nope05 from "@opencode-ai/ui/audio/nope-05.aac" -import nope06 from "@opencode-ai/ui/audio/nope-06.aac" -import nope07 from "@opencode-ai/ui/audio/nope-07.aac" -import nope08 from "@opencode-ai/ui/audio/nope-08.aac" -import nope09 from "@opencode-ai/ui/audio/nope-09.aac" -import nope10 from "@opencode-ai/ui/audio/nope-10.aac" -import nope11 from "@opencode-ai/ui/audio/nope-11.aac" -import nope12 from "@opencode-ai/ui/audio/nope-12.aac" -import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" -import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" -import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" -import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" -import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" -import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" -import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" -import yup01 from "@opencode-ai/ui/audio/yup-01.aac" -import yup02 from "@opencode-ai/ui/audio/yup-02.aac" -import yup03 from "@opencode-ai/ui/audio/yup-03.aac" -import yup04 from "@opencode-ai/ui/audio/yup-04.aac" -import yup05 from "@opencode-ai/ui/audio/yup-05.aac" -import yup06 from "@opencode-ai/ui/audio/yup-06.aac" +let files: Record Promise> | undefined +let loads: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< + string, + () => Promise + > + return files +} export const SOUND_OPTIONS = [ - { id: "alert-01", label: "sound.option.alert01", src: alert01 }, - { id: "alert-02", label: "sound.option.alert02", src: alert02 }, - { id: "alert-03", label: "sound.option.alert03", src: alert03 }, - { id: "alert-04", label: "sound.option.alert04", src: alert04 }, - { id: "alert-05", label: "sound.option.alert05", src: alert05 }, - { id: "alert-06", label: "sound.option.alert06", src: alert06 }, - { id: "alert-07", label: "sound.option.alert07", src: alert07 }, - { id: "alert-08", label: "sound.option.alert08", src: alert08 }, - { id: "alert-09", label: "sound.option.alert09", src: alert09 }, - { id: "alert-10", label: "sound.option.alert10", src: alert10 }, - { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, - { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, - { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, - { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, - { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, - { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, - { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, - { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, - { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, - { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, - { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, - { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, - { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, - { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, - { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, - { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, - { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, - { id: "nope-01", label: "sound.option.nope01", src: nope01 }, - { id: "nope-02", label: "sound.option.nope02", src: nope02 }, - { id: "nope-03", label: "sound.option.nope03", src: nope03 }, - { id: "nope-04", label: "sound.option.nope04", src: nope04 }, - { id: "nope-05", label: "sound.option.nope05", src: nope05 }, - { id: "nope-06", label: "sound.option.nope06", src: nope06 }, - { id: "nope-07", label: "sound.option.nope07", src: nope07 }, - { id: "nope-08", label: "sound.option.nope08", src: nope08 }, - { id: "nope-09", label: "sound.option.nope09", src: nope09 }, - { id: "nope-10", label: "sound.option.nope10", src: nope10 }, - { id: "nope-11", label: "sound.option.nope11", src: nope11 }, - { id: "nope-12", label: "sound.option.nope12", src: nope12 }, - { id: "yup-01", label: "sound.option.yup01", src: yup01 }, - { id: "yup-02", label: "sound.option.yup02", src: yup02 }, - { id: "yup-03", label: "sound.option.yup03", src: yup03 }, - { id: "yup-04", label: "sound.option.yup04", src: yup04 }, - { id: "yup-05", label: "sound.option.yup05", src: yup05 }, - { id: "yup-06", label: "sound.option.yup06", src: yup06 }, + { id: "alert-01", label: "sound.option.alert01" }, + { id: "alert-02", label: "sound.option.alert02" }, + { id: "alert-03", label: "sound.option.alert03" }, + { id: "alert-04", label: "sound.option.alert04" }, + { id: "alert-05", label: "sound.option.alert05" }, + { id: "alert-06", label: "sound.option.alert06" }, + { id: "alert-07", label: "sound.option.alert07" }, + { id: "alert-08", label: "sound.option.alert08" }, + { id: "alert-09", label: "sound.option.alert09" }, + { id: "alert-10", label: "sound.option.alert10" }, + { id: "bip-bop-01", label: "sound.option.bipbop01" }, + { id: "bip-bop-02", label: "sound.option.bipbop02" }, + { id: "bip-bop-03", label: "sound.option.bipbop03" }, + { id: "bip-bop-04", label: "sound.option.bipbop04" }, + { id: "bip-bop-05", label: "sound.option.bipbop05" }, + { id: "bip-bop-06", label: "sound.option.bipbop06" }, + { id: "bip-bop-07", label: "sound.option.bipbop07" }, + { id: "bip-bop-08", label: "sound.option.bipbop08" }, + { id: "bip-bop-09", label: "sound.option.bipbop09" }, + { id: "bip-bop-10", label: "sound.option.bipbop10" }, + { id: "staplebops-01", label: "sound.option.staplebops01" }, + { id: "staplebops-02", label: "sound.option.staplebops02" }, + { id: "staplebops-03", label: "sound.option.staplebops03" }, + { id: "staplebops-04", label: "sound.option.staplebops04" }, + { id: "staplebops-05", label: "sound.option.staplebops05" }, + { id: "staplebops-06", label: "sound.option.staplebops06" }, + { id: "staplebops-07", label: "sound.option.staplebops07" }, + { id: "nope-01", label: "sound.option.nope01" }, + { id: "nope-02", label: "sound.option.nope02" }, + { id: "nope-03", label: "sound.option.nope03" }, + { id: "nope-04", label: "sound.option.nope04" }, + { id: "nope-05", label: "sound.option.nope05" }, + { id: "nope-06", label: "sound.option.nope06" }, + { id: "nope-07", label: "sound.option.nope07" }, + { id: "nope-08", label: "sound.option.nope08" }, + { id: "nope-09", label: "sound.option.nope09" }, + { id: "nope-10", label: "sound.option.nope10" }, + { id: "nope-11", label: "sound.option.nope11" }, + { id: "nope-12", label: "sound.option.nope12" }, + { id: "yup-01", label: "sound.option.yup01" }, + { id: "yup-02", label: "sound.option.yup02" }, + { id: "yup-03", label: "sound.option.yup03" }, + { id: "yup-04", label: "sound.option.yup04" }, + { id: "yup-05", label: "sound.option.yup05" }, + { id: "yup-06", label: "sound.option.yup06" }, ] as const export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundID = SoundOption["id"] -const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record +function getLoads() { + if (loads) return loads + loads = Object.fromEntries( + Object.entries(getFiles()).flatMap(([path, load]) => { + const file = path.split("/").at(-1) + if (!file) return [] + return [[file.replace(/\.aac$/, ""), load] as const] + }), + ) as Record Promise> + return loads +} + +const cache = new Map>() export function soundSrc(id: string | undefined) { - if (!id) return - if (!(id in soundById)) return - return soundById[id as SoundID] + const loads = getLoads() + if (!id || !(id in loads)) return Promise.resolve(undefined) + const key = id as SoundID + const hit = cache.get(key) + if (hit) return hit + const next = loads[key]().catch(() => undefined) + cache.set(key, next) + return next } export function playSound(src: string | undefined) { @@ -108,10 +91,12 @@ export function playSound(src: string | undefined) { if (!src) return const audio = new Audio(src) audio.play().catch(() => undefined) - - // Return a cleanup function to pause the sound. return () => { audio.pause() audio.currentTime = 0 } } + +export function playSoundById(id: string | undefined) { + return soundSrc(id).then((src) => playSound(src)) +} diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index ec2b4d1e7a..44f2e6360c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -246,6 +249,17 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } const [windowCount] = createResource(() => window.api.getWindowCount()) @@ -257,6 +271,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) const servers = () => { const data = sidecar() @@ -309,15 +324,14 @@ render(() => { return ( - - + + {(_) => { return ( 1} > diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index e677956440..5fe88d501b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -414,6 +417,17 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) @@ -423,6 +437,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive const servers = () => { @@ -465,8 +480,8 @@ render(() => { return ( - - + + {(_) => { return ( + + diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg new file mode 100644 index 0000000000..b3a2edc3c0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg new file mode 100644 index 0000000000..086e9aa1fc --- /dev/null +++ b/packages/ui/src/assets/icons/provider/clarifai.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg new file mode 100644 index 0000000000..e045c96fb3 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/dinference.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg new file mode 100644 index 0000000000..472dee9122 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/drun.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg new file mode 100644 index 0000000000..a0f38862a4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/perplexity-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg new file mode 100644 index 0000000000..502e51a5be --- /dev/null +++ b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg index d8d9ef665f..9eb8045e45 100644 --- a/packages/ui/src/assets/icons/provider/zenmux.svg +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index f735747a49..e1a508f16a 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,121 +1,9 @@ +import { Link, Style } from "@solidjs/meta" import { Show } from "solid-js" -import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" -import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" - -import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" -import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" -import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" -import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" -import hack from "../assets/fonts/hack-nerd-font.woff2" -import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" -import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" -import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" -import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" -import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" -import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" -import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" -import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" -import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" -import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" -import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" -import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" -import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" -import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" -import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" -import iosevka from "../assets/fonts/iosevka-nerd-font.woff2" -import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2" -import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2" -import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2" - -type MonoFont = { - family: string - regular: string - bold: string -} - -export const MONO_NERD_FONTS = [ - { - family: "JetBrains Mono Nerd Font", - regular: jetbrainsMono, - bold: jetbrainsMonoBold, - }, - { - family: "Fira Code Nerd Font", - regular: firaCode, - bold: firaCodeBold, - }, - { - family: "Cascadia Code Nerd Font", - regular: cascadiaCode, - bold: cascadiaCodeBold, - }, - { - family: "Hack Nerd Font", - regular: hack, - bold: hackBold, - }, - { - family: "Source Code Pro Nerd Font", - regular: sourceCodePro, - bold: sourceCodeProBold, - }, - { - family: "Inconsolata Nerd Font", - regular: inconsolata, - bold: inconsolataBold, - }, - { - family: "Roboto Mono Nerd Font", - regular: robotoMono, - bold: robotoMonoBold, - }, - { - family: "Ubuntu Mono Nerd Font", - regular: ubuntuMono, - bold: ubuntuMonoBold, - }, - { - family: "Intel One Mono Nerd Font", - regular: intelOneMono, - bold: intelOneMonoBold, - }, - { - family: "Meslo LGS Nerd Font", - regular: mesloLgs, - bold: mesloLgsBold, - }, - { - family: "Iosevka Nerd Font", - regular: iosevka, - bold: iosevkaBold, - }, - { - family: "GeistMono Nerd Font", - regular: geistMono, - bold: geistMonoBold, - }, -] satisfies MonoFont[] - -const monoNerdCss = MONO_NERD_FONTS.map( - (font) => ` - @font-face { - font-family: "${font.family}"; - src: url("${font.regular}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "${font.family}"; - src: url("${font.bold}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; - }`, -).join("") +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" export const Font = () => { return ( @@ -165,7 +53,6 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } -${monoNerdCss} `} diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts new file mode 100644 index 0000000000..f2b1e6be13 --- /dev/null +++ b/packages/ui/src/font-loader.ts @@ -0,0 +1,133 @@ +type MonoFont = { + id: string + family: string + regular: string + bold: string +} + +let files: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> + return files +} + +export const MONO_NERD_FONTS = [ + { + id: "jetbrains-mono", + family: "JetBrains Mono Nerd Font", + regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", + bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", + }, + { + id: "fira-code", + family: "Fira Code Nerd Font", + regular: "./assets/fonts/fira-code-nerd-font.woff2", + bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", + }, + { + id: "cascadia-code", + family: "Cascadia Code Nerd Font", + regular: "./assets/fonts/cascadia-code-nerd-font.woff2", + bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", + }, + { + id: "hack", + family: "Hack Nerd Font", + regular: "./assets/fonts/hack-nerd-font.woff2", + bold: "./assets/fonts/hack-nerd-font-bold.woff2", + }, + { + id: "source-code-pro", + family: "Source Code Pro Nerd Font", + regular: "./assets/fonts/source-code-pro-nerd-font.woff2", + bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", + }, + { + id: "inconsolata", + family: "Inconsolata Nerd Font", + regular: "./assets/fonts/inconsolata-nerd-font.woff2", + bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", + }, + { + id: "roboto-mono", + family: "Roboto Mono Nerd Font", + regular: "./assets/fonts/roboto-mono-nerd-font.woff2", + bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", + }, + { + id: "ubuntu-mono", + family: "Ubuntu Mono Nerd Font", + regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", + bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", + }, + { + id: "intel-one-mono", + family: "Intel One Mono Nerd Font", + regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", + bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", + }, + { + id: "meslo-lgs", + family: "Meslo LGS Nerd Font", + regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", + bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", + }, + { + id: "iosevka", + family: "Iosevka Nerd Font", + regular: "./assets/fonts/iosevka-nerd-font.woff2", + bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", + }, + { + id: "geist-mono", + family: "GeistMono Nerd Font", + regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", + bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", + }, +] satisfies MonoFont[] + +const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record +const loads = new Map>() + +function css(font: { family: string; regular: string; bold: string }) { + return ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + } + ` +} + +export function ensureMonoFont(id: string | undefined) { + if (!id || id === "ibm-plex-mono") return Promise.resolve() + if (typeof document !== "object") return Promise.resolve() + const font = mono[id] + if (!font) return Promise.resolve() + const styleId = `oc-font-${font.id}` + if (document.getElementById(styleId)) return Promise.resolve() + const hit = loads.get(font.id) + if (hit) return hit + const files = getFiles() + const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { + if (!regular || !bold) return + if (document.getElementById(styleId)) return + const style = document.createElement("style") + style.id = styleId + style.textContent = css({ family: font.family, regular, bold }) + document.head.appendChild(style) + }) + loads.set(font.id, load) + return load +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 9808c8e841..7d25ac3972 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { DEFAULT_THEMES } from "./default-themes" +import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,14 +15,101 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" +let files: Record Promise<{ default: DesktopTheme }>> | undefined +let ids: string[] | undefined +let known: Set | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") + return files +} + +function themeIDs() { + if (ids) return ids + ids = Object.keys(getFiles()) + .map((path) => path.slice("./themes/".length, -".json".length)) + .sort() + return ids +} + +function knownThemes() { + if (known) return known + known = new Set(themeIDs()) + return known +} + +const names: Record = { + "oc-2": "OC-2", + amoled: "AMOLED", + aura: "Aura", + ayu: "Ayu", + carbonfox: "Carbonfox", + catppuccin: "Catppuccin", + "catppuccin-frappe": "Catppuccin Frappe", + "catppuccin-macchiato": "Catppuccin Macchiato", + cobalt2: "Cobalt2", + cursor: "Cursor", + dracula: "Dracula", + everforest: "Everforest", + flexoki: "Flexoki", + github: "GitHub", + gruvbox: "Gruvbox", + kanagawa: "Kanagawa", + "lucent-orng": "Lucent Orng", + material: "Material", + matrix: "Matrix", + mercury: "Mercury", + monokai: "Monokai", + nightowl: "Night Owl", + nord: "Nord", + "one-dark": "One Dark", + onedarkpro: "One Dark Pro", + opencode: "OpenCode", + orng: "Orng", + "osaka-jade": "Osaka Jade", + palenight: "Palenight", + rosepine: "Rose Pine", + shadesofpurple: "Shades of Purple", + solarized: "Solarized", + synthwave84: "Synthwave '84", + tokyonight: "Tokyonight", + vercel: "Vercel", + vesper: "Vesper", + zenburn: "Zenburn", +} +const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } +function read(key: string) { + if (typeof localStorage !== "object") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function write(key: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(key, value) + } catch {} +} + +function drop(key: string) { + if (typeof localStorage !== "object") return + try { + localStorage.removeItem(key) + } catch {} +} + function clear() { - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) + drop(STORAGE_KEYS.THEME_CSS_LIGHT) + drop(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { + if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } const fullCss = `:root { @@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: DEFAULT_THEMES as Record, - themeId: normalize(props.defaultTheme) ?? "oc-2", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themes: { + "oc-2": oc2Theme, + } as Record, + themeId, + colorScheme, + mode, previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) - window.addEventListener("storage", (e) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) - } - }) + const loads = new Map>() - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - if (store.colorScheme === "system") { - setStore("mode", getSystemMode()) - } - } - mediaQuery.addEventListener("change", handler) - onCleanup(() => mediaQuery.removeEventListener("change", handler)) - - const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const themeId = normalize(savedTheme) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (themeId && store.themes[themeId]) { - setStore("themeId", themeId) - } - if (savedTheme && themeId && savedTheme !== themeId) { - localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) - clear() - } - if (savedScheme) { - setStore("colorScheme", savedScheme) - if (savedScheme !== "system") { - setStore("mode", savedScheme) - } - } - const currentTheme = store.themes[store.themeId] - if (currentTheme) { - cacheThemeVariants(currentTheme, store.themeId) - } - }) + const load = (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme + }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } + const ids = () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } + + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + setStore("themeId", next) + if (next === "oc-2") { + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + }) + } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } + + if (typeof window === "object") { + window.addEventListener("storage", onStorage) + onCleanup(() => window.removeEventListener("storage", onStorage)) + } + + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + mediaQuery.addEventListener("change", onMedia) + onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) + }) + createEffect(() => { const theme = store.themes[store.themeId] - if (theme) { - applyTheme(theme, store.themeId, store.mode) - } + if (!theme) return + applyTheme(theme, store.themeId, store.mode) }) const setTheme = (id: string) => { @@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - const theme = store.themes[next] - if (!theme) { + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) - localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) clear() return } - cacheThemeVariants(theme, next) + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, + ids, + name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, + loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - const theme = store.themes[next] - if (!theme) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next) - const previewMode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, previewMode) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode) + }) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const previewMode = scheme === "system" ? getSystemMode() : scheme + const mode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - const theme = store.themes[id] - if (theme) { - applyTheme(theme, id, previewMode) - } + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode) + }) }, commitPreview: () => { if (store.previewThemeId) { @@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - const theme = store.themes[store.themeId] - if (theme) { + void load(store.themeId).then((theme) => { + if (!theme) return applyTheme(theme, store.themeId, store.mode) - } + }) }, } }, From 2b0baf97bd176dfbb9afe81931c90bc6288ada34 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Tue, 24 Mar 2026 18:49:14 -0500 Subject: [PATCH 16/36] Reapply "fix(app): more startup efficiency (#18985)" This reverts commit cbe1337f2401066cf33eb9009b597eafb49123ba. --- packages/app/src/components/prompt-input.tsx | 1 + .../app/src/context/global-sync/bootstrap.ts | 308 +++++++++++------- packages/app/src/context/settings.tsx | 7 +- packages/app/src/context/sync.tsx | 7 +- packages/app/src/pages/home.tsx | 8 + packages/app/src/pages/session.tsx | 7 +- .../pages/session/use-session-hash-scroll.ts | 18 + packages/app/vite.js | 12 + 8 files changed, 240 insertions(+), 128 deletions(-) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f523671ec9..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c795ab471c..47be3abcb3 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,6 +31,47 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient requestFailedTitle: string @@ -38,45 +79,54 @@ export async function bootstrapGlobal(input: { formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const tasks = [ - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), + const fast = [ + () => + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -119,95 +169,113 @@ export async function bootstrapDirectory(input: { } if (loading) input.setStore("status", "partial") - const results = await Promise.allSettled([ - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - retry(() => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), - retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - input.loadSessions(input.directory), - retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), - retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - ), - retry(() => - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - retry(() => - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - ]) + const fast = [ + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] - const errors = results - .filter((item): item is PromiseRejectedResult => item.status === "rejected") - .map((item) => item.reason) - if (errors.length > 0) { - console.error("Failed to bootstrap instance", errors[0]) + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + const errs = errors(await runAll(fast)) + if (errs.length > 0) { + console.error("Failed to bootstrap instance", errs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errors[0], input.translate), + description: formatServerError(errs[0], input.translate), }) - return } - if (loading) input.setStore("status", "complete") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } + + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 247d36dd36..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -118,8 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - void loadFont().then((x) => x.ensureMonoFont(store.appearance?.font)) - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66b889e2ad..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() {
+ +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7a3b476e8d..2d3e31355a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1184,8 +1184,6 @@ export default function Page() { on( () => sdk.directory, () => { - void file.tree.list("") - const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1640,6 +1638,9 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, + historyMore, + historyLoading, + loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1711,7 +1712,7 @@ export default function Page() {
- + string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/vite.js b/packages/app/vite.js index 6b8fd61376..f65a68a1cb 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) + /** * @type {import("vite").PluginOption} */ @@ -21,6 +24,15 @@ export default [ } }, }, + { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + return html.replace( + '', + ``, + ) + }, + }, tailwindcss(), solidPlugin(), ] From 53d0b58ebf3468bd161dcfcdc67cd66b6508e9f8 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 04:47:29 -0500 Subject: [PATCH 17/36] fix(app): hash inline script for csp --- packages/opencode/src/server/server.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index 7ead4df8a3..e4c98c609e 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -1,3 +1,4 @@ +import { createHash } from "node:crypto" import { Log } from "../util/log" import { describeRoute, generateSpecs, validator, resolver, openAPIRouteHandler } from "hono-openapi" import { Hono } from "hono" @@ -47,6 +48,9 @@ import { lazy } from "@/util/lazy" // @ts-ignore This global is needed to prevent ai-sdk from logging warnings to stdout https://github.com/vercel/ai/blob/2dc67e0ef538307f21368db32d5a12345d98831b/packages/ai/src/logger/log-warnings.ts#L85 globalThis.AI_SDK_LOG_WARNINGS = false +const csp = (hash = "") => + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` + export namespace Server { const log = Log.create({ service: "server" }) @@ -506,10 +510,13 @@ export namespace Server { host: "app.opencode.ai", }, }) - response.headers.set( - "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", - ) + const match = response.headers.get("content-type")?.includes("text/html") + ? (await response.clone().text()).match( + /]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i, + ) + : undefined + const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" + response.headers.set("Content-Security-Policy", csp(hash)) return response }) } From 898456a25cf2edbfc4ae4961b37424f633419dd6 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:23:25 -0500 Subject: [PATCH 18/36] Revert "fix(app): startup efficiency" --- packages/app/src/app.tsx | 8 +- .../components/dialog-connect-provider.tsx | 50 +-- packages/app/src/components/prompt-input.tsx | 1 - .../app/src/components/settings-general.tsx | 50 +-- .../app/src/components/status-popover.tsx | 23 +- packages/app/src/components/terminal.tsx | 5 +- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/context/global-sync.tsx | 58 +-- .../app/src/context/global-sync/bootstrap.ts | 353 +++++++----------- packages/app/src/context/language.tsx | 144 +++---- packages/app/src/context/notification.tsx | 6 +- packages/app/src/context/settings.tsx | 13 +- packages/app/src/context/sync.tsx | 7 +- packages/app/src/context/terminal-title.ts | 51 ++- packages/app/src/entry.tsx | 11 +- packages/app/src/hooks/use-providers.ts | 2 +- packages/app/src/index.ts | 1 - packages/app/src/pages/directory-layout.tsx | 70 ++-- packages/app/src/pages/home.tsx | 8 - packages/app/src/pages/layout.tsx | 62 ++- packages/app/src/pages/session.tsx | 7 +- .../pages/session/use-session-hash-scroll.ts | 18 - packages/app/src/utils/server-health.ts | 24 +- packages/app/src/utils/sound.ts | 177 +++++---- packages/app/vite.js | 12 - .../desktop-electron/src/renderer/index.tsx | 20 +- packages/desktop/src/index.tsx | 19 +- packages/opencode/src/server/server.ts | 15 +- packages/ui/package.json | 1 - .../icons/provider/alibaba-coding-plan-cn.svg | 3 - .../icons/provider/alibaba-coding-plan.svg | 3 - .../ui/src/assets/icons/provider/clarifai.svg | 24 -- .../src/assets/icons/provider/dinference.svg | 1 - .../ui/src/assets/icons/provider/drun.svg | 8 - .../icons/provider/perplexity-agent.svg | 3 - .../icons/provider/tencent-coding-plan.svg | 5 - .../ui/src/assets/icons/provider/zenmux.svg | 5 +- packages/ui/src/components/font.tsx | 119 +++++- packages/ui/src/font-loader.ts | 133 ------- packages/ui/src/theme/context.tsx | 294 ++++----------- 40 files changed, 695 insertions(+), 1121 deletions(-) delete mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg delete mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg delete mode 100644 packages/ui/src/assets/icons/provider/clarifai.svg delete mode 100644 packages/ui/src/assets/icons/provider/dinference.svg delete mode 100644 packages/ui/src/assets/icons/provider/drun.svg delete mode 100644 packages/ui/src/assets/icons/provider/perplexity-agent.svg delete mode 100644 packages/ui/src/assets/icons/provider/tencent-coding-plan.svg delete mode 100644 packages/ui/src/font-loader.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 0eb5b4e9e0..5247c951d3 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" -import { ThemeProvider } from "@opencode-ai/ui/theme/context" +import { ThemeProvider } from "@opencode-ai/ui/theme" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file" import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" import { HighlightsProvider } from "@/context/highlights" -import { LanguageProvider, type Locale, useLanguage } from "@/context/language" +import { LanguageProvider, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { +export function AppBaseProviders(props: ParentProps) { return ( @@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { void window.api?.setTitlebar?.({ mode }) }} > - + }> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index e7eaa1fb29..734958dd58 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" +import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" @@ -34,25 +34,15 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) - const fallback = createMemo(() => [ - { - type: "api" as const, - label: language.t("provider.connect.method.apiKey"), - }, - ]) - const [auth] = createResource( - () => props.provider, - async () => { - const cached = globalSync.data.provider_auth[props.provider] - if (cached) return cached - const res = await globalSDK.client.provider.auth() - if (!alive.value) return fallback() - globalSync.set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() - }, + const methods = createMemo( + () => + globalSync.data.provider_auth[props.provider] ?? [ + { + type: "api", + label: language.t("provider.connect.method.apiKey"), + }, + ], ) - const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -187,11 +177,7 @@ export function DialogConnectProvider(props: { provider: string }) { index: 0, }) - const prompts = createMemo>(() => { - const value = method() - if (value?.type !== "oauth") return [] - return value.prompts ?? [] - }) + const prompts = createMemo(() => method()?.prompts ?? []) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] @@ -310,12 +296,8 @@ export function DialogConnectProvider(props: { provider: string }) { listRef?.onKeyDown(e) } - let auto = false - createEffect(() => { - if (auto) return - if (loading()) return + onMount(() => { if (methods().length === 1) { - auto = true selectMethod(0) } }) @@ -591,14 +573,6 @@ export function DialogConnectProvider(props: { provider: string }) {
- -
-
- - {language.t("provider.connect.status.inProgress")} -
-
-
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index ee98e68cd5..f523671ec9 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,7 +572,6 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) - if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index f4b8198e7e..b768bafcca 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,41 +1,27 @@ -import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { playSound, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, - run: 0, -} - -type ThemeOption = { - id: string - name: string -} - -let font: Promise | undefined - -function loadFont() { - font ??= import("@opencode-ai/ui/font-loader") - return font } // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { - demoSoundState.run += 1 if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -43,19 +29,12 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (id: string | undefined) => { +const playDemoSound = (src: string | undefined) => { stopDemoSound() - if (!id) return + if (!src) return - const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) + demoSoundState.cleanup = playSound(src) }, 100) } @@ -65,10 +44,6 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() - onMount(() => { - void theme.loadThemes() - }) - const [store, setStore] = createStore({ checking: false, }) @@ -129,7 +104,9 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + const themeOptions = createMemo(() => + Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), + ) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -166,7 +143,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none" } as const + const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -181,7 +158,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) + playDemoSound(option.src) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -192,7 +169,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.id) + playDemoSound(option.src) }, variant: "secondary" as const, size: "small" as const, @@ -344,9 +321,6 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} - onHighlight={(option) => { - void loadFont().then((x) => x.ensureMonoFont(option?.value)) - }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 8d5ecac39a..464522443f 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,6 +16,7 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" +import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -53,15 +54,11 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor, enabled: Accessor) => { +const useServerHealth = (servers: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { - if (!enabled()) { - setStatus(reconcile({})) - return - } const list = servers() let dead = false @@ -165,12 +162,6 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) - let dialogRun = 0 - let dialogDead = false - onCleanup(() => { - dialogDead = true - dialogRun += 1 - }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -178,7 +169,7 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers, shown) + const health = useServerHealth(servers) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -309,13 +300,7 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 0a5a7d2d3e..aed46f1262 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,7 +1,4 @@ -import { withAlpha } from "@opencode-ai/ui/theme/color" -import { useTheme } from "@opencode-ai/ui/theme/context" -import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" -import type { HexColor } from "@opencode-ai/ui/theme/types" +import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 0a41f31196..77de1a73ce 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme/context" +import { useTheme } from "@opencode-ai/ui/theme" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index cbd08e99f5..2d1e501353 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,7 +9,17 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" +import { + createContext, + getOwner, + Match, + onCleanup, + onMount, + type ParentProps, + Switch, + untrack, + useContext, +} from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -70,8 +80,6 @@ function createGlobalSync() { let active = true let projectWritten = false - let bootedAt = 0 - let bootingRoot = false onCleanup(() => { active = false @@ -250,11 +258,6 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, - global: { - config: globalStore.config, - project: globalStore.project, - provider: globalStore.provider, - }, sdk, store: child[0], setStore: child[1], @@ -275,20 +278,15 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details - const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: () => { - if (recent) return - queue.refresh() - }, + refresh: queue.refresh, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { - if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -327,19 +325,17 @@ function createGlobalSync() { }) async function bootstrap() { - bootingRoot = true - try { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) - bootedAt = Date.now() - } finally { - bootingRoot = false - } + await bootstrapGlobal({ + globalSDK: globalSDK.client, + connectErrorTitle: language.t("dialog.server.add.error"), + connectErrorDescription: language.t("error.globalSync.connectFailed", { + url: globalSDK.url, + }), + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) } onMount(() => { @@ -396,7 +392,13 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return {props.children} + return ( + + + {props.children} + + + ) } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 47be3abcb3..13494b7ade 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,102 +31,73 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -function waitForPaint() { - return new Promise((resolve) => { - let done = false - const finish = () => { - if (done) return - done = true - resolve() - } - const timer = setTimeout(finish, 50) - if (typeof requestAnimationFrame !== "function") return - requestAnimationFrame(() => { - clearTimeout(timer) - finish() - }) - }) -} - -function errors(list: PromiseSettledResult[]) { - return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) -} - -function runAll(list: Array<() => Promise>) { - return Promise.allSettled(list.map((item) => item())) -} - -function showErrors(input: { - errors: unknown[] - title: string - translate: (key: string, vars?: Record) => string - formatMoreCount: (count: number) => string -}) { - if (input.errors.length === 0) return - const message = formatServerError(input.errors[0], input.translate) - const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" - showToast({ - variant: "error", - title: input.title, - description: message + more, - }) -} - export async function bootstrapGlobal(input: { globalSDK: OpencodeClient + connectErrorTitle: string + connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const fast = [ - () => - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - () => - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - () => - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), + const health = await input.globalSDK.global + .health() + .then((x) => x.data) + .catch(() => undefined) + if (!health?.healthy) { + showToast({ + variant: "error", + title: input.connectErrorTitle, + description: input.connectErrorDescription, + }) + input.setGlobalStore("ready", true) + return + } + + const tasks = [ + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), + retry(() => + input.globalSDK.provider.auth().then((x) => { + input.setGlobalStore("provider_auth", x.data ?? {}) + }), + ), ] - const slow = [ - () => - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - ] - - showErrors({ - errors: errors(await runAll(fast)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) - await waitForPaint() - showErrors({ - errors: errors(await runAll(slow)), - title: input.requestFailedTitle, - translate: input.translate, - formatMoreCount: input.formatMoreCount, - }) + const results = await Promise.allSettled(tasks) + const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) + if (errors.length) { + const message = formatServerError(errors[0], input.translate) + const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" + showToast({ + variant: "error", + title: input.requestFailedTitle, + description: message + more, + }) + } input.setGlobalStore("ready", true) } @@ -140,10 +111,6 @@ function groupBySession(input: T[]) }, {}) } -function projectID(directory: string, projects: Project[]) { - return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id -} - export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -152,130 +119,88 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string - global: { - config: Config - project: Project[] - provider: ProviderListResponse - } }) { - const loading = input.store.status !== "complete" - const seededProject = projectID(input.directory, input.global.project) - if (seededProject) input.setStore("project", seededProject) - if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { - input.setStore("provider", input.global.provider) - } - if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { - input.setStore("config", input.global.config) - } - if (loading) input.setStore("status", "partial") + if (input.store.status !== "complete") input.setStore("status", "loading") - const fast = [ - () => - seededProject - ? Promise.resolve() - : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), - () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), - () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), - () => - retry(() => - input.sdk.path.get().then((x) => { - input.setStore("path", x.data!) - const next = projectID(x.data?.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - }), - ), - () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), - () => - retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - ), - () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), - () => - retry(() => - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + const blockingRequests = { + project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), + provider: () => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), + config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), + } + + try { + await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) + } catch (err) { + console.error("Failed to bootstrap instance", err) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + input.setStore("status", "partial") + return + } + + if (input.store.status !== "complete") input.setStore("status", "partial") + + Promise.all([ + input.sdk.path.get().then((x) => input.setStore("path", x.data!)), + input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), + input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), + input.loadSessions(input.directory), + input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), + input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - () => - retry(() => - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ), - ] - - const slow = [ - () => - retry(() => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - ), - () => Promise.resolve(input.loadSessions(input.directory)), - () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), - () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), - ] - - const errs = errors(await runAll(fast)) - if (errs.length > 0) { - console.error("Failed to bootstrap instance", errs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(errs[0], input.translate), - }) - } - - await waitForPaint() - const slowErrs = errors(await runAll(slow)) - if (slowErrs.length > 0) { - console.error("Failed to finish bootstrap instance", slowErrs[0]) - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(slowErrs[0], input.translate), - }) - } - - if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") + } + }) + }), + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ]).then(() => { + input.setStore("status", "complete") + }) } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index 51dc09cd7d..b1edd541c3 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,10 +1,42 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo, createResource } from "solid-js" +import { createEffect, createMemo } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" +import { dict as zh } from "@/i18n/zh" +import { dict as zht } from "@/i18n/zht" +import { dict as ko } from "@/i18n/ko" +import { dict as de } from "@/i18n/de" +import { dict as es } from "@/i18n/es" +import { dict as fr } from "@/i18n/fr" +import { dict as da } from "@/i18n/da" +import { dict as ja } from "@/i18n/ja" +import { dict as pl } from "@/i18n/pl" +import { dict as ru } from "@/i18n/ru" +import { dict as ar } from "@/i18n/ar" +import { dict as no } from "@/i18n/no" +import { dict as br } from "@/i18n/br" +import { dict as th } from "@/i18n/th" +import { dict as bs } from "@/i18n/bs" +import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" +import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" +import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" +import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" +import { dict as uiDe } from "@opencode-ai/ui/i18n/de" +import { dict as uiEs } from "@opencode-ai/ui/i18n/es" +import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" +import { dict as uiDa } from "@opencode-ai/ui/i18n/da" +import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" +import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" +import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" +import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" +import { dict as uiNo } from "@opencode-ai/ui/i18n/no" +import { dict as uiBr } from "@opencode-ai/ui/i18n/br" +import { dict as uiTh } from "@opencode-ai/ui/i18n/th" +import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" +import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" export type Locale = | "en" @@ -27,7 +59,6 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten -type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -94,43 +125,24 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const dicts = new Map([["en", base]]) - -const merge = (app: Promise, ui: Promise) => - Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) - -const loaders: Record, () => Promise> = { - zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), - zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), - ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), - de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), - es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), - fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), - da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), - ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), - pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), - ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), - ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), - no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), - br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), - th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), - bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), - tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), -} - -function loadDict(locale: Locale) { - const hit = dicts.get(locale) - if (hit) return Promise.resolve(hit) - if (locale === "en") return Promise.resolve(base) - const load = loaders[locale] - return load().then((next: Dictionary) => { - dicts.set(locale, next) - return next - }) -} - -export function loadLocaleDict(locale: Locale) { - return loadDict(locale).then(() => undefined) +const DICT: Record = { + en: base, + zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, + zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, + ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, + de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, + es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, + fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, + da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, + ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, + pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, + ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, + ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, + no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, + br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, + th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, + bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, + tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -156,6 +168,27 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] +type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" +const PARITY_CHECK: Record, Record> = { + zh, + zht, + ko, + de, + es, + fr, + da, + ja, + pl, + ru, + ar, + no, + br, + th, + bs, + tr, +} +void PARITY_CHECK + function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -170,48 +203,27 @@ function detectLocale(): Locale { return "en" } -export function normalizeLocale(value: string): Locale { +function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } -function readStoredLocale() { - if (typeof localStorage !== "object") return - try { - const raw = localStorage.getItem("opencode.global.dat:language") - if (!raw) return - const next = JSON.parse(raw) as { locale?: string } - if (typeof next?.locale !== "string") return - return normalizeLocale(next.locale) - } catch { - return - } -} - -const warm = readStoredLocale() ?? detectLocale() -if (warm !== "en") void loadDict(warm) - export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: (props: { locale?: Locale }) => { - const initial = props.locale ?? readStoredLocale() ?? detectLocale() + init: () => { const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: initial, + locale: detectLocale() as Locale, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) + console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const [dict] = createResource(locale, loadDict, { - initialValue: dicts.get(initial) ?? base, - }) + const dict = createMemo(() => DICT[locale()]) - const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( - key: keyof Dictionary, - params?: Record, - ) => string + const t = i18n.translator(dict, i18n.resolveTemplate) const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 281a1ef33d..04bc2fdaaa 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSoundById } from "@/utils/sound" +import { playSound, soundSrc } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - void playSoundById(settings.sounds.agent()) + playSound(soundSrc(settings.sounds.agent())) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - void playSoundById(settings.sounds.errors()) + playSound(soundSrc(settings.sounds.errors())) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index eddd752eb4..48788fe8ec 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,13 +104,6 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } -let font: Promise | undefined - -function loadFont() { - font ??= import("@opencode-ai/ui/font-loader") - return font -} - export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -118,11 +111,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - const id = store.appearance?.font ?? defaultSettings.appearance.font - if (id !== defaultSettings.appearance.font) { - void loadFont().then((x) => x.ensureMonoFont(id)) - } - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index bbf4fc5ec4..66b889e2ad 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,8 +180,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const initialMessagePageSize = 80 - const historyMessagePageSize = 200 + const messagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -464,7 +463,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? initialMessagePageSize + const limit = meta.limit[key] ?? messagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -561,7 +560,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? historyMessagePageSize + const step = count ?? messagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index c8b18f4211..3e8fa9af25 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,18 +1,45 @@ -const template = "Terminal {{number}}" +import { dict as ar } from "@/i18n/ar" +import { dict as br } from "@/i18n/br" +import { dict as bs } from "@/i18n/bs" +import { dict as da } from "@/i18n/da" +import { dict as de } from "@/i18n/de" +import { dict as en } from "@/i18n/en" +import { dict as es } from "@/i18n/es" +import { dict as fr } from "@/i18n/fr" +import { dict as ja } from "@/i18n/ja" +import { dict as ko } from "@/i18n/ko" +import { dict as no } from "@/i18n/no" +import { dict as pl } from "@/i18n/pl" +import { dict as ru } from "@/i18n/ru" +import { dict as th } from "@/i18n/th" +import { dict as tr } from "@/i18n/tr" +import { dict as zh } from "@/i18n/zh" +import { dict as zht } from "@/i18n/zht" -const numbered = [ - template, - "محطة طرفية {{number}}", - "Терминал {{number}}", - "ターミナル {{number}}", - "터미널 {{number}}", - "เทอร์มินัล {{number}}", - "终端 {{number}}", - "終端機 {{number}}", -] +const numbered = Array.from( + new Set([ + en["terminal.title.numbered"], + ar["terminal.title.numbered"], + br["terminal.title.numbered"], + bs["terminal.title.numbered"], + da["terminal.title.numbered"], + de["terminal.title.numbered"], + es["terminal.title.numbered"], + fr["terminal.title.numbered"], + ja["terminal.title.numbered"], + ko["terminal.title.numbered"], + no["terminal.title.numbered"], + pl["terminal.title.numbered"], + ru["terminal.title.numbered"], + th["terminal.title.numbered"], + tr["terminal.title.numbered"], + zh["terminal.title.numbered"], + zht["terminal.title.numbered"], + ]), +) export function defaultTitle(number: number) { - return template.replace("{{number}}", String(number)) + return en["terminal.title.numbered"].replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index da22c55523..b5cbed6e75 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } -const localUrl = () => - `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - -const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) - const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return localUrl() - if (import.meta.env.DEV) return localUrl() - if (isLocalHost()) return localUrl() + if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (import.meta.env.DEV) + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` return location.origin } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a8f2360bbf..a25f8b4b25 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - if (projectStore.provider.all.length > 0) return projectStore.provider + return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d80e9fffb0..53063f48f8 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,7 +1,6 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" -export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 6d3b04be9d..cd5e079a69 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,7 +2,8 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createEffect, createMemo, type ParentProps, Show } from "solid-js" +import { createMemo, createResource, type ParentProps, Show } from "solid-js" +import { useGlobalSDK } from "@/context/global-sdk" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -10,18 +11,10 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { - const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) - createEffect(() => { - const next = sync.data.path.directory - if (!next || next === props.directory) return - const path = location.pathname.slice(slug().length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - return ( ) { export default function Layout(props: ParentProps) { const params = useParams() + const location = useLocation() const language = useLanguage() + const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const resolved = createMemo(() => { - if (!params.dir) return "" - return decode64(params.dir) ?? "" - }) + const [resolved] = createResource( + () => { + if (params.dir) return [location.pathname, params.dir] as const + }, + async ([pathname, b64Dir]) => { + const directory = decode64(b64Dir) - createEffect(() => { - const dir = params.dir - if (!dir) return - if (resolved()) { - invalid = "" - return - } - if (invalid === dir) return - invalid = dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - }) + if (!directory) { + if (invalid === params.dir) return + invalid = b64Dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + return + } + + return await globalSDK + .createClient({ + directory, + throwOnError: true, + }) + .path.get() + .then((x) => { + const next = x.data?.directory ?? directory + invalid = "" + if (next === directory) return next + const path = pathname.slice(b64Dir.length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + .catch(() => { + invalid = "" + return directory + }) + }, + ) return ( diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 4c795b9683..ba3a2b9427 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,14 +113,6 @@ export default function Home() {
- -
-
{language.t("common.loading")}
- -
-
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b5a96110f6..01e151605d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,16 +49,21 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSoundById } from "@/utils/sound" +import { playSound, soundSrc } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { DialogSelectProvider } from "@/components/dialog-select-provider" +import { DialogSelectServer } from "@/components/dialog-select-server" +import { DialogSettings } from "@/components/dialog-settings" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" +import { DialogSelectDirectory } from "@/components/dialog-select-directory" +import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -105,8 +110,6 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined - let dialogRun = 0 - let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -136,7 +139,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) + const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -198,8 +201,6 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { - dialogDead = true - dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -335,9 +336,10 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) + const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: theme.name(nextThemeId), + description: nextTheme?.name ?? nextThemeId, }) } @@ -492,7 +494,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - void playSoundById(settings.sounds.permissions()) + playSound(soundSrc(settings.sounds.permissions())) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -1152,10 +1154,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id] of availableThemeEntries()) { + for (const [id, definition] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: theme.name(id) }), + title: language.t("command.theme.set", { theme: definition.name ?? id }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1206,27 +1208,15 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - const run = ++dialogRun - void import("@/components/dialog-select-provider").then((x) => { - if (dialogDead || dialogRun !== run) return - dialog.show(() => ) - }) + dialog.show(() => ) } function openServer() { - const run = ++dialogRun - void import("@/components/dialog-select-server").then((x) => { - if (dialogDead || dialogRun !== run) return - dialog.show(() => ) - }) + dialog.show(() => ) } function openSettings() { - const run = ++dialogRun - void import("@/components/dialog-settings").then((x) => { - if (dialogDead || dialogRun !== run) return - dialog.show(() => ) - }) + dialog.show(() => ) } function projectRoot(directory: string) { @@ -1453,13 +1443,7 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => { - const run = ++dialogRun - void import("@/components/dialog-edit-project").then((x) => { - if (dialogDead || dialogRun !== run) return - dialog.show(() => ) - }) - } + const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1480,14 +1464,10 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - const run = ++dialogRun - void import("@/components/dialog-select-directory").then((x) => { - if (dialogDead || dialogRun !== run) return - dialog.show( - () => , - () => resolve(null), - ) - }) + dialog.show( + () => , + () => resolve(null), + ) } } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 2d3e31355a..7a3b476e8d 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1184,6 +1184,8 @@ export default function Page() { on( () => sdk.directory, () => { + void file.tree.list("") + const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1638,9 +1640,6 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, - historyMore, - historyLoading, - loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1712,7 +1711,7 @@ export default function Page() {
- + string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] - historyMore: () => boolean - historyLoading: () => boolean - loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -184,21 +181,6 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) - createEffect(() => { - const sessionID = input.sessionID() - if (!sessionID || !input.messagesReady()) return - - visibleUserMessages() - - let targetId = input.pendingMessage() - if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) - if (!targetId) return - if (messageById().has(targetId)) return - if (!input.historyMore() || input.historyLoading()) return - - void input.loadMore(sessionID) - }) - onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index a13fd34ef7..45a323c7be 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -14,15 +14,6 @@ interface CheckServerHealthOptions { const defaultTimeoutMs = 3000 const defaultRetryCount = 2 const defaultRetryDelayMs = 100 -const cacheMs = 750 -const healthCache = new Map< - string, - { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } ->() - -function cacheKey(server: ServerConnection.HttpBase) { - return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` -} function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout @@ -96,18 +87,5 @@ export function useCheckServerHealth() { const platform = usePlatform() const fetcher = platform.fetch ?? globalThis.fetch - return (http: ServerConnection.HttpBase) => { - const key = cacheKey(http) - const hit = healthCache.get(key) - const now = Date.now() - if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise - const promise = checkServerHealth(http, fetcher).finally(() => { - const next = healthCache.get(key) - if (!next || next.promise !== promise) return - next.done = true - next.at = Date.now() - }) - healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) - return promise - } + return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) } diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 78e5a0c565..6dea812ec8 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -1,89 +1,106 @@ -let files: Record Promise> | undefined -let loads: Record Promise> | undefined - -function getFiles() { - if (files) return files - files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< - string, - () => Promise - > - return files -} +import alert01 from "@opencode-ai/ui/audio/alert-01.aac" +import alert02 from "@opencode-ai/ui/audio/alert-02.aac" +import alert03 from "@opencode-ai/ui/audio/alert-03.aac" +import alert04 from "@opencode-ai/ui/audio/alert-04.aac" +import alert05 from "@opencode-ai/ui/audio/alert-05.aac" +import alert06 from "@opencode-ai/ui/audio/alert-06.aac" +import alert07 from "@opencode-ai/ui/audio/alert-07.aac" +import alert08 from "@opencode-ai/ui/audio/alert-08.aac" +import alert09 from "@opencode-ai/ui/audio/alert-09.aac" +import alert10 from "@opencode-ai/ui/audio/alert-10.aac" +import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" +import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" +import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" +import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" +import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" +import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" +import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" +import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" +import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" +import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" +import nope01 from "@opencode-ai/ui/audio/nope-01.aac" +import nope02 from "@opencode-ai/ui/audio/nope-02.aac" +import nope03 from "@opencode-ai/ui/audio/nope-03.aac" +import nope04 from "@opencode-ai/ui/audio/nope-04.aac" +import nope05 from "@opencode-ai/ui/audio/nope-05.aac" +import nope06 from "@opencode-ai/ui/audio/nope-06.aac" +import nope07 from "@opencode-ai/ui/audio/nope-07.aac" +import nope08 from "@opencode-ai/ui/audio/nope-08.aac" +import nope09 from "@opencode-ai/ui/audio/nope-09.aac" +import nope10 from "@opencode-ai/ui/audio/nope-10.aac" +import nope11 from "@opencode-ai/ui/audio/nope-11.aac" +import nope12 from "@opencode-ai/ui/audio/nope-12.aac" +import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" +import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" +import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" +import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" +import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" +import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" +import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" +import yup01 from "@opencode-ai/ui/audio/yup-01.aac" +import yup02 from "@opencode-ai/ui/audio/yup-02.aac" +import yup03 from "@opencode-ai/ui/audio/yup-03.aac" +import yup04 from "@opencode-ai/ui/audio/yup-04.aac" +import yup05 from "@opencode-ai/ui/audio/yup-05.aac" +import yup06 from "@opencode-ai/ui/audio/yup-06.aac" export const SOUND_OPTIONS = [ - { id: "alert-01", label: "sound.option.alert01" }, - { id: "alert-02", label: "sound.option.alert02" }, - { id: "alert-03", label: "sound.option.alert03" }, - { id: "alert-04", label: "sound.option.alert04" }, - { id: "alert-05", label: "sound.option.alert05" }, - { id: "alert-06", label: "sound.option.alert06" }, - { id: "alert-07", label: "sound.option.alert07" }, - { id: "alert-08", label: "sound.option.alert08" }, - { id: "alert-09", label: "sound.option.alert09" }, - { id: "alert-10", label: "sound.option.alert10" }, - { id: "bip-bop-01", label: "sound.option.bipbop01" }, - { id: "bip-bop-02", label: "sound.option.bipbop02" }, - { id: "bip-bop-03", label: "sound.option.bipbop03" }, - { id: "bip-bop-04", label: "sound.option.bipbop04" }, - { id: "bip-bop-05", label: "sound.option.bipbop05" }, - { id: "bip-bop-06", label: "sound.option.bipbop06" }, - { id: "bip-bop-07", label: "sound.option.bipbop07" }, - { id: "bip-bop-08", label: "sound.option.bipbop08" }, - { id: "bip-bop-09", label: "sound.option.bipbop09" }, - { id: "bip-bop-10", label: "sound.option.bipbop10" }, - { id: "staplebops-01", label: "sound.option.staplebops01" }, - { id: "staplebops-02", label: "sound.option.staplebops02" }, - { id: "staplebops-03", label: "sound.option.staplebops03" }, - { id: "staplebops-04", label: "sound.option.staplebops04" }, - { id: "staplebops-05", label: "sound.option.staplebops05" }, - { id: "staplebops-06", label: "sound.option.staplebops06" }, - { id: "staplebops-07", label: "sound.option.staplebops07" }, - { id: "nope-01", label: "sound.option.nope01" }, - { id: "nope-02", label: "sound.option.nope02" }, - { id: "nope-03", label: "sound.option.nope03" }, - { id: "nope-04", label: "sound.option.nope04" }, - { id: "nope-05", label: "sound.option.nope05" }, - { id: "nope-06", label: "sound.option.nope06" }, - { id: "nope-07", label: "sound.option.nope07" }, - { id: "nope-08", label: "sound.option.nope08" }, - { id: "nope-09", label: "sound.option.nope09" }, - { id: "nope-10", label: "sound.option.nope10" }, - { id: "nope-11", label: "sound.option.nope11" }, - { id: "nope-12", label: "sound.option.nope12" }, - { id: "yup-01", label: "sound.option.yup01" }, - { id: "yup-02", label: "sound.option.yup02" }, - { id: "yup-03", label: "sound.option.yup03" }, - { id: "yup-04", label: "sound.option.yup04" }, - { id: "yup-05", label: "sound.option.yup05" }, - { id: "yup-06", label: "sound.option.yup06" }, + { id: "alert-01", label: "sound.option.alert01", src: alert01 }, + { id: "alert-02", label: "sound.option.alert02", src: alert02 }, + { id: "alert-03", label: "sound.option.alert03", src: alert03 }, + { id: "alert-04", label: "sound.option.alert04", src: alert04 }, + { id: "alert-05", label: "sound.option.alert05", src: alert05 }, + { id: "alert-06", label: "sound.option.alert06", src: alert06 }, + { id: "alert-07", label: "sound.option.alert07", src: alert07 }, + { id: "alert-08", label: "sound.option.alert08", src: alert08 }, + { id: "alert-09", label: "sound.option.alert09", src: alert09 }, + { id: "alert-10", label: "sound.option.alert10", src: alert10 }, + { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, + { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, + { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, + { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, + { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, + { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, + { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, + { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, + { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, + { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, + { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, + { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, + { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, + { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, + { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, + { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, + { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, + { id: "nope-01", label: "sound.option.nope01", src: nope01 }, + { id: "nope-02", label: "sound.option.nope02", src: nope02 }, + { id: "nope-03", label: "sound.option.nope03", src: nope03 }, + { id: "nope-04", label: "sound.option.nope04", src: nope04 }, + { id: "nope-05", label: "sound.option.nope05", src: nope05 }, + { id: "nope-06", label: "sound.option.nope06", src: nope06 }, + { id: "nope-07", label: "sound.option.nope07", src: nope07 }, + { id: "nope-08", label: "sound.option.nope08", src: nope08 }, + { id: "nope-09", label: "sound.option.nope09", src: nope09 }, + { id: "nope-10", label: "sound.option.nope10", src: nope10 }, + { id: "nope-11", label: "sound.option.nope11", src: nope11 }, + { id: "nope-12", label: "sound.option.nope12", src: nope12 }, + { id: "yup-01", label: "sound.option.yup01", src: yup01 }, + { id: "yup-02", label: "sound.option.yup02", src: yup02 }, + { id: "yup-03", label: "sound.option.yup03", src: yup03 }, + { id: "yup-04", label: "sound.option.yup04", src: yup04 }, + { id: "yup-05", label: "sound.option.yup05", src: yup05 }, + { id: "yup-06", label: "sound.option.yup06", src: yup06 }, ] as const export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundID = SoundOption["id"] -function getLoads() { - if (loads) return loads - loads = Object.fromEntries( - Object.entries(getFiles()).flatMap(([path, load]) => { - const file = path.split("/").at(-1) - if (!file) return [] - return [[file.replace(/\.aac$/, ""), load] as const] - }), - ) as Record Promise> - return loads -} - -const cache = new Map>() +const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record export function soundSrc(id: string | undefined) { - const loads = getLoads() - if (!id || !(id in loads)) return Promise.resolve(undefined) - const key = id as SoundID - const hit = cache.get(key) - if (hit) return hit - const next = loads[key]().catch(() => undefined) - cache.set(key, next) - return next + if (!id) return + if (!(id in soundById)) return + return soundById[id as SoundID] } export function playSound(src: string | undefined) { @@ -91,12 +108,10 @@ export function playSound(src: string | undefined) { if (!src) return const audio = new Audio(src) audio.play().catch(() => undefined) + + // Return a cleanup function to pause the sound. return () => { audio.pause() audio.currentTime = 0 } } - -export function playSoundById(id: string | undefined) { - return soundSrc(id).then((src) => playSound(src)) -} diff --git a/packages/app/vite.js b/packages/app/vite.js index f65a68a1cb..6b8fd61376 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,10 +1,7 @@ -import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" -const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) - /** * @type {import("vite").PluginOption} */ @@ -24,15 +21,6 @@ export default [ } }, }, - { - name: "opencode-desktop:theme-preload", - transformIndexHtml(html) { - return html.replace( - '', - ``, - ) - }, - }, tailwindcss(), solidPlugin(), ] diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index 44f2e6360c..ec2b4d1e7a 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,9 +6,6 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, - loadLocaleDict, - normalizeLocale, - type Locale, type Platform, PlatformProvider, ServerConnection, @@ -249,17 +246,6 @@ listenForDeepLinks() render(() => { const platform = createPlatform() - const loadLocale = async () => { - const current = await platform.storage?.("opencode.global.dat").getItem("language") - const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") - const raw = current ?? legacy - if (!raw) return - const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] - if (!locale) return - const next = normalizeLocale(locale) - if (next !== "en") await loadLocaleDict(next) - return next satisfies Locale - } const [windowCount] = createResource(() => window.api.getWindowCount()) @@ -271,7 +257,6 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) - const [locale] = createResource(loadLocale) const servers = () => { const data = sidecar() @@ -324,14 +309,15 @@ render(() => { return ( - - + + {(_) => { return ( 1} > diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 5fe88d501b..e677956440 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,9 +6,6 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, - loadLocaleDict, - normalizeLocale, - type Locale, type Platform, PlatformProvider, ServerConnection, @@ -417,17 +414,6 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() - const loadLocale = async () => { - const current = await platform.storage?.("opencode.global.dat").getItem("language") - const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") - const raw = current ?? legacy - if (!raw) return - const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] - if (!locale) return - const next = normalizeLocale(locale) - if (next !== "en") await loadLocaleDict(next) - return next satisfies Locale - } // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) @@ -437,7 +423,6 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) - const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive const servers = () => { @@ -480,8 +465,8 @@ render(() => { return ( - - + + {(_) => { return ( - `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` - export namespace Server { const log = Log.create({ service: "server" }) @@ -510,13 +506,10 @@ export namespace Server { host: "app.opencode.ai", }, }) - const match = response.headers.get("content-type")?.includes("text/html") - ? (await response.clone().text()).match( - /]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i, - ) - : undefined - const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" - response.headers.set("Content-Security-Policy", csp(hash)) + response.headers.set( + "Content-Security-Policy", + "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", + ) return response }) } diff --git a/packages/ui/package.json b/packages/ui/package.json index 7d4a39a262..cc6be2abe5 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,7 +12,6 @@ "./hooks": "./src/hooks/index.ts", "./context": "./src/context/index.ts", "./context/*": "./src/context/*.tsx", - "./font-loader": "./src/font-loader.ts", "./styles": "./src/styles/index.css", "./styles/tailwind": "./src/styles/tailwind/index.css", "./theme": "./src/theme/index.ts", diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg deleted file mode 100644 index b3a2edc3c0..0000000000 --- a/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg deleted file mode 100644 index b3a2edc3c0..0000000000 --- a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg deleted file mode 100644 index 086e9aa1fc..0000000000 --- a/packages/ui/src/assets/icons/provider/clarifai.svg +++ /dev/null @@ -1,24 +0,0 @@ - - - - - diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg deleted file mode 100644 index e045c96fb3..0000000000 --- a/packages/ui/src/assets/icons/provider/dinference.svg +++ /dev/null @@ -1 +0,0 @@ - diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg deleted file mode 100644 index 472dee9122..0000000000 --- a/packages/ui/src/assets/icons/provider/drun.svg +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg deleted file mode 100644 index a0f38862a4..0000000000 --- a/packages/ui/src/assets/icons/provider/perplexity-agent.svg +++ /dev/null @@ -1,3 +0,0 @@ - - - diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg deleted file mode 100644 index 502e51a5be..0000000000 --- a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg index 9eb8045e45..d8d9ef665f 100644 --- a/packages/ui/src/assets/icons/provider/zenmux.svg +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -1,4 +1,3 @@ - - - + + diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index e1a508f16a..f735747a49 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,9 +1,121 @@ -import { Link, Style } from "@solidjs/meta" import { Show } from "solid-js" +import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" -import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" + +import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" +import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" +import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" +import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" +import hack from "../assets/fonts/hack-nerd-font.woff2" +import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" +import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" +import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" +import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" +import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" +import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" +import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" +import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" +import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" +import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" +import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" +import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" +import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" +import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" +import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" +import iosevka from "../assets/fonts/iosevka-nerd-font.woff2" +import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2" +import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2" +import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2" + +type MonoFont = { + family: string + regular: string + bold: string +} + +export const MONO_NERD_FONTS = [ + { + family: "JetBrains Mono Nerd Font", + regular: jetbrainsMono, + bold: jetbrainsMonoBold, + }, + { + family: "Fira Code Nerd Font", + regular: firaCode, + bold: firaCodeBold, + }, + { + family: "Cascadia Code Nerd Font", + regular: cascadiaCode, + bold: cascadiaCodeBold, + }, + { + family: "Hack Nerd Font", + regular: hack, + bold: hackBold, + }, + { + family: "Source Code Pro Nerd Font", + regular: sourceCodePro, + bold: sourceCodeProBold, + }, + { + family: "Inconsolata Nerd Font", + regular: inconsolata, + bold: inconsolataBold, + }, + { + family: "Roboto Mono Nerd Font", + regular: robotoMono, + bold: robotoMonoBold, + }, + { + family: "Ubuntu Mono Nerd Font", + regular: ubuntuMono, + bold: ubuntuMonoBold, + }, + { + family: "Intel One Mono Nerd Font", + regular: intelOneMono, + bold: intelOneMonoBold, + }, + { + family: "Meslo LGS Nerd Font", + regular: mesloLgs, + bold: mesloLgsBold, + }, + { + family: "Iosevka Nerd Font", + regular: iosevka, + bold: iosevkaBold, + }, + { + family: "GeistMono Nerd Font", + regular: geistMono, + bold: geistMonoBold, + }, +] satisfies MonoFont[] + +const monoNerdCss = MONO_NERD_FONTS.map( + (font) => ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + }`, +).join("") export const Font = () => { return ( @@ -53,6 +165,7 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } +${monoNerdCss} `} diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts deleted file mode 100644 index f2b1e6be13..0000000000 --- a/packages/ui/src/font-loader.ts +++ /dev/null @@ -1,133 +0,0 @@ -type MonoFont = { - id: string - family: string - regular: string - bold: string -} - -let files: Record Promise> | undefined - -function getFiles() { - if (files) return files - files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> - return files -} - -export const MONO_NERD_FONTS = [ - { - id: "jetbrains-mono", - family: "JetBrains Mono Nerd Font", - regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", - bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", - }, - { - id: "fira-code", - family: "Fira Code Nerd Font", - regular: "./assets/fonts/fira-code-nerd-font.woff2", - bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", - }, - { - id: "cascadia-code", - family: "Cascadia Code Nerd Font", - regular: "./assets/fonts/cascadia-code-nerd-font.woff2", - bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", - }, - { - id: "hack", - family: "Hack Nerd Font", - regular: "./assets/fonts/hack-nerd-font.woff2", - bold: "./assets/fonts/hack-nerd-font-bold.woff2", - }, - { - id: "source-code-pro", - family: "Source Code Pro Nerd Font", - regular: "./assets/fonts/source-code-pro-nerd-font.woff2", - bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", - }, - { - id: "inconsolata", - family: "Inconsolata Nerd Font", - regular: "./assets/fonts/inconsolata-nerd-font.woff2", - bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", - }, - { - id: "roboto-mono", - family: "Roboto Mono Nerd Font", - regular: "./assets/fonts/roboto-mono-nerd-font.woff2", - bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", - }, - { - id: "ubuntu-mono", - family: "Ubuntu Mono Nerd Font", - regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", - bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", - }, - { - id: "intel-one-mono", - family: "Intel One Mono Nerd Font", - regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", - bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", - }, - { - id: "meslo-lgs", - family: "Meslo LGS Nerd Font", - regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", - bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", - }, - { - id: "iosevka", - family: "Iosevka Nerd Font", - regular: "./assets/fonts/iosevka-nerd-font.woff2", - bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", - }, - { - id: "geist-mono", - family: "GeistMono Nerd Font", - regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", - bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", - }, -] satisfies MonoFont[] - -const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record -const loads = new Map>() - -function css(font: { family: string; regular: string; bold: string }) { - return ` - @font-face { - font-family: "${font.family}"; - src: url("${font.regular}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "${font.family}"; - src: url("${font.bold}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; - } - ` -} - -export function ensureMonoFont(id: string | undefined) { - if (!id || id === "ibm-plex-mono") return Promise.resolve() - if (typeof document !== "object") return Promise.resolve() - const font = mono[id] - if (!font) return Promise.resolve() - const styleId = `oc-font-${font.id}` - if (document.getElementById(styleId)) return Promise.resolve() - const hit = loads.get(font.id) - if (hit) return hit - const files = getFiles() - const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { - if (!regular || !bold) return - if (document.getElementById(styleId)) return - const style = document.createElement("style") - style.id = styleId - style.textContent = css({ family: font.family, regular, bold }) - document.head.appendChild(style) - }) - loads.set(font.id, load) - return load -} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 7d25ac3972..9808c8e841 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import oc2ThemeJson from "./themes/oc-2.json" +import { DEFAULT_THEMES } from "./default-themes" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,101 +15,14 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" -let files: Record Promise<{ default: DesktopTheme }>> | undefined -let ids: string[] | undefined -let known: Set | undefined - -function getFiles() { - if (files) return files - files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") - return files -} - -function themeIDs() { - if (ids) return ids - ids = Object.keys(getFiles()) - .map((path) => path.slice("./themes/".length, -".json".length)) - .sort() - return ids -} - -function knownThemes() { - if (known) return known - known = new Set(themeIDs()) - return known -} - -const names: Record = { - "oc-2": "OC-2", - amoled: "AMOLED", - aura: "Aura", - ayu: "Ayu", - carbonfox: "Carbonfox", - catppuccin: "Catppuccin", - "catppuccin-frappe": "Catppuccin Frappe", - "catppuccin-macchiato": "Catppuccin Macchiato", - cobalt2: "Cobalt2", - cursor: "Cursor", - dracula: "Dracula", - everforest: "Everforest", - flexoki: "Flexoki", - github: "GitHub", - gruvbox: "Gruvbox", - kanagawa: "Kanagawa", - "lucent-orng": "Lucent Orng", - material: "Material", - matrix: "Matrix", - mercury: "Mercury", - monokai: "Monokai", - nightowl: "Night Owl", - nord: "Nord", - "one-dark": "One Dark", - onedarkpro: "One Dark Pro", - opencode: "OpenCode", - orng: "Orng", - "osaka-jade": "Osaka Jade", - palenight: "Palenight", - rosepine: "Rose Pine", - shadesofpurple: "Shades of Purple", - solarized: "Solarized", - synthwave84: "Synthwave '84", - tokyonight: "Tokyonight", - vercel: "Vercel", - vesper: "Vesper", - zenburn: "Zenburn", -} -const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } -function read(key: string) { - if (typeof localStorage !== "object") return null - try { - return localStorage.getItem(key) - } catch { - return null - } -} - -function write(key: string, value: string) { - if (typeof localStorage !== "object") return - try { - localStorage.setItem(key, value) - } catch {} -} - -function drop(key: string) { - if (typeof localStorage !== "object") return - try { - localStorage.removeItem(key) - } catch {} -} - function clear() { - drop(STORAGE_KEYS.THEME_CSS_LIGHT) - drop(STORAGE_KEYS.THEME_CSS_DARK) + localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) + localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -122,7 +35,6 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { - if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -133,7 +45,9 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + try { + localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + } catch {} } const fullCss = `:root { @@ -155,122 +69,74 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + try { + localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) + } catch {} } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { - const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" - const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" - const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: { - "oc-2": oc2Theme, - } as Record, - themeId, - colorScheme, - mode, + themes: DEFAULT_THEMES as Record, + themeId: normalize(props.defaultTheme) ?? "oc-2", + colorScheme: "system" as ColorScheme, + mode: getSystemMode(), previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) - const loads = new Map>() + window.addEventListener("storage", (e) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) + } + }) - const load = (id: string) => { - const next = normalize(id) - if (!next) return Promise.resolve(undefined) - const hit = store.themes[next] - if (hit) return Promise.resolve(hit) - const pending = loads.get(next) - if (pending) return pending - const file = getFiles()[`./themes/${next}.json`] - if (!file) return Promise.resolve(undefined) - const task = file() - .then((mod) => { - const theme = mod.default - setStore("themes", next, theme) - return theme - }) - .finally(() => { - loads.delete(next) - }) - loads.set(next, task) - return task - } + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const handler = () => { + if (store.colorScheme === "system") { + setStore("mode", getSystemMode()) + } + } + mediaQuery.addEventListener("change", handler) + onCleanup(() => mediaQuery.removeEventListener("change", handler)) + + const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) + const themeId = normalize(savedTheme) + const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null + if (themeId && store.themes[themeId]) { + setStore("themeId", themeId) + } + if (savedTheme && themeId && savedTheme !== themeId) { + localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) + clear() + } + if (savedScheme) { + setStore("colorScheme", savedScheme) + if (savedScheme !== "system") { + setStore("mode", savedScheme) + } + } + const currentTheme = store.themes[store.themeId] + if (currentTheme) { + cacheThemeVariants(currentTheme, store.themeId) + } + }) const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } - const ids = () => { - const extra = Object.keys(store.themes) - .filter((id) => !knownThemes().has(id)) - .sort() - const all = themeIDs() - if (extra.length === 0) return all - return [...all, ...extra] - } - - const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) - - const onStorage = (e: StorageEvent) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { - const next = normalize(e.newValue) - if (!next) return - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return - setStore("themeId", next) - if (next === "oc-2") { - clear() - return - } - void load(next).then((theme) => { - if (!theme || store.themeId !== next) return - cacheThemeVariants(theme, next) - }) - } - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) - } - } - - if (typeof window === "object") { - window.addEventListener("storage", onStorage) - onCleanup(() => window.removeEventListener("storage", onStorage)) - } - - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const onMedia = () => { - if (store.colorScheme !== "system") return - setStore("mode", getSystemMode()) - } - mediaQuery.addEventListener("change", onMedia) - onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) - - const rawTheme = read(STORAGE_KEYS.THEME_ID) - const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" - const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" - if (rawTheme && rawTheme !== savedTheme) { - write(STORAGE_KEYS.THEME_ID, savedTheme) - clear() - } - if (savedTheme !== store.themeId) setStore("themeId", savedTheme) - if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) - setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) - void load(savedTheme).then((theme) => { - if (!theme || store.themeId !== savedTheme) return - cacheThemeVariants(theme, savedTheme) - }) - }) - createEffect(() => { const theme = store.themes[store.themeId] - if (!theme) return - applyTheme(theme, store.themeId, store.mode) + if (theme) { + applyTheme(theme, store.themeId, store.mode) + } }) const setTheme = (id: string) => { @@ -279,26 +145,23 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { + const theme = store.themes[next] + if (!theme) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) + localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { - write(STORAGE_KEYS.THEME_ID, next) clear() return } - void load(next).then((theme) => { - if (!theme || store.themeId !== next) return - cacheThemeVariants(theme, next) - write(STORAGE_KEYS.THEME_ID, next) - }) + cacheThemeVariants(theme, next) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - write(STORAGE_KEYS.COLOR_SCHEME, scheme) + localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -306,9 +169,6 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, - ids, - name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, - loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -316,28 +176,24 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + const theme = store.themes[next] + if (!theme) return setStore("previewThemeId", next) - void load(next).then((theme) => { - if (!theme || store.previewThemeId !== next) return - const mode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, mode) - }) + const previewMode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, previewMode) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const mode = scheme === "system" ? getSystemMode() : scheme + const previewMode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - void load(id).then((theme) => { - if (!theme) return - if ((store.previewThemeId ?? store.themeId) !== id) return - if (store.previewScheme !== scheme) return - applyTheme(theme, id, mode) - }) + const theme = store.themes[id] + if (theme) { + applyTheme(theme, id, previewMode) + } }, commitPreview: () => { if (store.previewThemeId) { @@ -352,10 +208,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - void load(store.themeId).then((theme) => { - if (!theme) return + const theme = store.themes[store.themeId] + if (theme) { applyTheme(theme, store.themeId, store.mode) - }) + } }, } }, From 1041ae91d1a39401fe099747e3bc093bdcdaa079 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:25:57 -0500 Subject: [PATCH 19/36] Reapply "fix(app): startup efficiency" This reverts commit 898456a25cf2edbfc4ae4961b37424f633419dd6. --- packages/app/src/app.tsx | 8 +- .../components/dialog-connect-provider.tsx | 50 ++- packages/app/src/components/prompt-input.tsx | 1 + .../app/src/components/settings-general.tsx | 50 ++- .../app/src/components/status-popover.tsx | 23 +- packages/app/src/components/terminal.tsx | 5 +- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/context/global-sync.tsx | 58 ++- .../app/src/context/global-sync/bootstrap.ts | 339 +++++++++++------- packages/app/src/context/language.tsx | 144 ++++---- packages/app/src/context/notification.tsx | 6 +- packages/app/src/context/settings.tsx | 13 +- packages/app/src/context/sync.tsx | 7 +- packages/app/src/context/terminal-title.ts | 51 +-- packages/app/src/entry.tsx | 11 +- packages/app/src/hooks/use-providers.ts | 2 +- packages/app/src/index.ts | 1 + packages/app/src/pages/directory-layout.tsx | 70 ++-- packages/app/src/pages/home.tsx | 8 + packages/app/src/pages/layout.tsx | 62 ++-- packages/app/src/pages/session.tsx | 7 +- .../pages/session/use-session-hash-scroll.ts | 18 + packages/app/src/utils/server-health.ts | 24 +- packages/app/src/utils/sound.ts | 177 +++++---- packages/app/vite.js | 12 + .../desktop-electron/src/renderer/index.tsx | 20 +- packages/desktop/src/index.tsx | 19 +- packages/opencode/src/server/server.ts | 15 +- packages/ui/package.json | 1 + .../icons/provider/alibaba-coding-plan-cn.svg | 3 + .../icons/provider/alibaba-coding-plan.svg | 3 + .../ui/src/assets/icons/provider/clarifai.svg | 24 ++ .../src/assets/icons/provider/dinference.svg | 1 + .../ui/src/assets/icons/provider/drun.svg | 8 + .../icons/provider/perplexity-agent.svg | 3 + .../icons/provider/tencent-coding-plan.svg | 5 + .../ui/src/assets/icons/provider/zenmux.svg | 5 +- packages/ui/src/components/font.tsx | 119 +----- packages/ui/src/font-loader.ts | 133 +++++++ packages/ui/src/theme/context.tsx | 294 +++++++++++---- 40 files changed, 1114 insertions(+), 688 deletions(-) create mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg create mode 100644 packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg create mode 100644 packages/ui/src/assets/icons/provider/clarifai.svg create mode 100644 packages/ui/src/assets/icons/provider/dinference.svg create mode 100644 packages/ui/src/assets/icons/provider/drun.svg create mode 100644 packages/ui/src/assets/icons/provider/perplexity-agent.svg create mode 100644 packages/ui/src/assets/icons/provider/tencent-coding-plan.svg create mode 100644 packages/ui/src/font-loader.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 5247c951d3..0eb5b4e9e0 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -6,7 +6,7 @@ import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { File } from "@opencode-ai/ui/file" import { Font } from "@opencode-ai/ui/font" import { Splash } from "@opencode-ai/ui/logo" -import { ThemeProvider } from "@opencode-ai/ui/theme" +import { ThemeProvider } from "@opencode-ai/ui/theme/context" import { MetaProvider } from "@solidjs/meta" import { type BaseRouterProps, Navigate, Route, Router } from "@solidjs/router" import { QueryClient, QueryClientProvider } from "@tanstack/solid-query" @@ -32,7 +32,7 @@ import { FileProvider } from "@/context/file" import { GlobalSDKProvider } from "@/context/global-sdk" import { GlobalSyncProvider } from "@/context/global-sync" import { HighlightsProvider } from "@/context/highlights" -import { LanguageProvider, useLanguage } from "@/context/language" +import { LanguageProvider, type Locale, useLanguage } from "@/context/language" import { LayoutProvider } from "@/context/layout" import { ModelsProvider } from "@/context/models" import { NotificationProvider } from "@/context/notification" @@ -130,7 +130,7 @@ function RouterRoot(props: ParentProps<{ appChildren?: JSX.Element }>) { ) } -export function AppBaseProviders(props: ParentProps) { +export function AppBaseProviders(props: ParentProps<{ locale?: Locale }>) { return ( @@ -139,7 +139,7 @@ export function AppBaseProviders(props: ParentProps) { void window.api?.setTitlebar?.({ mode }) }} > - + }> diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 734958dd58..e7eaa1fb29 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,4 @@ -import type { ProviderAuthAuthorization } from "@opencode-ai/sdk/v2/client" +import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -9,7 +9,7 @@ import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { Spinner } from "@opencode-ai/ui/spinner" import { TextField } from "@opencode-ai/ui/text-field" import { showToast } from "@opencode-ai/ui/toast" -import { createMemo, Match, onCleanup, onMount, Switch } from "solid-js" +import { createEffect, createMemo, createResource, Match, onCleanup, onMount, Switch } from "solid-js" import { createStore, produce } from "solid-js/store" import { Link } from "@/components/link" import { useGlobalSDK } from "@/context/global-sdk" @@ -34,15 +34,25 @@ export function DialogConnectProvider(props: { provider: string }) { }) const provider = createMemo(() => globalSync.data.provider.all.find((x) => x.id === props.provider)!) - const methods = createMemo( - () => - globalSync.data.provider_auth[props.provider] ?? [ - { - type: "api", - label: language.t("provider.connect.method.apiKey"), - }, - ], + const fallback = createMemo(() => [ + { + type: "api" as const, + label: language.t("provider.connect.method.apiKey"), + }, + ]) + const [auth] = createResource( + () => props.provider, + async () => { + const cached = globalSync.data.provider_auth[props.provider] + if (cached) return cached + const res = await globalSDK.client.provider.auth() + if (!alive.value) return fallback() + globalSync.set("provider_auth", res.data ?? {}) + return res.data?.[props.provider] ?? fallback() + }, ) + const loading = createMemo(() => auth.loading && !globalSync.data.provider_auth[props.provider]) + const methods = createMemo(() => auth.latest ?? globalSync.data.provider_auth[props.provider] ?? fallback()) const [store, setStore] = createStore({ methodIndex: undefined as undefined | number, authorization: undefined as undefined | ProviderAuthAuthorization, @@ -177,7 +187,11 @@ export function DialogConnectProvider(props: { provider: string }) { index: 0, }) - const prompts = createMemo(() => method()?.prompts ?? []) + const prompts = createMemo>(() => { + const value = method() + if (value?.type !== "oauth") return [] + return value.prompts ?? [] + }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true const actual = value[prompt.when.key] @@ -296,8 +310,12 @@ export function DialogConnectProvider(props: { provider: string }) { listRef?.onKeyDown(e) } - onMount(() => { + let auto = false + createEffect(() => { + if (auto) return + if (loading()) return if (methods().length === 1) { + auto = true selectMethod(0) } }) @@ -573,6 +591,14 @@ export function DialogConnectProvider(props: { provider: string }) {
+ +
+
+ + {language.t("provider.connect.status.inProgress")} +
+
+
diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index f523671ec9..ee98e68cd5 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -572,6 +572,7 @@ export const PromptInput: Component = (props) => { const open = recent() const seen = new Set(open) const pinned: AtOption[] = open.map((path) => ({ type: "file", path, display: path, recent: true })) + if (!query.trim()) return [...agents, ...pinned] const paths = await files.searchFilesAndDirectories(query) const fileOptions: AtOption[] = paths .filter((path) => !seen.has(path)) diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index b768bafcca..f4b8198e7e 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -1,27 +1,41 @@ -import { Component, Show, createMemo, createResource, type JSX } from "solid-js" +import { Component, Show, createMemo, createResource, onMount, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { Select } from "@opencode-ai/ui/select" import { Switch } from "@opencode-ai/ui/switch" import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useSettings, monoFontFamily } from "@/context/settings" -import { playSound, SOUND_OPTIONS } from "@/utils/sound" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" import { Link } from "./link" import { SettingsList } from "./settings-list" let demoSoundState = { cleanup: undefined as (() => void) | undefined, timeout: undefined as NodeJS.Timeout | undefined, + run: 0, +} + +type ThemeOption = { + id: string + name: string +} + +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font } // To prevent audio from overlapping/playing very quickly when navigating the settings menus, // delay the playback by 100ms during quick selection changes and pause existing sounds. const stopDemoSound = () => { + demoSoundState.run += 1 if (demoSoundState.cleanup) { demoSoundState.cleanup() } @@ -29,12 +43,19 @@ const stopDemoSound = () => { demoSoundState.cleanup = undefined } -const playDemoSound = (src: string | undefined) => { +const playDemoSound = (id: string | undefined) => { stopDemoSound() - if (!src) return + if (!id) return + const run = ++demoSoundState.run demoSoundState.timeout = setTimeout(() => { - demoSoundState.cleanup = playSound(src) + void playSoundById(id).then((cleanup) => { + if (demoSoundState.run !== run) { + cleanup?.() + return + } + demoSoundState.cleanup = cleanup + }) }, 100) } @@ -44,6 +65,10 @@ export const SettingsGeneral: Component = () => { const platform = usePlatform() const settings = useSettings() + onMount(() => { + void theme.loadThemes() + }) + const [store, setStore] = createStore({ checking: false, }) @@ -104,9 +129,7 @@ export const SettingsGeneral: Component = () => { .finally(() => setStore("checking", false)) } - const themeOptions = createMemo(() => - Object.entries(theme.themes()).map(([id, def]) => ({ id, name: def.name ?? id })), - ) + const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ { value: "system", label: language.t("theme.scheme.system") }, @@ -143,7 +166,7 @@ export const SettingsGeneral: Component = () => { ] as const const fontOptionsList = [...fontOptions] - const noneSound = { id: "none", label: "sound.option.none", src: undefined } as const + const noneSound = { id: "none", label: "sound.option.none" } as const const soundOptions = [noneSound, ...SOUND_OPTIONS] const soundSelectProps = ( @@ -158,7 +181,7 @@ export const SettingsGeneral: Component = () => { label: (o: (typeof soundOptions)[number]) => language.t(o.label), onHighlight: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return - playDemoSound(option.src) + playDemoSound(option.id === "none" ? undefined : option.id) }, onSelect: (option: (typeof soundOptions)[number] | undefined) => { if (!option) return @@ -169,7 +192,7 @@ export const SettingsGeneral: Component = () => { } setEnabled(true) set(option.id) - playDemoSound(option.src) + playDemoSound(option.id) }, variant: "secondary" as const, size: "small" as const, @@ -321,6 +344,9 @@ export const SettingsGeneral: Component = () => { current={fontOptionsList.find((o) => o.value === settings.appearance.font())} value={(o) => o.value} label={(o) => language.t(o.label)} + onHighlight={(option) => { + void loadFont().then((x) => x.ensureMonoFont(option?.value)) + }} onSelect={(option) => option && settings.appearance.setFont(option.value)} variant="secondary" size="small" diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index 464522443f..8d5ecac39a 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -16,7 +16,6 @@ import { useSDK } from "@/context/sdk" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" import { useSync } from "@/context/sync" import { useCheckServerHealth, type ServerHealth } from "@/utils/server-health" -import { DialogSelectServer } from "./dialog-select-server" const pollMs = 10_000 @@ -54,11 +53,15 @@ const listServersByHealth = ( }) } -const useServerHealth = (servers: Accessor) => { +const useServerHealth = (servers: Accessor, enabled: Accessor) => { const checkServerHealth = useCheckServerHealth() const [status, setStatus] = createStore({} as Record) createEffect(() => { + if (!enabled()) { + setStatus(reconcile({})) + return + } const list = servers() let dead = false @@ -162,6 +165,12 @@ export function StatusPopover() { const navigate = useNavigate() const [shown, setShown] = createSignal(false) + let dialogRun = 0 + let dialogDead = false + onCleanup(() => { + dialogDead = true + dialogRun += 1 + }) const servers = createMemo(() => { const current = server.current const list = server.list @@ -169,7 +178,7 @@ export function StatusPopover() { if (list.every((item) => ServerConnection.key(item) !== ServerConnection.key(current))) return [current, ...list] return [current, ...list.filter((item) => ServerConnection.key(item) !== ServerConnection.key(current))] }) - const health = useServerHealth(servers) + const health = useServerHealth(servers, shown) const sortedServers = createMemo(() => listServersByHealth(servers(), server.key, health)) const toggleMcp = useMcpToggleMutation() const defaultServer = useDefaultServerKey(platform.getDefaultServer) @@ -300,7 +309,13 @@ export function StatusPopover() { diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index aed46f1262..0a5a7d2d3e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -1,4 +1,7 @@ -import { type HexColor, resolveThemeVariant, useTheme, withAlpha } from "@opencode-ai/ui/theme" +import { withAlpha } from "@opencode-ai/ui/theme/color" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { resolveThemeVariant } from "@opencode-ai/ui/theme/resolve" +import type { HexColor } from "@opencode-ai/ui/theme/types" import { showToast } from "@opencode-ai/ui/toast" import type { FitAddon, Ghostty, Terminal as Term } from "ghostty-web" import { type ComponentProps, createEffect, createMemo, onCleanup, onMount, splitProps } from "solid-js" diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 77de1a73ce..0a41f31196 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -5,7 +5,7 @@ import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" -import { useTheme } from "@opencode-ai/ui/theme" +import { useTheme } from "@opencode-ai/ui/theme/context" import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 2d1e501353..cbd08e99f5 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -9,17 +9,7 @@ import type { } from "@opencode-ai/sdk/v2/client" import { showToast } from "@opencode-ai/ui/toast" import { getFilename } from "@opencode-ai/util/path" -import { - createContext, - getOwner, - Match, - onCleanup, - onMount, - type ParentProps, - Switch, - untrack, - useContext, -} from "solid-js" +import { createContext, getOwner, onCleanup, onMount, type ParentProps, untrack, useContext } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" import { useLanguage } from "@/context/language" import { Persist, persisted } from "@/utils/persist" @@ -80,6 +70,8 @@ function createGlobalSync() { let active = true let projectWritten = false + let bootedAt = 0 + let bootingRoot = false onCleanup(() => { active = false @@ -258,6 +250,11 @@ function createGlobalSync() { const sdk = sdkFor(directory) await bootstrapDirectory({ directory, + global: { + config: globalStore.config, + project: globalStore.project, + provider: globalStore.provider, + }, sdk, store: child[0], setStore: child[1], @@ -278,15 +275,20 @@ function createGlobalSync() { const unsub = globalSDK.event.listen((e) => { const directory = e.name const event = e.details + const recent = bootingRoot || Date.now() - bootedAt < 1500 if (directory === "global") { applyGlobalEvent({ event, project: globalStore.project, - refresh: queue.refresh, + refresh: () => { + if (recent) return + queue.refresh() + }, setGlobalProject: setProjects, }) if (event.type === "server.connected" || event.type === "global.disposed") { + if (recent) return for (const directory of Object.keys(children.children)) { queue.push(directory) } @@ -325,17 +327,19 @@ function createGlobalSync() { }) async function bootstrap() { - await bootstrapGlobal({ - globalSDK: globalSDK.client, - connectErrorTitle: language.t("dialog.server.add.error"), - connectErrorDescription: language.t("error.globalSync.connectFailed", { - url: globalSDK.url, - }), - requestFailedTitle: language.t("common.requestFailed"), - translate: language.t, - formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), - setGlobalStore: setBootStore, - }) + bootingRoot = true + try { + await bootstrapGlobal({ + globalSDK: globalSDK.client, + requestFailedTitle: language.t("common.requestFailed"), + translate: language.t, + formatMoreCount: (count) => language.t("common.moreCountSuffix", { count }), + setGlobalStore: setBootStore, + }) + bootedAt = Date.now() + } finally { + bootingRoot = false + } } onMount(() => { @@ -392,13 +396,7 @@ const GlobalSyncContext = createContext>() export function GlobalSyncProvider(props: ParentProps) { const value = createGlobalSync() - return ( - - - {props.children} - - - ) + return {props.children} } export function useGlobalSync() { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 13494b7ade..47be3abcb3 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -31,73 +31,102 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } +function waitForPaint() { + return new Promise((resolve) => { + let done = false + const finish = () => { + if (done) return + done = true + resolve() + } + const timer = setTimeout(finish, 50) + if (typeof requestAnimationFrame !== "function") return + requestAnimationFrame(() => { + clearTimeout(timer) + finish() + }) + }) +} + +function errors(list: PromiseSettledResult[]) { + return list.filter((item): item is PromiseRejectedResult => item.status === "rejected").map((item) => item.reason) +} + +function runAll(list: Array<() => Promise>) { + return Promise.allSettled(list.map((item) => item())) +} + +function showErrors(input: { + errors: unknown[] + title: string + translate: (key: string, vars?: Record) => string + formatMoreCount: (count: number) => string +}) { + if (input.errors.length === 0) return + const message = formatServerError(input.errors[0], input.translate) + const more = input.errors.length > 1 ? input.formatMoreCount(input.errors.length - 1) : "" + showToast({ + variant: "error", + title: input.title, + description: message + more, + }) +} + export async function bootstrapGlobal(input: { globalSDK: OpencodeClient - connectErrorTitle: string - connectErrorDescription: string requestFailedTitle: string translate: (key: string, vars?: Record) => string formatMoreCount: (count: number) => string setGlobalStore: SetStoreFunction }) { - const health = await input.globalSDK.global - .health() - .then((x) => x.data) - .catch(() => undefined) - if (!health?.healthy) { - showToast({ - variant: "error", - title: input.connectErrorTitle, - description: input.connectErrorDescription, - }) - input.setGlobalStore("ready", true) - return - } - - const tasks = [ - retry(() => - input.globalSDK.path.get().then((x) => { - input.setGlobalStore("path", x.data!) - }), - ), - retry(() => - input.globalSDK.global.config.get().then((x) => { - input.setGlobalStore("config", x.data!) - }), - ), - retry(() => - input.globalSDK.project.list().then((x) => { - const projects = (x.data ?? []) - .filter((p) => !!p?.id) - .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) - .slice() - .sort((a, b) => cmp(a.id, b.id)) - input.setGlobalStore("project", projects) - }), - ), - retry(() => - input.globalSDK.provider.list().then((x) => { - input.setGlobalStore("provider", normalizeProviderList(x.data!)) - }), - ), - retry(() => - input.globalSDK.provider.auth().then((x) => { - input.setGlobalStore("provider_auth", x.data ?? {}) - }), - ), + const fast = [ + () => + retry(() => + input.globalSDK.path.get().then((x) => { + input.setGlobalStore("path", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.global.config.get().then((x) => { + input.setGlobalStore("config", x.data!) + }), + ), + () => + retry(() => + input.globalSDK.provider.list().then((x) => { + input.setGlobalStore("provider", normalizeProviderList(x.data!)) + }), + ), ] - const results = await Promise.allSettled(tasks) - const errors = results.filter((r): r is PromiseRejectedResult => r.status === "rejected").map((r) => r.reason) - if (errors.length) { - const message = formatServerError(errors[0], input.translate) - const more = errors.length > 1 ? input.formatMoreCount(errors.length - 1) : "" - showToast({ - variant: "error", - title: input.requestFailedTitle, - description: message + more, - }) - } + const slow = [ + () => + retry(() => + input.globalSDK.project.list().then((x) => { + const projects = (x.data ?? []) + .filter((p) => !!p?.id) + .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .slice() + .sort((a, b) => cmp(a.id, b.id)) + input.setGlobalStore("project", projects) + }), + ), + ] + + showErrors({ + errors: errors(await runAll(fast)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) + await waitForPaint() + showErrors({ + errors: errors(await runAll(slow)), + title: input.requestFailedTitle, + translate: input.translate, + formatMoreCount: input.formatMoreCount, + }) input.setGlobalStore("ready", true) } @@ -111,6 +140,10 @@ function groupBySession(input: T[]) }, {}) } +function projectID(directory: string, projects: Project[]) { + return projects.find((project) => project.worktree === directory || project.sandboxes?.includes(directory))?.id +} + export async function bootstrapDirectory(input: { directory: string sdk: OpencodeClient @@ -119,88 +152,130 @@ export async function bootstrapDirectory(input: { vcsCache: VcsCache loadSessions: (directory: string) => Promise | void translate: (key: string, vars?: Record) => string -}) { - if (input.store.status !== "complete") input.setStore("status", "loading") - - const blockingRequests = { - project: () => input.sdk.project.current().then((x) => input.setStore("project", x.data!.id)), - provider: () => - input.sdk.provider.list().then((x) => { - input.setStore("provider", normalizeProviderList(x.data!)) - }), - agent: () => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? [])), - config: () => input.sdk.config.get().then((x) => input.setStore("config", x.data!)), + global: { + config: Config + project: Project[] + provider: ProviderListResponse } +}) { + const loading = input.store.status !== "complete" + const seededProject = projectID(input.directory, input.global.project) + if (seededProject) input.setStore("project", seededProject) + if (input.store.provider.all.length === 0 && input.global.provider.all.length > 0) { + input.setStore("provider", input.global.provider) + } + if (Object.keys(input.store.config).length === 0 && Object.keys(input.global.config).length > 0) { + input.setStore("config", input.global.config) + } + if (loading) input.setStore("status", "partial") - try { - await Promise.all(Object.values(blockingRequests).map((p) => retry(p))) - } catch (err) { - console.error("Failed to bootstrap instance", err) + const fast = [ + () => + seededProject + ? Promise.resolve() + : retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id)), + () => retry(() => input.sdk.app.agents().then((x) => input.setStore("agent", x.data ?? []))), + () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", x.data!))), + () => + retry(() => + input.sdk.path.get().then((x) => { + input.setStore("path", x.data!) + const next = projectID(x.data?.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + }), + ), + () => retry(() => input.sdk.session.status().then((x) => input.setStore("session_status", x.data!))), + () => + retry(() => + input.sdk.vcs.get().then((x) => { + const next = x.data ?? input.store.vcs + input.setStore("vcs", next) + if (next?.branch) input.vcsCache.setStore("value", next) + }), + ), + () => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? []))), + () => + retry(() => + input.sdk.permission.list().then((x) => { + const grouped = groupBySession( + (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + ) + batch(() => { + for (const sessionID of Object.keys(input.store.permission)) { + if (grouped[sessionID]) continue + input.setStore("permission", sessionID, []) + } + for (const [sessionID, permissions] of Object.entries(grouped)) { + input.setStore( + "permission", + sessionID, + reconcile( + permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + () => + retry(() => + input.sdk.question.list().then((x) => { + const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + batch(() => { + for (const sessionID of Object.keys(input.store.question)) { + if (grouped[sessionID]) continue + input.setStore("question", sessionID, []) + } + for (const [sessionID, questions] of Object.entries(grouped)) { + input.setStore( + "question", + sessionID, + reconcile( + questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), + { key: "id" }, + ), + ) + } + }) + }), + ), + ] + + const slow = [ + () => + retry(() => + input.sdk.provider.list().then((x) => { + input.setStore("provider", normalizeProviderList(x.data!)) + }), + ), + () => Promise.resolve(input.loadSessions(input.directory)), + () => retry(() => input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!))), + () => retry(() => input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!))), + ] + + const errs = errors(await runAll(fast)) + if (errs.length > 0) { + console.error("Failed to bootstrap instance", errs[0]) const project = getFilename(input.directory) showToast({ variant: "error", title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), + description: formatServerError(errs[0], input.translate), }) - input.setStore("status", "partial") - return } - if (input.store.status !== "complete") input.setStore("status", "partial") + await waitForPaint() + const slowErrs = errors(await runAll(slow)) + if (slowErrs.length > 0) { + console.error("Failed to finish bootstrap instance", slowErrs[0]) + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(slowErrs[0], input.translate), + }) + } - Promise.all([ - input.sdk.path.get().then((x) => input.setStore("path", x.data!)), - input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])), - input.sdk.session.status().then((x) => input.setStore("session_status", x.data!)), - input.loadSessions(input.directory), - input.sdk.mcp.status().then((x) => input.setStore("mcp", x.data!)), - input.sdk.lsp.status().then((x) => input.setStore("lsp", x.data!)), - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs - input.setStore("vcs", next) - if (next?.branch) input.vcsCache.setStore("value", next) - }), - input.sdk.permission.list().then((x) => { - const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), - ) - batch(() => { - for (const sessionID of Object.keys(input.store.permission)) { - if (grouped[sessionID]) continue - input.setStore("permission", sessionID, []) - } - for (const [sessionID, permissions] of Object.entries(grouped)) { - input.setStore( - "permission", - sessionID, - reconcile( - permissions.filter((p) => !!p?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - input.sdk.question.list().then((x) => { - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) - batch(() => { - for (const sessionID of Object.keys(input.store.question)) { - if (grouped[sessionID]) continue - input.setStore("question", sessionID, []) - } - for (const [sessionID, questions] of Object.entries(grouped)) { - input.setStore( - "question", - sessionID, - reconcile( - questions.filter((q) => !!q?.id).sort((a, b) => cmp(a.id, b.id)), - { key: "id" }, - ), - ) - } - }) - }), - ]).then(() => { - input.setStore("status", "complete") - }) + if (loading && errs.length === 0 && slowErrs.length === 0) input.setStore("status", "complete") } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index b1edd541c3..51dc09cd7d 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,42 +1,10 @@ import * as i18n from "@solid-primitives/i18n" -import { createEffect, createMemo } from "solid-js" +import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" -import { dict as ko } from "@/i18n/ko" -import { dict as de } from "@/i18n/de" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as da } from "@/i18n/da" -import { dict as ja } from "@/i18n/ja" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as ar } from "@/i18n/ar" -import { dict as no } from "@/i18n/no" -import { dict as br } from "@/i18n/br" -import { dict as th } from "@/i18n/th" -import { dict as bs } from "@/i18n/bs" -import { dict as tr } from "@/i18n/tr" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" -import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" -import { dict as uiZht } from "@opencode-ai/ui/i18n/zht" -import { dict as uiKo } from "@opencode-ai/ui/i18n/ko" -import { dict as uiDe } from "@opencode-ai/ui/i18n/de" -import { dict as uiEs } from "@opencode-ai/ui/i18n/es" -import { dict as uiFr } from "@opencode-ai/ui/i18n/fr" -import { dict as uiDa } from "@opencode-ai/ui/i18n/da" -import { dict as uiJa } from "@opencode-ai/ui/i18n/ja" -import { dict as uiPl } from "@opencode-ai/ui/i18n/pl" -import { dict as uiRu } from "@opencode-ai/ui/i18n/ru" -import { dict as uiAr } from "@opencode-ai/ui/i18n/ar" -import { dict as uiNo } from "@opencode-ai/ui/i18n/no" -import { dict as uiBr } from "@opencode-ai/ui/i18n/br" -import { dict as uiTh } from "@opencode-ai/ui/i18n/th" -import { dict as uiBs } from "@opencode-ai/ui/i18n/bs" -import { dict as uiTr } from "@opencode-ai/ui/i18n/tr" export type Locale = | "en" @@ -59,6 +27,7 @@ export type Locale = type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten +type Source = { dict: Record } function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` @@ -125,24 +94,43 @@ const LABEL_KEY: Record = { } const base = i18n.flatten({ ...en, ...uiEn }) -const DICT: Record = { - en: base, - zh: { ...base, ...i18n.flatten({ ...zh, ...uiZh }) }, - zht: { ...base, ...i18n.flatten({ ...zht, ...uiZht }) }, - ko: { ...base, ...i18n.flatten({ ...ko, ...uiKo }) }, - de: { ...base, ...i18n.flatten({ ...de, ...uiDe }) }, - es: { ...base, ...i18n.flatten({ ...es, ...uiEs }) }, - fr: { ...base, ...i18n.flatten({ ...fr, ...uiFr }) }, - da: { ...base, ...i18n.flatten({ ...da, ...uiDa }) }, - ja: { ...base, ...i18n.flatten({ ...ja, ...uiJa }) }, - pl: { ...base, ...i18n.flatten({ ...pl, ...uiPl }) }, - ru: { ...base, ...i18n.flatten({ ...ru, ...uiRu }) }, - ar: { ...base, ...i18n.flatten({ ...ar, ...uiAr }) }, - no: { ...base, ...i18n.flatten({ ...no, ...uiNo }) }, - br: { ...base, ...i18n.flatten({ ...br, ...uiBr }) }, - th: { ...base, ...i18n.flatten({ ...th, ...uiTh }) }, - bs: { ...base, ...i18n.flatten({ ...bs, ...uiBs }) }, - tr: { ...base, ...i18n.flatten({ ...tr, ...uiTr }) }, +const dicts = new Map([["en", base]]) + +const merge = (app: Promise, ui: Promise) => + Promise.all([app, ui]).then(([a, b]) => ({ ...base, ...i18n.flatten({ ...a.dict, ...b.dict }) }) as Dictionary) + +const loaders: Record, () => Promise> = { + zh: () => merge(import("@/i18n/zh"), import("@opencode-ai/ui/i18n/zh")), + zht: () => merge(import("@/i18n/zht"), import("@opencode-ai/ui/i18n/zht")), + ko: () => merge(import("@/i18n/ko"), import("@opencode-ai/ui/i18n/ko")), + de: () => merge(import("@/i18n/de"), import("@opencode-ai/ui/i18n/de")), + es: () => merge(import("@/i18n/es"), import("@opencode-ai/ui/i18n/es")), + fr: () => merge(import("@/i18n/fr"), import("@opencode-ai/ui/i18n/fr")), + da: () => merge(import("@/i18n/da"), import("@opencode-ai/ui/i18n/da")), + ja: () => merge(import("@/i18n/ja"), import("@opencode-ai/ui/i18n/ja")), + pl: () => merge(import("@/i18n/pl"), import("@opencode-ai/ui/i18n/pl")), + ru: () => merge(import("@/i18n/ru"), import("@opencode-ai/ui/i18n/ru")), + ar: () => merge(import("@/i18n/ar"), import("@opencode-ai/ui/i18n/ar")), + no: () => merge(import("@/i18n/no"), import("@opencode-ai/ui/i18n/no")), + br: () => merge(import("@/i18n/br"), import("@opencode-ai/ui/i18n/br")), + th: () => merge(import("@/i18n/th"), import("@opencode-ai/ui/i18n/th")), + bs: () => merge(import("@/i18n/bs"), import("@opencode-ai/ui/i18n/bs")), + tr: () => merge(import("@/i18n/tr"), import("@opencode-ai/ui/i18n/tr")), +} + +function loadDict(locale: Locale) { + const hit = dicts.get(locale) + if (hit) return Promise.resolve(hit) + if (locale === "en") return Promise.resolve(base) + const load = loaders[locale] + return load().then((next: Dictionary) => { + dicts.set(locale, next) + return next + }) +} + +export function loadLocaleDict(locale: Locale) { + return loadDict(locale).then(() => undefined) } const localeMatchers: Array<{ locale: Locale; match: (language: string) => boolean }> = [ @@ -168,27 +156,6 @@ const localeMatchers: Array<{ locale: Locale; match: (language: string) => boole { locale: "tr", match: (language) => language.startsWith("tr") }, ] -type ParityKey = "command.session.previous.unseen" | "command.session.next.unseen" -const PARITY_CHECK: Record, Record> = { - zh, - zht, - ko, - de, - es, - fr, - da, - ja, - pl, - ru, - ar, - no, - br, - th, - bs, - tr, -} -void PARITY_CHECK - function detectLocale(): Locale { if (typeof navigator !== "object") return "en" @@ -203,27 +170,48 @@ function detectLocale(): Locale { return "en" } -function normalizeLocale(value: string): Locale { +export function normalizeLocale(value: string): Locale { return LOCALES.includes(value as Locale) ? (value as Locale) : "en" } +function readStoredLocale() { + if (typeof localStorage !== "object") return + try { + const raw = localStorage.getItem("opencode.global.dat:language") + if (!raw) return + const next = JSON.parse(raw) as { locale?: string } + if (typeof next?.locale !== "string") return + return normalizeLocale(next.locale) + } catch { + return + } +} + +const warm = readStoredLocale() ?? detectLocale() +if (warm !== "en") void loadDict(warm) + export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", - init: () => { + init: (props: { locale?: Locale }) => { + const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), createStore({ - locale: detectLocale() as Locale, + locale: initial, }), ) const locale = createMemo(() => normalizeLocale(store.locale)) - console.log("locale", locale()) const intl = createMemo(() => INTL[locale()]) - const dict = createMemo(() => DICT[locale()]) + const [dict] = createResource(locale, loadDict, { + initialValue: dicts.get(initial) ?? base, + }) - const t = i18n.translator(dict, i18n.resolveTemplate) + const t = i18n.translator(() => dict() ?? base, i18n.resolveTemplate) as ( + key: keyof Dictionary, + params?: Record, + ) => string const label = (value: Locale) => t(LABEL_KEY[value]) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 04bc2fdaaa..281a1ef33d 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -12,7 +12,7 @@ import { base64Encode } from "@opencode-ai/util/encode" import { decode64 } from "@/utils/base64" import { EventSessionError } from "@opencode-ai/sdk/v2" import { Persist, persisted } from "@/utils/persist" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" type NotificationBase = { directory?: string @@ -234,7 +234,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session.parentID) return if (settings.sounds.agentEnabled()) { - playSound(soundSrc(settings.sounds.agent())) + void playSoundById(settings.sounds.agent()) } append({ @@ -263,7 +263,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi if (session?.parentID) return if (settings.sounds.errorsEnabled()) { - playSound(soundSrc(settings.sounds.errors())) + void playSoundById(settings.sounds.errors()) } const error = "error" in event.properties ? event.properties.error : undefined diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 48788fe8ec..eddd752eb4 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -104,6 +104,13 @@ function withFallback(read: () => T | undefined, fallback: T) { return createMemo(() => read() ?? fallback) } +let font: Promise | undefined + +function loadFont() { + font ??= import("@opencode-ai/ui/font-loader") + return font +} + export const { use: useSettings, provider: SettingsProvider } = createSimpleContext({ name: "Settings", init: () => { @@ -111,7 +118,11 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (typeof document === "undefined") return - document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(store.appearance?.font)) + const id = store.appearance?.font ?? defaultSettings.appearance.font + if (id !== defaultSettings.appearance.font) { + void loadFont().then((x) => x.ensureMonoFont(id)) + } + document.documentElement.style.setProperty("--font-family-mono", monoFontFamily(id)) }) return { diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 66b889e2ad..bbf4fc5ec4 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -180,7 +180,8 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return globalSync.child(directory) } const absolute = (path: string) => (current()[0].path.directory + "/" + path).replace("//", "/") - const messagePageSize = 200 + const initialMessagePageSize = 80 + const historyMessagePageSize = 200 const inflight = new Map>() const inflightDiff = new Map>() const inflightTodo = new Map>() @@ -463,7 +464,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const cached = store.message[sessionID] !== undefined && meta.limit[key] !== undefined if (cached && hasSession && !opts?.force) return - const limit = meta.limit[key] ?? messagePageSize + const limit = meta.limit[key] ?? initialMessagePageSize const sessionReq = hasSession && !opts?.force ? Promise.resolve() @@ -560,7 +561,7 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const [, setStore] = globalSync.child(directory) touch(directory, setStore, sessionID) const key = keyFor(directory, sessionID) - const step = count ?? messagePageSize + const step = count ?? historyMessagePageSize if (meta.loading[key]) return if (meta.complete[key]) return const before = meta.cursor[key] diff --git a/packages/app/src/context/terminal-title.ts b/packages/app/src/context/terminal-title.ts index 3e8fa9af25..c8b18f4211 100644 --- a/packages/app/src/context/terminal-title.ts +++ b/packages/app/src/context/terminal-title.ts @@ -1,45 +1,18 @@ -import { dict as ar } from "@/i18n/ar" -import { dict as br } from "@/i18n/br" -import { dict as bs } from "@/i18n/bs" -import { dict as da } from "@/i18n/da" -import { dict as de } from "@/i18n/de" -import { dict as en } from "@/i18n/en" -import { dict as es } from "@/i18n/es" -import { dict as fr } from "@/i18n/fr" -import { dict as ja } from "@/i18n/ja" -import { dict as ko } from "@/i18n/ko" -import { dict as no } from "@/i18n/no" -import { dict as pl } from "@/i18n/pl" -import { dict as ru } from "@/i18n/ru" -import { dict as th } from "@/i18n/th" -import { dict as tr } from "@/i18n/tr" -import { dict as zh } from "@/i18n/zh" -import { dict as zht } from "@/i18n/zht" +const template = "Terminal {{number}}" -const numbered = Array.from( - new Set([ - en["terminal.title.numbered"], - ar["terminal.title.numbered"], - br["terminal.title.numbered"], - bs["terminal.title.numbered"], - da["terminal.title.numbered"], - de["terminal.title.numbered"], - es["terminal.title.numbered"], - fr["terminal.title.numbered"], - ja["terminal.title.numbered"], - ko["terminal.title.numbered"], - no["terminal.title.numbered"], - pl["terminal.title.numbered"], - ru["terminal.title.numbered"], - th["terminal.title.numbered"], - tr["terminal.title.numbered"], - zh["terminal.title.numbered"], - zht["terminal.title.numbered"], - ]), -) +const numbered = [ + template, + "محطة طرفية {{number}}", + "Терминал {{number}}", + "ターミナル {{number}}", + "터미널 {{number}}", + "เทอร์มินัล {{number}}", + "终端 {{number}}", + "終端機 {{number}}", +] export function defaultTitle(number: number) { - return en["terminal.title.numbered"].replace("{{number}}", String(number)) + return template.replace("{{number}}", String(number)) } export function isDefaultTitle(title: string, number: number) { diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index b5cbed6e75..da22c55523 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,10 +97,15 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } +const localUrl = () => + `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + +const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) + const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" - if (import.meta.env.DEV) - return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` + if (location.hostname.includes("opencode.ai")) return localUrl() + if (import.meta.env.DEV) return localUrl() + if (isLocalHost()) return localUrl() return location.origin } diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index a25f8b4b25..a8f2360bbf 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -22,7 +22,7 @@ export function useProviders() { const providers = () => { if (dir()) { const [projectStore] = globalSync.child(dir()) - return projectStore.provider + if (projectStore.provider.all.length > 0) return projectStore.provider } return globalSync.data.provider } diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 53063f48f8..d80e9fffb0 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,6 +1,7 @@ export { AppBaseProviders, AppInterface } from "./app" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" +export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" export { type DisplayBackend, type Platform, PlatformProvider } from "./context/platform" export { ServerConnection } from "./context/server" export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index cd5e079a69..6d3b04be9d 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -2,8 +2,7 @@ import { DataProvider } from "@opencode-ai/ui/context" import { showToast } from "@opencode-ai/ui/toast" import { base64Encode } from "@opencode-ai/util/encode" import { useLocation, useNavigate, useParams } from "@solidjs/router" -import { createMemo, createResource, type ParentProps, Show } from "solid-js" -import { useGlobalSDK } from "@/context/global-sdk" +import { createEffect, createMemo, type ParentProps, Show } from "solid-js" import { useLanguage } from "@/context/language" import { LocalProvider } from "@/context/local" import { SDKProvider } from "@/context/sdk" @@ -11,10 +10,18 @@ import { SyncProvider, useSync } from "@/context/sync" import { decode64 } from "@/utils/base64" function DirectoryDataProvider(props: ParentProps<{ directory: string }>) { + const location = useLocation() const navigate = useNavigate() const sync = useSync() const slug = createMemo(() => base64Encode(props.directory)) + createEffect(() => { + const next = sync.data.path.directory + if (!next || next === props.directory) return + const path = location.pathname.slice(slug().length + 1) + navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) + }) + return ( ) { export default function Layout(props: ParentProps) { const params = useParams() - const location = useLocation() const language = useLanguage() - const globalSDK = useGlobalSDK() const navigate = useNavigate() let invalid = "" - const [resolved] = createResource( - () => { - if (params.dir) return [location.pathname, params.dir] as const - }, - async ([pathname, b64Dir]) => { - const directory = decode64(b64Dir) + const resolved = createMemo(() => { + if (!params.dir) return "" + return decode64(params.dir) ?? "" + }) - if (!directory) { - if (invalid === params.dir) return - invalid = b64Dir - showToast({ - variant: "error", - title: language.t("common.requestFailed"), - description: language.t("directory.error.invalidUrl"), - }) - navigate("/", { replace: true }) - return - } - - return await globalSDK - .createClient({ - directory, - throwOnError: true, - }) - .path.get() - .then((x) => { - const next = x.data?.directory ?? directory - invalid = "" - if (next === directory) return next - const path = pathname.slice(b64Dir.length + 1) - navigate(`/${base64Encode(next)}${path}${location.search}${location.hash}`, { replace: true }) - }) - .catch(() => { - invalid = "" - return directory - }) - }, - ) + createEffect(() => { + const dir = params.dir + if (!dir) return + if (resolved()) { + invalid = "" + return + } + if (invalid === dir) return + invalid = dir + showToast({ + variant: "error", + title: language.t("common.requestFailed"), + description: language.t("directory.error.invalidUrl"), + }) + navigate("/", { replace: true }) + }) return ( diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index ba3a2b9427..4c795b9683 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -113,6 +113,14 @@ export default function Home() {
+ +
+
{language.t("common.loading")}
+ +
+
diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 01e151605d..b5a96110f6 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -49,21 +49,16 @@ import { useNotification } from "@/context/notification" import { usePermission } from "@/context/permission" import { Binary } from "@opencode-ai/util/binary" import { retry } from "@opencode-ai/util/retry" -import { playSound, soundSrc } from "@/utils/sound" +import { playSoundById } from "@/utils/sound" import { createAim } from "@/utils/aim" import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme" -import { DialogSelectProvider } from "@/components/dialog-select-provider" -import { DialogSelectServer } from "@/components/dialog-select-server" -import { DialogSettings } from "@/components/dialog-settings" +import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useCommand, type CommandOption } from "@/context/command" import { ConstrainDragXAxis, getDraggableId } from "@/utils/solid-dnd" -import { DialogSelectDirectory } from "@/components/dialog-select-directory" -import { DialogEditProject } from "@/components/dialog-edit-project" import { DebugBar } from "@/components/debug-bar" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" @@ -110,6 +105,8 @@ export default function Layout(props: ParentProps) { const pageReady = createMemo(() => ready()) let scrollContainerRef: HTMLDivElement | undefined + let dialogRun = 0 + let dialogDead = false const params = useParams() const globalSDK = useGlobalSDK() @@ -139,7 +136,7 @@ export default function Layout(props: ParentProps) { dir: globalSync.peek(dir, { bootstrap: false })[0].path.directory || dir, } }) - const availableThemeEntries = createMemo(() => Object.entries(theme.themes())) + const availableThemeEntries = createMemo(() => theme.ids().map((id) => [id, theme.themes()[id]] as const)) const colorSchemeOrder: ColorScheme[] = ["system", "light", "dark"] const colorSchemeKey: Record = { system: "theme.scheme.system", @@ -201,6 +198,8 @@ export default function Layout(props: ParentProps) { }) onCleanup(() => { + dialogDead = true + dialogRun += 1 if (navLeave.current !== undefined) clearTimeout(navLeave.current) clearTimeout(sortNowTimeout) if (sortNowInterval) clearInterval(sortNowInterval) @@ -336,10 +335,9 @@ export default function Layout(props: ParentProps) { const nextIndex = currentIndex === -1 ? 0 : (currentIndex + direction + ids.length) % ids.length const nextThemeId = ids[nextIndex] theme.setTheme(nextThemeId) - const nextTheme = theme.themes()[nextThemeId] showToast({ title: language.t("toast.theme.title"), - description: nextTheme?.name ?? nextThemeId, + description: theme.name(nextThemeId), }) } @@ -494,7 +492,7 @@ export default function Layout(props: ParentProps) { if (e.details.type === "permission.asked") { if (settings.sounds.permissionsEnabled()) { - playSound(soundSrc(settings.sounds.permissions())) + void playSoundById(settings.sounds.permissions()) } if (settings.notifications.permissions()) { void platform.notify(title, description, href) @@ -1154,10 +1152,10 @@ export default function Layout(props: ParentProps) { }, ] - for (const [id, definition] of availableThemeEntries()) { + for (const [id] of availableThemeEntries()) { commands.push({ id: `theme.set.${id}`, - title: language.t("command.theme.set", { theme: definition.name ?? id }), + title: language.t("command.theme.set", { theme: theme.name(id) }), category: language.t("command.category.theme"), onSelect: () => theme.commitPreview(), onHighlight: () => { @@ -1208,15 +1206,27 @@ export default function Layout(props: ParentProps) { }) function connectProvider() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-provider").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openServer() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-select-server").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function openSettings() { - dialog.show(() => ) + const run = ++dialogRun + void import("@/components/dialog-settings").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) } function projectRoot(directory: string) { @@ -1443,7 +1453,13 @@ export default function Layout(props: ParentProps) { layout.sidebar.toggleWorkspaces(project.worktree) } - const showEditProjectDialog = (project: LocalProject) => dialog.show(() => ) + const showEditProjectDialog = (project: LocalProject) => { + const run = ++dialogRun + void import("@/components/dialog-edit-project").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show(() => ) + }) + } async function chooseProject() { function resolve(result: string | string[] | null) { @@ -1464,10 +1480,14 @@ export default function Layout(props: ParentProps) { }) resolve(result) } else { - dialog.show( - () => , - () => resolve(null), - ) + const run = ++dialogRun + void import("@/components/dialog-select-directory").then((x) => { + if (dialogDead || dialogRun !== run) return + dialog.show( + () => , + () => resolve(null), + ) + }) } } diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 7a3b476e8d..2d3e31355a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1184,8 +1184,6 @@ export default function Page() { on( () => sdk.directory, () => { - void file.tree.list("") - const tab = activeFileTab() if (!tab) return const path = file.pathFromTab(tab) @@ -1640,6 +1638,9 @@ export default function Page() { sessionID: () => params.id, messagesReady, visibleUserMessages, + historyMore, + historyLoading, + loadMore: (sessionID) => sync.session.history.loadMore(sessionID), turnStart: historyWindow.turnStart, currentMessageId: () => store.messageId, pendingMessage: () => ui.pendingMessage, @@ -1711,7 +1712,7 @@ export default function Page() {
- + string | undefined messagesReady: () => boolean visibleUserMessages: () => UserMessage[] + historyMore: () => boolean + historyLoading: () => boolean + loadMore: (sessionID: string) => Promise turnStart: () => number currentMessageId: () => string | undefined pendingMessage: () => string | undefined @@ -181,6 +184,21 @@ export const useSessionHashScroll = (input: { queue(() => scrollToMessage(msg, "auto")) }) + createEffect(() => { + const sessionID = input.sessionID() + if (!sessionID || !input.messagesReady()) return + + visibleUserMessages() + + let targetId = input.pendingMessage() + if (!targetId && !clearing) targetId = messageIdFromHash(location.hash) + if (!targetId) return + if (messageById().has(targetId)) return + if (!input.historyMore() || input.historyLoading()) return + + void input.loadMore(sessionID) + }) + onMount(() => { if (typeof window !== "undefined" && "scrollRestoration" in window.history) { window.history.scrollRestoration = "manual" diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 45a323c7be..a13fd34ef7 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -14,6 +14,15 @@ interface CheckServerHealthOptions { const defaultTimeoutMs = 3000 const defaultRetryCount = 2 const defaultRetryDelayMs = 100 +const cacheMs = 750 +const healthCache = new Map< + string, + { at: number; done: boolean; fetch: typeof globalThis.fetch; promise: Promise } +>() + +function cacheKey(server: ServerConnection.HttpBase) { + return `${server.url}\n${server.username ?? ""}\n${server.password ?? ""}` +} function timeoutSignal(timeoutMs: number) { const timeout = (AbortSignal as unknown as { timeout?: (ms: number) => AbortSignal }).timeout @@ -87,5 +96,18 @@ export function useCheckServerHealth() { const platform = usePlatform() const fetcher = platform.fetch ?? globalThis.fetch - return (http: ServerConnection.HttpBase) => checkServerHealth(http, fetcher) + return (http: ServerConnection.HttpBase) => { + const key = cacheKey(http) + const hit = healthCache.get(key) + const now = Date.now() + if (hit && hit.fetch === fetcher && (!hit.done || now - hit.at < cacheMs)) return hit.promise + const promise = checkServerHealth(http, fetcher).finally(() => { + const next = healthCache.get(key) + if (!next || next.promise !== promise) return + next.done = true + next.at = Date.now() + }) + healthCache.set(key, { at: now, done: false, fetch: fetcher, promise }) + return promise + } } diff --git a/packages/app/src/utils/sound.ts b/packages/app/src/utils/sound.ts index 6dea812ec8..78e5a0c565 100644 --- a/packages/app/src/utils/sound.ts +++ b/packages/app/src/utils/sound.ts @@ -1,106 +1,89 @@ -import alert01 from "@opencode-ai/ui/audio/alert-01.aac" -import alert02 from "@opencode-ai/ui/audio/alert-02.aac" -import alert03 from "@opencode-ai/ui/audio/alert-03.aac" -import alert04 from "@opencode-ai/ui/audio/alert-04.aac" -import alert05 from "@opencode-ai/ui/audio/alert-05.aac" -import alert06 from "@opencode-ai/ui/audio/alert-06.aac" -import alert07 from "@opencode-ai/ui/audio/alert-07.aac" -import alert08 from "@opencode-ai/ui/audio/alert-08.aac" -import alert09 from "@opencode-ai/ui/audio/alert-09.aac" -import alert10 from "@opencode-ai/ui/audio/alert-10.aac" -import bipbop01 from "@opencode-ai/ui/audio/bip-bop-01.aac" -import bipbop02 from "@opencode-ai/ui/audio/bip-bop-02.aac" -import bipbop03 from "@opencode-ai/ui/audio/bip-bop-03.aac" -import bipbop04 from "@opencode-ai/ui/audio/bip-bop-04.aac" -import bipbop05 from "@opencode-ai/ui/audio/bip-bop-05.aac" -import bipbop06 from "@opencode-ai/ui/audio/bip-bop-06.aac" -import bipbop07 from "@opencode-ai/ui/audio/bip-bop-07.aac" -import bipbop08 from "@opencode-ai/ui/audio/bip-bop-08.aac" -import bipbop09 from "@opencode-ai/ui/audio/bip-bop-09.aac" -import bipbop10 from "@opencode-ai/ui/audio/bip-bop-10.aac" -import nope01 from "@opencode-ai/ui/audio/nope-01.aac" -import nope02 from "@opencode-ai/ui/audio/nope-02.aac" -import nope03 from "@opencode-ai/ui/audio/nope-03.aac" -import nope04 from "@opencode-ai/ui/audio/nope-04.aac" -import nope05 from "@opencode-ai/ui/audio/nope-05.aac" -import nope06 from "@opencode-ai/ui/audio/nope-06.aac" -import nope07 from "@opencode-ai/ui/audio/nope-07.aac" -import nope08 from "@opencode-ai/ui/audio/nope-08.aac" -import nope09 from "@opencode-ai/ui/audio/nope-09.aac" -import nope10 from "@opencode-ai/ui/audio/nope-10.aac" -import nope11 from "@opencode-ai/ui/audio/nope-11.aac" -import nope12 from "@opencode-ai/ui/audio/nope-12.aac" -import staplebops01 from "@opencode-ai/ui/audio/staplebops-01.aac" -import staplebops02 from "@opencode-ai/ui/audio/staplebops-02.aac" -import staplebops03 from "@opencode-ai/ui/audio/staplebops-03.aac" -import staplebops04 from "@opencode-ai/ui/audio/staplebops-04.aac" -import staplebops05 from "@opencode-ai/ui/audio/staplebops-05.aac" -import staplebops06 from "@opencode-ai/ui/audio/staplebops-06.aac" -import staplebops07 from "@opencode-ai/ui/audio/staplebops-07.aac" -import yup01 from "@opencode-ai/ui/audio/yup-01.aac" -import yup02 from "@opencode-ai/ui/audio/yup-02.aac" -import yup03 from "@opencode-ai/ui/audio/yup-03.aac" -import yup04 from "@opencode-ai/ui/audio/yup-04.aac" -import yup05 from "@opencode-ai/ui/audio/yup-05.aac" -import yup06 from "@opencode-ai/ui/audio/yup-06.aac" +let files: Record Promise> | undefined +let loads: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("../../../ui/src/assets/audio/*.aac", { import: "default" }) as Record< + string, + () => Promise + > + return files +} export const SOUND_OPTIONS = [ - { id: "alert-01", label: "sound.option.alert01", src: alert01 }, - { id: "alert-02", label: "sound.option.alert02", src: alert02 }, - { id: "alert-03", label: "sound.option.alert03", src: alert03 }, - { id: "alert-04", label: "sound.option.alert04", src: alert04 }, - { id: "alert-05", label: "sound.option.alert05", src: alert05 }, - { id: "alert-06", label: "sound.option.alert06", src: alert06 }, - { id: "alert-07", label: "sound.option.alert07", src: alert07 }, - { id: "alert-08", label: "sound.option.alert08", src: alert08 }, - { id: "alert-09", label: "sound.option.alert09", src: alert09 }, - { id: "alert-10", label: "sound.option.alert10", src: alert10 }, - { id: "bip-bop-01", label: "sound.option.bipbop01", src: bipbop01 }, - { id: "bip-bop-02", label: "sound.option.bipbop02", src: bipbop02 }, - { id: "bip-bop-03", label: "sound.option.bipbop03", src: bipbop03 }, - { id: "bip-bop-04", label: "sound.option.bipbop04", src: bipbop04 }, - { id: "bip-bop-05", label: "sound.option.bipbop05", src: bipbop05 }, - { id: "bip-bop-06", label: "sound.option.bipbop06", src: bipbop06 }, - { id: "bip-bop-07", label: "sound.option.bipbop07", src: bipbop07 }, - { id: "bip-bop-08", label: "sound.option.bipbop08", src: bipbop08 }, - { id: "bip-bop-09", label: "sound.option.bipbop09", src: bipbop09 }, - { id: "bip-bop-10", label: "sound.option.bipbop10", src: bipbop10 }, - { id: "staplebops-01", label: "sound.option.staplebops01", src: staplebops01 }, - { id: "staplebops-02", label: "sound.option.staplebops02", src: staplebops02 }, - { id: "staplebops-03", label: "sound.option.staplebops03", src: staplebops03 }, - { id: "staplebops-04", label: "sound.option.staplebops04", src: staplebops04 }, - { id: "staplebops-05", label: "sound.option.staplebops05", src: staplebops05 }, - { id: "staplebops-06", label: "sound.option.staplebops06", src: staplebops06 }, - { id: "staplebops-07", label: "sound.option.staplebops07", src: staplebops07 }, - { id: "nope-01", label: "sound.option.nope01", src: nope01 }, - { id: "nope-02", label: "sound.option.nope02", src: nope02 }, - { id: "nope-03", label: "sound.option.nope03", src: nope03 }, - { id: "nope-04", label: "sound.option.nope04", src: nope04 }, - { id: "nope-05", label: "sound.option.nope05", src: nope05 }, - { id: "nope-06", label: "sound.option.nope06", src: nope06 }, - { id: "nope-07", label: "sound.option.nope07", src: nope07 }, - { id: "nope-08", label: "sound.option.nope08", src: nope08 }, - { id: "nope-09", label: "sound.option.nope09", src: nope09 }, - { id: "nope-10", label: "sound.option.nope10", src: nope10 }, - { id: "nope-11", label: "sound.option.nope11", src: nope11 }, - { id: "nope-12", label: "sound.option.nope12", src: nope12 }, - { id: "yup-01", label: "sound.option.yup01", src: yup01 }, - { id: "yup-02", label: "sound.option.yup02", src: yup02 }, - { id: "yup-03", label: "sound.option.yup03", src: yup03 }, - { id: "yup-04", label: "sound.option.yup04", src: yup04 }, - { id: "yup-05", label: "sound.option.yup05", src: yup05 }, - { id: "yup-06", label: "sound.option.yup06", src: yup06 }, + { id: "alert-01", label: "sound.option.alert01" }, + { id: "alert-02", label: "sound.option.alert02" }, + { id: "alert-03", label: "sound.option.alert03" }, + { id: "alert-04", label: "sound.option.alert04" }, + { id: "alert-05", label: "sound.option.alert05" }, + { id: "alert-06", label: "sound.option.alert06" }, + { id: "alert-07", label: "sound.option.alert07" }, + { id: "alert-08", label: "sound.option.alert08" }, + { id: "alert-09", label: "sound.option.alert09" }, + { id: "alert-10", label: "sound.option.alert10" }, + { id: "bip-bop-01", label: "sound.option.bipbop01" }, + { id: "bip-bop-02", label: "sound.option.bipbop02" }, + { id: "bip-bop-03", label: "sound.option.bipbop03" }, + { id: "bip-bop-04", label: "sound.option.bipbop04" }, + { id: "bip-bop-05", label: "sound.option.bipbop05" }, + { id: "bip-bop-06", label: "sound.option.bipbop06" }, + { id: "bip-bop-07", label: "sound.option.bipbop07" }, + { id: "bip-bop-08", label: "sound.option.bipbop08" }, + { id: "bip-bop-09", label: "sound.option.bipbop09" }, + { id: "bip-bop-10", label: "sound.option.bipbop10" }, + { id: "staplebops-01", label: "sound.option.staplebops01" }, + { id: "staplebops-02", label: "sound.option.staplebops02" }, + { id: "staplebops-03", label: "sound.option.staplebops03" }, + { id: "staplebops-04", label: "sound.option.staplebops04" }, + { id: "staplebops-05", label: "sound.option.staplebops05" }, + { id: "staplebops-06", label: "sound.option.staplebops06" }, + { id: "staplebops-07", label: "sound.option.staplebops07" }, + { id: "nope-01", label: "sound.option.nope01" }, + { id: "nope-02", label: "sound.option.nope02" }, + { id: "nope-03", label: "sound.option.nope03" }, + { id: "nope-04", label: "sound.option.nope04" }, + { id: "nope-05", label: "sound.option.nope05" }, + { id: "nope-06", label: "sound.option.nope06" }, + { id: "nope-07", label: "sound.option.nope07" }, + { id: "nope-08", label: "sound.option.nope08" }, + { id: "nope-09", label: "sound.option.nope09" }, + { id: "nope-10", label: "sound.option.nope10" }, + { id: "nope-11", label: "sound.option.nope11" }, + { id: "nope-12", label: "sound.option.nope12" }, + { id: "yup-01", label: "sound.option.yup01" }, + { id: "yup-02", label: "sound.option.yup02" }, + { id: "yup-03", label: "sound.option.yup03" }, + { id: "yup-04", label: "sound.option.yup04" }, + { id: "yup-05", label: "sound.option.yup05" }, + { id: "yup-06", label: "sound.option.yup06" }, ] as const export type SoundOption = (typeof SOUND_OPTIONS)[number] export type SoundID = SoundOption["id"] -const soundById = Object.fromEntries(SOUND_OPTIONS.map((s) => [s.id, s.src])) as Record +function getLoads() { + if (loads) return loads + loads = Object.fromEntries( + Object.entries(getFiles()).flatMap(([path, load]) => { + const file = path.split("/").at(-1) + if (!file) return [] + return [[file.replace(/\.aac$/, ""), load] as const] + }), + ) as Record Promise> + return loads +} + +const cache = new Map>() export function soundSrc(id: string | undefined) { - if (!id) return - if (!(id in soundById)) return - return soundById[id as SoundID] + const loads = getLoads() + if (!id || !(id in loads)) return Promise.resolve(undefined) + const key = id as SoundID + const hit = cache.get(key) + if (hit) return hit + const next = loads[key]().catch(() => undefined) + cache.set(key, next) + return next } export function playSound(src: string | undefined) { @@ -108,10 +91,12 @@ export function playSound(src: string | undefined) { if (!src) return const audio = new Audio(src) audio.play().catch(() => undefined) - - // Return a cleanup function to pause the sound. return () => { audio.pause() audio.currentTime = 0 } } + +export function playSoundById(id: string | undefined) { + return soundSrc(id).then((src) => playSound(src)) +} diff --git a/packages/app/vite.js b/packages/app/vite.js index 6b8fd61376..f65a68a1cb 100644 --- a/packages/app/vite.js +++ b/packages/app/vite.js @@ -1,7 +1,10 @@ +import { readFileSync } from "node:fs" import solidPlugin from "vite-plugin-solid" import tailwindcss from "@tailwindcss/vite" import { fileURLToPath } from "url" +const theme = fileURLToPath(new URL("./public/oc-theme-preload.js", import.meta.url)) + /** * @type {import("vite").PluginOption} */ @@ -21,6 +24,15 @@ export default [ } }, }, + { + name: "opencode-desktop:theme-preload", + transformIndexHtml(html) { + return html.replace( + '', + ``, + ) + }, + }, tailwindcss(), solidPlugin(), ] diff --git a/packages/desktop-electron/src/renderer/index.tsx b/packages/desktop-electron/src/renderer/index.tsx index ec2b4d1e7a..44f2e6360c 100644 --- a/packages/desktop-electron/src/renderer/index.tsx +++ b/packages/desktop-electron/src/renderer/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -246,6 +249,17 @@ listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } const [windowCount] = createResource(() => window.api.getWindowCount()) @@ -257,6 +271,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) const servers = () => { const data = sidecar() @@ -309,15 +324,14 @@ render(() => { return ( - - + + {(_) => { return ( 1} > diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index e677956440..5fe88d501b 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -6,6 +6,9 @@ import { AppBaseProviders, AppInterface, handleNotificationClick, + loadLocaleDict, + normalizeLocale, + type Locale, type Platform, PlatformProvider, ServerConnection, @@ -414,6 +417,17 @@ void listenForDeepLinks() render(() => { const platform = createPlatform() + const loadLocale = async () => { + const current = await platform.storage?.("opencode.global.dat").getItem("language") + const legacy = current ? undefined : await platform.storage?.().getItem("language.v1") + const raw = current ?? legacy + if (!raw) return + const locale = raw.match(/"locale"\s*:\s*"([^"]+)"/)?.[1] + if (!locale) return + const next = normalizeLocale(locale) + if (next !== "en") await loadLocaleDict(next) + return next satisfies Locale + } // Fetch sidecar credentials from Rust (available immediately, before health check) const [sidecar] = createResource(() => commands.awaitInitialization(new Channel() as any)) @@ -423,6 +437,7 @@ render(() => { if (url) return ServerConnection.key({ type: "http", http: { url } }) }), ) + const [locale] = createResource(loadLocale) // Build the sidecar server connection once credentials arrive const servers = () => { @@ -465,8 +480,8 @@ render(() => { return ( - - + + {(_) => { return ( + `default-src 'self'; script-src 'self' 'wasm-unsafe-eval'${hash ? ` 'sha256-${hash}'` : ""}; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:` + export namespace Server { const log = Log.create({ service: "server" }) @@ -506,10 +510,13 @@ export namespace Server { host: "app.opencode.ai", }, }) - response.headers.set( - "Content-Security-Policy", - "default-src 'self'; script-src 'self' 'wasm-unsafe-eval'; style-src 'self' 'unsafe-inline'; img-src 'self' data: https:; font-src 'self' data:; media-src 'self' data:; connect-src 'self' data:", - ) + const match = response.headers.get("content-type")?.includes("text/html") + ? (await response.clone().text()).match( + /]*\bsrc\s*=)[^>]*\bid=(['"])oc-theme-preload-script\1[^>]*>([\s\S]*?)<\/script>/i, + ) + : undefined + const hash = match ? createHash("sha256").update(match[2]).digest("base64") : "" + response.headers.set("Content-Security-Policy", csp(hash)) return response }) } diff --git a/packages/ui/package.json b/packages/ui/package.json index cc6be2abe5..7d4a39a262 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -12,6 +12,7 @@ "./hooks": "./src/hooks/index.ts", "./context": "./src/context/index.ts", "./context/*": "./src/context/*.tsx", + "./font-loader": "./src/font-loader.ts", "./styles": "./src/styles/index.css", "./styles/tailwind": "./src/styles/tailwind/index.css", "./theme": "./src/theme/index.ts", diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg new file mode 100644 index 0000000000..b3a2edc3c0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan-cn.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg new file mode 100644 index 0000000000..b3a2edc3c0 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/alibaba-coding-plan.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/clarifai.svg b/packages/ui/src/assets/icons/provider/clarifai.svg new file mode 100644 index 0000000000..086e9aa1fc --- /dev/null +++ b/packages/ui/src/assets/icons/provider/clarifai.svg @@ -0,0 +1,24 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/dinference.svg b/packages/ui/src/assets/icons/provider/dinference.svg new file mode 100644 index 0000000000..e045c96fb3 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/dinference.svg @@ -0,0 +1 @@ + diff --git a/packages/ui/src/assets/icons/provider/drun.svg b/packages/ui/src/assets/icons/provider/drun.svg new file mode 100644 index 0000000000..472dee9122 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/drun.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/packages/ui/src/assets/icons/provider/perplexity-agent.svg b/packages/ui/src/assets/icons/provider/perplexity-agent.svg new file mode 100644 index 0000000000..a0f38862a4 --- /dev/null +++ b/packages/ui/src/assets/icons/provider/perplexity-agent.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg new file mode 100644 index 0000000000..502e51a5be --- /dev/null +++ b/packages/ui/src/assets/icons/provider/tencent-coding-plan.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/packages/ui/src/assets/icons/provider/zenmux.svg b/packages/ui/src/assets/icons/provider/zenmux.svg index d8d9ef665f..9eb8045e45 100644 --- a/packages/ui/src/assets/icons/provider/zenmux.svg +++ b/packages/ui/src/assets/icons/provider/zenmux.svg @@ -1,3 +1,4 @@ - - + + + diff --git a/packages/ui/src/components/font.tsx b/packages/ui/src/components/font.tsx index f735747a49..e1a508f16a 100644 --- a/packages/ui/src/components/font.tsx +++ b/packages/ui/src/components/font.tsx @@ -1,121 +1,9 @@ +import { Link, Style } from "@solidjs/meta" import { Show } from "solid-js" -import { Style, Link } from "@solidjs/meta" import inter from "../assets/fonts/inter.woff2" -import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" -import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" import ibmPlexMonoBold from "../assets/fonts/ibm-plex-mono-bold.woff2" - -import cascadiaCode from "../assets/fonts/cascadia-code-nerd-font.woff2" -import cascadiaCodeBold from "../assets/fonts/cascadia-code-nerd-font-bold.woff2" -import firaCode from "../assets/fonts/fira-code-nerd-font.woff2" -import firaCodeBold from "../assets/fonts/fira-code-nerd-font-bold.woff2" -import hack from "../assets/fonts/hack-nerd-font.woff2" -import hackBold from "../assets/fonts/hack-nerd-font-bold.woff2" -import inconsolata from "../assets/fonts/inconsolata-nerd-font.woff2" -import inconsolataBold from "../assets/fonts/inconsolata-nerd-font-bold.woff2" -import intelOneMono from "../assets/fonts/intel-one-mono-nerd-font.woff2" -import intelOneMonoBold from "../assets/fonts/intel-one-mono-nerd-font-bold.woff2" -import jetbrainsMono from "../assets/fonts/jetbrains-mono-nerd-font.woff2" -import jetbrainsMonoBold from "../assets/fonts/jetbrains-mono-nerd-font-bold.woff2" -import mesloLgs from "../assets/fonts/meslo-lgs-nerd-font.woff2" -import mesloLgsBold from "../assets/fonts/meslo-lgs-nerd-font-bold.woff2" -import robotoMono from "../assets/fonts/roboto-mono-nerd-font.woff2" -import robotoMonoBold from "../assets/fonts/roboto-mono-nerd-font-bold.woff2" -import sourceCodePro from "../assets/fonts/source-code-pro-nerd-font.woff2" -import sourceCodeProBold from "../assets/fonts/source-code-pro-nerd-font-bold.woff2" -import ubuntuMono from "../assets/fonts/ubuntu-mono-nerd-font.woff2" -import ubuntuMonoBold from "../assets/fonts/ubuntu-mono-nerd-font-bold.woff2" -import iosevka from "../assets/fonts/iosevka-nerd-font.woff2" -import iosevkaBold from "../assets/fonts/iosevka-nerd-font-bold.woff2" -import geistMono from "../assets/fonts/GeistMonoNerdFontMono-Regular.woff2" -import geistMonoBold from "../assets/fonts/GeistMonoNerdFontMono-Bold.woff2" - -type MonoFont = { - family: string - regular: string - bold: string -} - -export const MONO_NERD_FONTS = [ - { - family: "JetBrains Mono Nerd Font", - regular: jetbrainsMono, - bold: jetbrainsMonoBold, - }, - { - family: "Fira Code Nerd Font", - regular: firaCode, - bold: firaCodeBold, - }, - { - family: "Cascadia Code Nerd Font", - regular: cascadiaCode, - bold: cascadiaCodeBold, - }, - { - family: "Hack Nerd Font", - regular: hack, - bold: hackBold, - }, - { - family: "Source Code Pro Nerd Font", - regular: sourceCodePro, - bold: sourceCodeProBold, - }, - { - family: "Inconsolata Nerd Font", - regular: inconsolata, - bold: inconsolataBold, - }, - { - family: "Roboto Mono Nerd Font", - regular: robotoMono, - bold: robotoMonoBold, - }, - { - family: "Ubuntu Mono Nerd Font", - regular: ubuntuMono, - bold: ubuntuMonoBold, - }, - { - family: "Intel One Mono Nerd Font", - regular: intelOneMono, - bold: intelOneMonoBold, - }, - { - family: "Meslo LGS Nerd Font", - regular: mesloLgs, - bold: mesloLgsBold, - }, - { - family: "Iosevka Nerd Font", - regular: iosevka, - bold: iosevkaBold, - }, - { - family: "GeistMono Nerd Font", - regular: geistMono, - bold: geistMonoBold, - }, -] satisfies MonoFont[] - -const monoNerdCss = MONO_NERD_FONTS.map( - (font) => ` - @font-face { - font-family: "${font.family}"; - src: url("${font.regular}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 400; - } - @font-face { - font-family: "${font.family}"; - src: url("${font.bold}") format("woff2"); - font-display: swap; - font-style: normal; - font-weight: 700; - }`, -).join("") +import ibmPlexMonoMedium from "../assets/fonts/ibm-plex-mono-medium.woff2" +import ibmPlexMonoRegular from "../assets/fonts/ibm-plex-mono.woff2" export const Font = () => { return ( @@ -165,7 +53,6 @@ export const Font = () => { descent-override: 25%; line-gap-override: 1%; } -${monoNerdCss} `} diff --git a/packages/ui/src/font-loader.ts b/packages/ui/src/font-loader.ts new file mode 100644 index 0000000000..f2b1e6be13 --- /dev/null +++ b/packages/ui/src/font-loader.ts @@ -0,0 +1,133 @@ +type MonoFont = { + id: string + family: string + regular: string + bold: string +} + +let files: Record Promise> | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob("./assets/fonts/*.woff2", { import: "default" }) as Record Promise> + return files +} + +export const MONO_NERD_FONTS = [ + { + id: "jetbrains-mono", + family: "JetBrains Mono Nerd Font", + regular: "./assets/fonts/jetbrains-mono-nerd-font.woff2", + bold: "./assets/fonts/jetbrains-mono-nerd-font-bold.woff2", + }, + { + id: "fira-code", + family: "Fira Code Nerd Font", + regular: "./assets/fonts/fira-code-nerd-font.woff2", + bold: "./assets/fonts/fira-code-nerd-font-bold.woff2", + }, + { + id: "cascadia-code", + family: "Cascadia Code Nerd Font", + regular: "./assets/fonts/cascadia-code-nerd-font.woff2", + bold: "./assets/fonts/cascadia-code-nerd-font-bold.woff2", + }, + { + id: "hack", + family: "Hack Nerd Font", + regular: "./assets/fonts/hack-nerd-font.woff2", + bold: "./assets/fonts/hack-nerd-font-bold.woff2", + }, + { + id: "source-code-pro", + family: "Source Code Pro Nerd Font", + regular: "./assets/fonts/source-code-pro-nerd-font.woff2", + bold: "./assets/fonts/source-code-pro-nerd-font-bold.woff2", + }, + { + id: "inconsolata", + family: "Inconsolata Nerd Font", + regular: "./assets/fonts/inconsolata-nerd-font.woff2", + bold: "./assets/fonts/inconsolata-nerd-font-bold.woff2", + }, + { + id: "roboto-mono", + family: "Roboto Mono Nerd Font", + regular: "./assets/fonts/roboto-mono-nerd-font.woff2", + bold: "./assets/fonts/roboto-mono-nerd-font-bold.woff2", + }, + { + id: "ubuntu-mono", + family: "Ubuntu Mono Nerd Font", + regular: "./assets/fonts/ubuntu-mono-nerd-font.woff2", + bold: "./assets/fonts/ubuntu-mono-nerd-font-bold.woff2", + }, + { + id: "intel-one-mono", + family: "Intel One Mono Nerd Font", + regular: "./assets/fonts/intel-one-mono-nerd-font.woff2", + bold: "./assets/fonts/intel-one-mono-nerd-font-bold.woff2", + }, + { + id: "meslo-lgs", + family: "Meslo LGS Nerd Font", + regular: "./assets/fonts/meslo-lgs-nerd-font.woff2", + bold: "./assets/fonts/meslo-lgs-nerd-font-bold.woff2", + }, + { + id: "iosevka", + family: "Iosevka Nerd Font", + regular: "./assets/fonts/iosevka-nerd-font.woff2", + bold: "./assets/fonts/iosevka-nerd-font-bold.woff2", + }, + { + id: "geist-mono", + family: "GeistMono Nerd Font", + regular: "./assets/fonts/GeistMonoNerdFontMono-Regular.woff2", + bold: "./assets/fonts/GeistMonoNerdFontMono-Bold.woff2", + }, +] satisfies MonoFont[] + +const mono = Object.fromEntries(MONO_NERD_FONTS.map((font) => [font.id, font])) as Record +const loads = new Map>() + +function css(font: { family: string; regular: string; bold: string }) { + return ` + @font-face { + font-family: "${font.family}"; + src: url("${font.regular}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 400; + } + @font-face { + font-family: "${font.family}"; + src: url("${font.bold}") format("woff2"); + font-display: swap; + font-style: normal; + font-weight: 700; + } + ` +} + +export function ensureMonoFont(id: string | undefined) { + if (!id || id === "ibm-plex-mono") return Promise.resolve() + if (typeof document !== "object") return Promise.resolve() + const font = mono[id] + if (!font) return Promise.resolve() + const styleId = `oc-font-${font.id}` + if (document.getElementById(styleId)) return Promise.resolve() + const hit = loads.get(font.id) + if (hit) return hit + const files = getFiles() + const load = Promise.all([files[font.regular]?.(), files[font.bold]?.()]).then(([regular, bold]) => { + if (!regular || !bold) return + if (document.getElementById(styleId)) return + const style = document.createElement("style") + style.id = styleId + style.textContent = css({ family: font.family, regular, bold }) + document.head.appendChild(style) + }) + loads.set(font.id, load) + return load +} diff --git a/packages/ui/src/theme/context.tsx b/packages/ui/src/theme/context.tsx index 9808c8e841..7d25ac3972 100644 --- a/packages/ui/src/theme/context.tsx +++ b/packages/ui/src/theme/context.tsx @@ -1,7 +1,7 @@ import { createEffect, onCleanup, onMount } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "../context/helper" -import { DEFAULT_THEMES } from "./default-themes" +import oc2ThemeJson from "./themes/oc-2.json" import { resolveThemeVariant, themeToCss } from "./resolve" import type { DesktopTheme } from "./types" @@ -15,14 +15,101 @@ const STORAGE_KEYS = { } as const const THEME_STYLE_ID = "oc-theme" +let files: Record Promise<{ default: DesktopTheme }>> | undefined +let ids: string[] | undefined +let known: Set | undefined + +function getFiles() { + if (files) return files + files = import.meta.glob<{ default: DesktopTheme }>("./themes/*.json") + return files +} + +function themeIDs() { + if (ids) return ids + ids = Object.keys(getFiles()) + .map((path) => path.slice("./themes/".length, -".json".length)) + .sort() + return ids +} + +function knownThemes() { + if (known) return known + known = new Set(themeIDs()) + return known +} + +const names: Record = { + "oc-2": "OC-2", + amoled: "AMOLED", + aura: "Aura", + ayu: "Ayu", + carbonfox: "Carbonfox", + catppuccin: "Catppuccin", + "catppuccin-frappe": "Catppuccin Frappe", + "catppuccin-macchiato": "Catppuccin Macchiato", + cobalt2: "Cobalt2", + cursor: "Cursor", + dracula: "Dracula", + everforest: "Everforest", + flexoki: "Flexoki", + github: "GitHub", + gruvbox: "Gruvbox", + kanagawa: "Kanagawa", + "lucent-orng": "Lucent Orng", + material: "Material", + matrix: "Matrix", + mercury: "Mercury", + monokai: "Monokai", + nightowl: "Night Owl", + nord: "Nord", + "one-dark": "One Dark", + onedarkpro: "One Dark Pro", + opencode: "OpenCode", + orng: "Orng", + "osaka-jade": "Osaka Jade", + palenight: "Palenight", + rosepine: "Rose Pine", + shadesofpurple: "Shades of Purple", + solarized: "Solarized", + synthwave84: "Synthwave '84", + tokyonight: "Tokyonight", + vercel: "Vercel", + vesper: "Vesper", + zenburn: "Zenburn", +} +const oc2Theme = oc2ThemeJson as DesktopTheme function normalize(id: string | null | undefined) { return id === "oc-1" ? "oc-2" : id } +function read(key: string) { + if (typeof localStorage !== "object") return null + try { + return localStorage.getItem(key) + } catch { + return null + } +} + +function write(key: string, value: string) { + if (typeof localStorage !== "object") return + try { + localStorage.setItem(key, value) + } catch {} +} + +function drop(key: string) { + if (typeof localStorage !== "object") return + try { + localStorage.removeItem(key) + } catch {} +} + function clear() { - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_LIGHT) - localStorage.removeItem(STORAGE_KEYS.THEME_CSS_DARK) + drop(STORAGE_KEYS.THEME_CSS_LIGHT) + drop(STORAGE_KEYS.THEME_CSS_DARK) } function ensureThemeStyleElement(): HTMLStyleElement { @@ -35,6 +122,7 @@ function ensureThemeStyleElement(): HTMLStyleElement { } function getSystemMode(): "light" | "dark" { + if (typeof window !== "object") return "light" return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light" } @@ -45,9 +133,7 @@ function applyThemeCss(theme: DesktopTheme, themeId: string, mode: "light" | "da const css = themeToCss(tokens) if (themeId !== "oc-2") { - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } const fullCss = `:root { @@ -69,74 +155,122 @@ function cacheThemeVariants(theme: DesktopTheme, themeId: string) { const variant = isDark ? theme.dark : theme.light const tokens = resolveThemeVariant(variant, isDark) const css = themeToCss(tokens) - try { - localStorage.setItem(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) - } catch {} + write(isDark ? STORAGE_KEYS.THEME_CSS_DARK : STORAGE_KEYS.THEME_CSS_LIGHT, css) } } export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ name: "Theme", init: (props: { defaultTheme?: string; onThemeApplied?: (theme: DesktopTheme, mode: "light" | "dark") => void }) => { + const themeId = normalize(read(STORAGE_KEYS.THEME_ID) ?? props.defaultTheme) ?? "oc-2" + const colorScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + const mode = colorScheme === "system" ? getSystemMode() : colorScheme const [store, setStore] = createStore({ - themes: DEFAULT_THEMES as Record, - themeId: normalize(props.defaultTheme) ?? "oc-2", - colorScheme: "system" as ColorScheme, - mode: getSystemMode(), + themes: { + "oc-2": oc2Theme, + } as Record, + themeId, + colorScheme, + mode, previewThemeId: null as string | null, previewScheme: null as ColorScheme | null, }) - window.addEventListener("storage", (e) => { - if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) setStore("themeId", e.newValue) - if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { - setStore("colorScheme", e.newValue as ColorScheme) - setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as any)) - } - }) + const loads = new Map>() - onMount(() => { - const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") - const handler = () => { - if (store.colorScheme === "system") { - setStore("mode", getSystemMode()) - } - } - mediaQuery.addEventListener("change", handler) - onCleanup(() => mediaQuery.removeEventListener("change", handler)) - - const savedTheme = localStorage.getItem(STORAGE_KEYS.THEME_ID) - const themeId = normalize(savedTheme) - const savedScheme = localStorage.getItem(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null - if (themeId && store.themes[themeId]) { - setStore("themeId", themeId) - } - if (savedTheme && themeId && savedTheme !== themeId) { - localStorage.setItem(STORAGE_KEYS.THEME_ID, themeId) - clear() - } - if (savedScheme) { - setStore("colorScheme", savedScheme) - if (savedScheme !== "system") { - setStore("mode", savedScheme) - } - } - const currentTheme = store.themes[store.themeId] - if (currentTheme) { - cacheThemeVariants(currentTheme, store.themeId) - } - }) + const load = (id: string) => { + const next = normalize(id) + if (!next) return Promise.resolve(undefined) + const hit = store.themes[next] + if (hit) return Promise.resolve(hit) + const pending = loads.get(next) + if (pending) return pending + const file = getFiles()[`./themes/${next}.json`] + if (!file) return Promise.resolve(undefined) + const task = file() + .then((mod) => { + const theme = mod.default + setStore("themes", next, theme) + return theme + }) + .finally(() => { + loads.delete(next) + }) + loads.set(next, task) + return task + } const applyTheme = (theme: DesktopTheme, themeId: string, mode: "light" | "dark") => { applyThemeCss(theme, themeId, mode) props.onThemeApplied?.(theme, mode) } + const ids = () => { + const extra = Object.keys(store.themes) + .filter((id) => !knownThemes().has(id)) + .sort() + const all = themeIDs() + if (extra.length === 0) return all + return [...all, ...extra] + } + + const loadThemes = () => Promise.all(themeIDs().map(load)).then(() => store.themes) + + const onStorage = (e: StorageEvent) => { + if (e.key === STORAGE_KEYS.THEME_ID && e.newValue) { + const next = normalize(e.newValue) + if (!next) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return + setStore("themeId", next) + if (next === "oc-2") { + clear() + return + } + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + }) + } + if (e.key === STORAGE_KEYS.COLOR_SCHEME && e.newValue) { + setStore("colorScheme", e.newValue as ColorScheme) + setStore("mode", e.newValue === "system" ? getSystemMode() : (e.newValue as "light" | "dark")) + } + } + + if (typeof window === "object") { + window.addEventListener("storage", onStorage) + onCleanup(() => window.removeEventListener("storage", onStorage)) + } + + onMount(() => { + const mediaQuery = window.matchMedia("(prefers-color-scheme: dark)") + const onMedia = () => { + if (store.colorScheme !== "system") return + setStore("mode", getSystemMode()) + } + mediaQuery.addEventListener("change", onMedia) + onCleanup(() => mediaQuery.removeEventListener("change", onMedia)) + + const rawTheme = read(STORAGE_KEYS.THEME_ID) + const savedTheme = normalize(rawTheme ?? props.defaultTheme) ?? "oc-2" + const savedScheme = (read(STORAGE_KEYS.COLOR_SCHEME) as ColorScheme | null) ?? "system" + if (rawTheme && rawTheme !== savedTheme) { + write(STORAGE_KEYS.THEME_ID, savedTheme) + clear() + } + if (savedTheme !== store.themeId) setStore("themeId", savedTheme) + if (savedScheme !== store.colorScheme) setStore("colorScheme", savedScheme) + setStore("mode", savedScheme === "system" ? getSystemMode() : savedScheme) + void load(savedTheme).then((theme) => { + if (!theme || store.themeId !== savedTheme) return + cacheThemeVariants(theme, savedTheme) + }) + }) + createEffect(() => { const theme = store.themes[store.themeId] - if (theme) { - applyTheme(theme, store.themeId, store.mode) - } + if (!theme) return + applyTheme(theme, store.themeId, store.mode) }) const setTheme = (id: string) => { @@ -145,23 +279,26 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ console.warn(`Theme "${id}" not found`) return } - const theme = store.themes[next] - if (!theme) { + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) { console.warn(`Theme "${id}" not found`) return } setStore("themeId", next) - localStorage.setItem(STORAGE_KEYS.THEME_ID, next) if (next === "oc-2") { + write(STORAGE_KEYS.THEME_ID, next) clear() return } - cacheThemeVariants(theme, next) + void load(next).then((theme) => { + if (!theme || store.themeId !== next) return + cacheThemeVariants(theme, next) + write(STORAGE_KEYS.THEME_ID, next) + }) } const setColorScheme = (scheme: ColorScheme) => { setStore("colorScheme", scheme) - localStorage.setItem(STORAGE_KEYS.COLOR_SCHEME, scheme) + write(STORAGE_KEYS.COLOR_SCHEME, scheme) setStore("mode", scheme === "system" ? getSystemMode() : scheme) } @@ -169,6 +306,9 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ themeId: () => store.themeId, colorScheme: () => store.colorScheme, mode: () => store.mode, + ids, + name: (id: string) => store.themes[id]?.name ?? names[id] ?? id, + loadThemes, themes: () => store.themes, setTheme, setColorScheme, @@ -176,24 +316,28 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ previewTheme: (id: string) => { const next = normalize(id) if (!next) return - const theme = store.themes[next] - if (!theme) return + if (next !== "oc-2" && !knownThemes().has(next) && !store.themes[next]) return setStore("previewThemeId", next) - const previewMode = store.previewScheme - ? store.previewScheme === "system" - ? getSystemMode() - : store.previewScheme - : store.mode - applyTheme(theme, next, previewMode) + void load(next).then((theme) => { + if (!theme || store.previewThemeId !== next) return + const mode = store.previewScheme + ? store.previewScheme === "system" + ? getSystemMode() + : store.previewScheme + : store.mode + applyTheme(theme, next, mode) + }) }, previewColorScheme: (scheme: ColorScheme) => { setStore("previewScheme", scheme) - const previewMode = scheme === "system" ? getSystemMode() : scheme + const mode = scheme === "system" ? getSystemMode() : scheme const id = store.previewThemeId ?? store.themeId - const theme = store.themes[id] - if (theme) { - applyTheme(theme, id, previewMode) - } + void load(id).then((theme) => { + if (!theme) return + if ((store.previewThemeId ?? store.themeId) !== id) return + if (store.previewScheme !== scheme) return + applyTheme(theme, id, mode) + }) }, commitPreview: () => { if (store.previewThemeId) { @@ -208,10 +352,10 @@ export const { use: useTheme, provider: ThemeProvider } = createSimpleContext({ cancelPreview: () => { setStore("previewThemeId", null) setStore("previewScheme", null) - const theme = store.themes[store.themeId] - if (theme) { + void load(store.themeId).then((theme) => { + if (!theme) return applyTheme(theme, store.themeId, store.mode) - } + }) }, } }, From 4167e25c7ec53d066fc81cb15c7ac490569be073 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 06:41:00 -0500 Subject: [PATCH 20/36] fix(app): opencode web server url --- packages/app/src/entry.tsx | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index da22c55523..b5cbed6e75 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -97,15 +97,10 @@ if (!(root instanceof HTMLElement) && import.meta.env.DEV) { throw new Error(getRootNotFoundError()) } -const localUrl = () => - `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` - -const isLocalHost = () => ["localhost", "127.0.0.1", "0.0.0.0"].includes(location.hostname) - const getCurrentUrl = () => { - if (location.hostname.includes("opencode.ai")) return localUrl() - if (import.meta.env.DEV) return localUrl() - if (isLocalHost()) return localUrl() + if (location.hostname.includes("opencode.ai")) return "http://localhost:4096" + if (import.meta.env.DEV) + return `http://${import.meta.env.VITE_OPENCODE_SERVER_HOST ?? "localhost"}:${import.meta.env.VITE_OPENCODE_SERVER_PORT ?? "4096"}` return location.origin } From b480a38d313416f7020f61f8bfbe4df920fd90d4 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 25 Mar 2026 07:05:04 -0500 Subject: [PATCH 21/36] chore(app): markdown playground in storyboard --- packages/ui/src/components/message-part.css | 5 - packages/ui/src/components/session-turn.css | 4 - .../timeline-playground.stories.tsx | 1579 +++++++++++++++++ 3 files changed, 1579 insertions(+), 9 deletions(-) create mode 100644 packages/ui/src/components/timeline-playground.stories.tsx diff --git a/packages/ui/src/components/message-part.css b/packages/ui/src/components/message-part.css index aa685392a9..fbde8ee7cf 100644 --- a/packages/ui/src/components/message-part.css +++ b/packages/ui/src/components/message-part.css @@ -248,11 +248,6 @@ opacity: 1; pointer-events: auto; } - - [data-component="markdown"] { - margin-top: 0; - font-size: var(--font-size-base); - } } [data-component="compaction-part"] { diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 26d918050d..8075d25777 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -85,10 +85,6 @@ flex-direction: column; align-self: stretch; gap: 12px; - - > :first-child > [data-component="markdown"]:first-child { - margin-top: 0; - } } [data-slot="session-turn-diffs"] { diff --git a/packages/ui/src/components/timeline-playground.stories.tsx b/packages/ui/src/components/timeline-playground.stories.tsx new file mode 100644 index 0000000000..16bf2591a5 --- /dev/null +++ b/packages/ui/src/components/timeline-playground.stories.tsx @@ -0,0 +1,1579 @@ +// @ts-nocheck +import { createSignal, createMemo, For, Show, Index, batch } from "solid-js" +import { createStore, produce } from "solid-js/store" +import type { + Message, + UserMessage, + AssistantMessage, + Part, + TextPart, + ReasoningPart, + ToolPart, + CompactionPart, + FilePart, + AgentPart, +} from "@opencode-ai/sdk/v2" +import { DataProvider } from "../context/data" +import { FileComponentProvider } from "../context/file" +import { SessionTurn } from "./session-turn" + +// --------------------------------------------------------------------------- +// ID helpers +// --------------------------------------------------------------------------- +let seq = 0 +const uid = () => `pg-${++seq}-${Date.now().toString(36)}` + +// --------------------------------------------------------------------------- +// Lorem ipsum content +// --------------------------------------------------------------------------- +const LOREM = [ + "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua.", + "Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat.", + "Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur.", + "Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum.", + "Cras justo odio, dapibus ut facilisis in, egestas eget quam. Vestibulum id ligula porta felis euismod semper.", +] + +// --------------------------------------------------------------------------- +// User message variants +// --------------------------------------------------------------------------- +const USER_VARIANTS = { + short: { + label: "short", + text: "Fix the bug in the login form", + parts: [] as Part[], + }, + medium: { + label: "medium", + text: "Can you update the session timeline component to support lazy loading? The current implementation loads everything eagerly which causes jank on large sessions.", + parts: [] as Part[], + }, + long: { + label: "long", + text: `I need you to refactor the message rendering pipeline. Currently the timeline renders all messages synchronously which blocks first paint. Here's what I want: + +1. Implement virtual scrolling for the message list +2. Defer-mount older messages using requestAnimationFrame batching +3. Add content-visibility: auto to each turn container +4. Make sure the scroll-to-bottom behavior still works correctly after these changes + +Please also add appropriate CSS containment hints and make sure we don't break the sticky header behavior for the session title.`, + parts: [] as Part[], + }, + "with @file": { + label: "with @file", + text: "Update @src/components/session-turn.tsx to fix the spacing issue between parts", + parts: (() => { + const id = `static-file-${Date.now()}` + return [ + { + id, + type: "file", + mime: "text/plain", + filename: "session-turn.tsx", + url: "src/components/session-turn.tsx", + source: { + type: "file", + path: "src/components/session-turn.tsx", + text: { + value: "@src/components/session-turn.tsx", + start: 7, + end: 38, + }, + }, + } as FilePart, + ] + })(), + }, + "with @agent": { + label: "with @agent", + text: "Use @explore to find all CSS files related to the timeline, then fix the spacing", + parts: (() => { + return [ + { + id: `static-agent-${Date.now()}`, + type: "agent", + name: "explore", + source: { start: 4, end: 12 }, + } as AgentPart, + ] + })(), + }, + "with image": { + label: "with image", + text: "Here's a screenshot of the bug I'm seeing", + parts: (() => { + // 1x1 blue pixel PNG as data URI for a realistic attachment + const pixel = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + return [ + { + id: `static-img-${Date.now()}`, + type: "file", + mime: "image/png", + filename: "screenshot.png", + url: pixel, + } as FilePart, + ] + })(), + }, + "with file attachment": { + label: "with file attachment", + text: "Check this config file for issues", + parts: (() => { + return [ + { + id: `static-attach-${Date.now()}`, + type: "file", + mime: "application/json", + filename: "tsconfig.json", + url: "data:application/json;base64,e30=", + } as FilePart, + ] + })(), + }, + "multi attachment": { + label: "multi attachment", + text: "Look at these files and the screenshot, then fix the layout", + parts: (() => { + const pixel = + "data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==" + return [ + { + id: `static-multi-img-${Date.now()}`, + type: "file", + mime: "image/png", + filename: "layout-bug.png", + url: pixel, + } as FilePart, + { + id: `static-multi-file-${Date.now()}`, + type: "file", + mime: "text/css", + filename: "session-turn.css", + url: "data:text/css;base64,LyogZW1wdHkgKi8=", + } as FilePart, + { + id: `static-multi-ref-${Date.now()}`, + type: "file", + mime: "text/plain", + filename: "session-turn.tsx", + url: "src/components/session-turn.tsx", + source: { + type: "file", + path: "src/components/session-turn.tsx", + text: { value: "@src/components/session-turn.tsx", start: 0, end: 0 }, + }, + } as FilePart, + ] + })(), + }, +} satisfies Record + +const MARKDOWN_SAMPLES = { + headings: `# Heading 1 +## Heading 2 +### Heading 3 +#### Heading 4 + +Some paragraph text after headings.`, + + lists: `Here's a list of changes: + +- First item with some explanation +- Second item that is a bit longer and wraps to the next line when the viewport is narrow +- Third item + - Nested item A + - Nested item B + +1. Numbered first +2. Numbered second +3. Numbered third`, + + code: `Here's an inline \`variable\` reference and a code block: + +\`\`\`typescript +export function sum(values: number[]) { + return values.reduce((total, value) => total + value, 0) +} + +export function average(values: number[]) { + if (values.length === 0) return 0 + return sum(values) / values.length +} +\`\`\` + +And some text after the code block.`, + + mixed: `## Implementation Plan + +I'll make the following changes: + +1. **Update the schema** - Add new fields to the database model +2. **Create the API endpoint** - Handle validation and persistence +3. **Add frontend components** - Build the form and display views + +Here's the key change: + +\`\`\`typescript +const table = sqliteTable("session", { + id: text().primaryKey(), + project_id: text().notNull(), + created_at: integer().notNull(), +}) +\`\`\` + +> Note: This is a breaking change that requires a migration. + +The migration will handle existing data by setting \`project_id\` to the default workspace. + +--- + +For more details, see the [documentation](https://example.com/docs).`, + + table: `## Comparison + +| Feature | Before | After | +|---------|--------|-------| +| Speed | 120ms | 45ms | +| Memory | 256MB | 128MB | +| Bundle | 1.2MB | 890KB | + +The improvements are significant across all metrics.`, + + blockquote: `## Summary + +> This is a blockquote that contains important information about the implementation approach. +> +> It spans multiple lines and contains **bold** and \`code\` elements. + +The approach above was chosen for its simplicity.`, + + links: `Check out these resources: + +- [SolidJS docs](https://solidjs.com) +- [TypeScript handbook](https://www.typescriptlang.org/docs/handbook) +- The API is at \`https://api.example.com/v2\` + +You can also visit https://example.com/docs for more info.`, + + images: `## Screenshot + +Here's what the output looks like: + +![Alt text](https://via.placeholder.com/400x200) + +And below is the final result.`, +} + +const REASONING_SAMPLES = [ + `**Analyzing the request** + +The user wants to add a new feature to the session timeline. I need to understand the existing component structure first. + +Let me look at the key files involved: +- \`session-turn.tsx\` handles individual turns +- \`message-part.tsx\` renders different part types +- The data flows through the \`DataProvider\` context`, + + `**Considering approaches** + +I could either modify the existing SessionTurn component or create a wrapper. The wrapper approach is cleaner because it doesn't touch the core rendering logic. + +The trade-off is that we'd need to pass additional props through, but that's acceptable for this use case.`, + + `**Planning the implementation** + +I'll need to: +1. Create the data generators +2. Wire up the context providers +3. Add CSS variable controls +4. Implement the export functionality + +This should be straightforward given the existing component architecture.`, +] + +const TOOL_SAMPLES = { + read: { + tool: "read", + input: { filePath: "src/components/session-turn.tsx", offset: 1, limit: 50 }, + output: "export function SessionTurn(props) {\n // component implementation\n return
...
\n}", + title: "Read src/components/session-turn.tsx", + metadata: {}, + }, + glob: { + tool: "glob", + input: { pattern: "**/*.tsx", path: "src/components" }, + output: "src/components/button.tsx\nsrc/components/card.tsx\nsrc/components/session-turn.tsx", + title: "Found 3 files", + metadata: {}, + }, + grep: { + tool: "grep", + input: { pattern: "SessionTurn", path: "src", include: "*.tsx" }, + output: "src/components/session-turn.tsx:141\nsrc/pages/session/timeline.tsx:987", + title: "Found 2 matches", + metadata: {}, + }, + bash: { + tool: "bash", + input: { command: "bun test --filter session", description: "Run session tests" }, + output: + "bun test v1.3.11\n\n✓ session-turn.test.tsx (3 tests) 45ms\n✓ message-part.test.tsx (7 tests) 120ms\n\nTest Suites: 2 passed, 2 total\nTests: 10 passed, 10 total\nTime: 0.89s", + title: "Run session tests", + metadata: { command: "bun test --filter session" }, + }, + edit: { + tool: "edit", + input: { + filePath: "src/components/session-turn.tsx", + oldString: "gap: 12px", + newString: "gap: 18px", + }, + output: "File edited successfully", + title: "Edit src/components/session-turn.tsx", + metadata: { + filediff: { + file: "src/components/session-turn.tsx", + before: " gap: 12px;\n display: flex;", + after: " gap: 18px;\n display: flex;", + additions: 1, + deletions: 1, + }, + }, + }, + write: { + tool: "write", + input: { + filePath: "src/utils/helpers.ts", + content: + "export function clamp(value: number, min: number, max: number) {\n return Math.min(Math.max(value, min), max)\n}\n", + }, + output: "File written successfully", + title: "Write src/utils/helpers.ts", + metadata: {}, + }, + task: { + tool: "task", + input: { description: "Explore components", subagent_type: "explore", prompt: "Find all session components" }, + output: "Found 12 session-related components across 3 directories.", + title: "Agent (Explore)", + metadata: { sessionId: "sub-session-1" }, + }, + webfetch: { + tool: "webfetch", + input: { url: "https://solidjs.com/docs/latest/api" }, + output: "# SolidJS API Reference\n\nCore primitives for building reactive applications...", + title: "Fetch https://solidjs.com/docs/latest/api", + metadata: {}, + }, + websearch: { + tool: "websearch", + input: { query: "SolidJS createStore performance" }, + output: + "https://solidjs.com/docs/latest/api#createstore\nhttps://dev.to/solidjs/understanding-solid-reactivity\nhttps://github.com/solidjs/solid/discussions/1234", + title: "Search: SolidJS createStore performance", + metadata: {}, + }, + question: { + tool: "question", + input: { + questions: [ + { + question: "Which approach do you prefer?", + header: "Approach", + options: [ + { label: "Wrapper component", description: "Create a new wrapper around SessionTurn" }, + { label: "Direct modification", description: "Modify SessionTurn directly" }, + ], + }, + ], + }, + output: "", + title: "Question", + metadata: { answers: [["Wrapper component"]] }, + }, + skill: { + tool: "skill", + input: { name: "playwriter" }, + output: "Skill loaded successfully", + title: "playwriter", + metadata: {}, + }, + todowrite: { + tool: "todowrite", + input: { + todos: [ + { content: "Create data generators", status: "completed", priority: "high" }, + { content: "Build UI controls", status: "in_progress", priority: "high" }, + { content: "Add CSS export", status: "pending", priority: "medium" }, + ], + }, + output: "", + title: "Todos", + metadata: { + todos: [ + { content: "Create data generators", status: "completed", priority: "high" }, + { content: "Build UI controls", status: "in_progress", priority: "high" }, + { content: "Add CSS export", status: "pending", priority: "medium" }, + ], + }, + }, +} + +// --------------------------------------------------------------------------- +// Fake data generators +// --------------------------------------------------------------------------- +const SESSION_ID = "playground-session" + +function mkUser(text: string, extra: Part[] = []): { message: UserMessage; parts: Part[] } { + const id = uid() + return { + message: { + id, + sessionID: SESSION_ID, + role: "user", + time: { created: Date.now() }, + agent: "code", + model: { providerID: "anthropic", modelID: "claude-sonnet-4-20250514" }, + } as UserMessage, + parts: [ + { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart, + // Clone extra parts with fresh ids so each user message owns unique part instances + ...extra.map((p) => ({ ...p, id: uid() })), + ], + } +} + +function mkAssistant(parentID: string): AssistantMessage { + return { + id: uid(), + sessionID: SESSION_ID, + role: "assistant", + time: { created: Date.now(), completed: Date.now() + 3000 }, + parentID, + modelID: "claude-sonnet-4-20250514", + providerID: "anthropic", + mode: "default", + agent: "code", + path: { cwd: "/project", root: "/project" }, + cost: 0.003, + tokens: { input: 1200, output: 800, reasoning: 200, cache: { read: 0, write: 0 } }, + } as AssistantMessage +} + +function textPart(text: string): TextPart { + return { id: uid(), type: "text", text, time: { created: Date.now() } } as TextPart +} + +function reasoningPart(text: string): ReasoningPart { + return { id: uid(), type: "reasoning", text, time: { start: Date.now(), end: Date.now() + 500 } } as ReasoningPart +} + +function toolPart(sample: (typeof TOOL_SAMPLES)[keyof typeof TOOL_SAMPLES], status = "completed"): ToolPart { + const base = { + id: uid(), + type: "tool" as const, + callID: uid(), + tool: sample.tool, + } + if (status === "completed") { + return { + ...base, + state: { + status: "completed", + input: sample.input, + output: sample.output, + title: sample.title, + metadata: sample.metadata ?? {}, + time: { start: Date.now(), end: Date.now() + 1000 }, + }, + } as ToolPart + } + if (status === "running") { + return { + ...base, + state: { + status: "running", + input: sample.input, + title: sample.title, + metadata: sample.metadata ?? {}, + time: { start: Date.now() }, + }, + } as ToolPart + } + return { + ...base, + state: { status: "pending", input: sample.input, raw: "" }, + } as ToolPart +} + +function compactionPart(): CompactionPart { + return { id: uid(), type: "compaction", auto: true } as CompactionPart +} + +// --------------------------------------------------------------------------- +// CSS Controls definition +// --------------------------------------------------------------------------- +type CSSControl = { + key: string + label: string + group: string + type: "range" | "color" | "select" + initial: string + selector: string + property: string + min?: string + max?: string + step?: string + options?: string[] + unit?: string +} + +const CSS_CONTROLS: CSSControl[] = [ + // --- Timeline spacing --- + { + key: "turn-gap", + label: "Turn gap", + group: "Timeline Spacing", + type: "range", + initial: "12", + selector: '[role="log"]', + property: "gap", + min: "0", + max: "80", + step: "1", + unit: "px", + }, + { + key: "container-gap", + label: "Container gap", + group: "Timeline Spacing", + type: "range", + initial: "18", + selector: '[data-slot="session-turn-message-container"]', + property: "gap", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "assistant-gap", + label: "Assistant parts gap", + group: "Timeline Spacing", + type: "range", + initial: "12", + selector: '[data-slot="session-turn-assistant-content"]', + property: "gap", + min: "0", + max: "40", + step: "1", + unit: "px", + }, + { + key: "text-part-margin", + label: "Text part margin-top", + group: "Timeline Spacing", + type: "range", + initial: "24", + selector: '[data-component="text-part"]', + property: "margin-top", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + + // --- Markdown typography --- + { + key: "md-font-size", + label: "Font size", + group: "Markdown Typography", + type: "range", + initial: "14", + selector: '[data-component="markdown"]', + property: "font-size", + min: "10", + max: "22", + step: "1", + unit: "px", + }, + { + key: "md-line-height", + label: "Line height", + group: "Markdown Typography", + type: "range", + initial: "180", + selector: '[data-component="markdown"]', + property: "line-height", + min: "100", + max: "300", + step: "5", + unit: "%", + }, + + // --- Markdown headings --- + { + key: "md-heading-margin-top", + label: "Heading margin-top", + group: "Markdown Headings", + type: "range", + initial: "32", + selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)', + property: "margin-top", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "md-heading-margin-bottom", + label: "Heading margin-bottom", + group: "Markdown Headings", + type: "range", + initial: "12", + selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)', + property: "margin-bottom", + min: "0", + max: "40", + step: "1", + unit: "px", + }, + { + key: "md-heading-font-size", + label: "Heading font size", + group: "Markdown Headings", + type: "range", + initial: "14", + selector: '[data-component="markdown"] :is(h1,h2,h3,h4,h5,h6)', + property: "font-size", + min: "12", + max: "28", + step: "1", + unit: "px", + }, + + // --- Markdown paragraphs --- + { + key: "md-p-margin-bottom", + label: "Paragraph margin-bottom", + group: "Markdown Paragraphs", + type: "range", + initial: "16", + selector: '[data-component="markdown"] p', + property: "margin-bottom", + min: "0", + max: "40", + step: "1", + unit: "px", + }, + + // --- Markdown lists --- + { + key: "md-list-margin-top", + label: "List margin-top", + group: "Markdown Lists", + type: "range", + initial: "8", + selector: '[data-component="markdown"] :is(ul,ol)', + property: "margin-top", + min: "0", + max: "40", + step: "1", + unit: "px", + }, + { + key: "md-list-margin-bottom", + label: "List margin-bottom", + group: "Markdown Lists", + type: "range", + initial: "16", + selector: '[data-component="markdown"] :is(ul,ol)', + property: "margin-bottom", + min: "0", + max: "40", + step: "1", + unit: "px", + }, + { + key: "md-list-padding-left", + label: "List padding-left", + group: "Markdown Lists", + type: "range", + initial: "24", + selector: '[data-component="markdown"] :is(ul,ol)', + property: "padding-left", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "md-li-margin-bottom", + label: "List item margin-bottom", + group: "Markdown Lists", + type: "range", + initial: "8", + selector: '[data-component="markdown"] li', + property: "margin-bottom", + min: "0", + max: "20", + step: "1", + unit: "px", + }, + + // --- Markdown code blocks --- + { + key: "md-pre-margin-top", + label: "Code block margin-top", + group: "Markdown Code", + type: "range", + initial: "32", + selector: '[data-component="markdown"] pre', + property: "margin-top", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "md-pre-margin-bottom", + label: "Code block margin-bottom", + group: "Markdown Code", + type: "range", + initial: "32", + selector: '[data-component="markdown"] pre', + property: "margin-bottom", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "md-shiki-font-size", + label: "Code font size", + group: "Markdown Code", + type: "range", + initial: "13", + selector: '[data-component="markdown"] .shiki', + property: "font-size", + min: "10", + max: "20", + step: "1", + unit: "px", + }, + { + key: "md-shiki-padding", + label: "Code padding", + group: "Markdown Code", + type: "range", + initial: "12", + selector: '[data-component="markdown"] .shiki', + property: "padding", + min: "0", + max: "32", + step: "1", + unit: "px", + }, + { + key: "md-shiki-radius", + label: "Code border-radius", + group: "Markdown Code", + type: "range", + initial: "6", + selector: '[data-component="markdown"] .shiki', + property: "border-radius", + min: "0", + max: "16", + step: "1", + unit: "px", + }, + + // --- Markdown blockquotes --- + { + key: "md-blockquote-margin", + label: "Blockquote margin", + group: "Markdown Blockquotes", + type: "range", + initial: "24", + selector: '[data-component="markdown"] blockquote', + property: "margin-block", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "md-blockquote-padding-left", + label: "Blockquote padding-left", + group: "Markdown Blockquotes", + type: "range", + initial: "8", + selector: '[data-component="markdown"] blockquote', + property: "padding-left", + min: "0", + max: "40", + step: "1", + unit: "px", + }, + { + key: "md-blockquote-border-width", + label: "Blockquote border width", + group: "Markdown Blockquotes", + type: "range", + initial: "2", + selector: '[data-component="markdown"] blockquote', + property: "border-left-width", + min: "0", + max: "8", + step: "1", + unit: "px", + }, + + // --- Markdown tables --- + { + key: "md-table-margin", + label: "Table margin", + group: "Markdown Tables", + type: "range", + initial: "24", + selector: '[data-component="markdown"] table', + property: "margin-block", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + { + key: "md-td-padding", + label: "Cell padding", + group: "Markdown Tables", + type: "range", + initial: "12", + selector: '[data-component="markdown"] :is(th,td)', + property: "padding", + min: "0", + max: "24", + step: "1", + unit: "px", + }, + + // --- Markdown HR --- + { + key: "md-hr-margin", + label: "HR margin", + group: "Markdown HR", + type: "range", + initial: "40", + selector: '[data-component="markdown"] hr', + property: "margin-block", + min: "0", + max: "80", + step: "1", + unit: "px", + }, + + // --- Reasoning part --- + { + key: "reasoning-md-margin-top", + label: "Reasoning markdown margin-top", + group: "Reasoning Part", + type: "range", + initial: "24", + selector: '[data-component="reasoning-part"] [data-component="markdown"]', + property: "margin-top", + min: "0", + max: "60", + step: "1", + unit: "px", + }, + + // --- User message --- + { + key: "user-msg-padding", + label: "User bubble padding", + group: "User Message", + type: "range", + initial: "12", + selector: '[data-slot="user-message-text"]', + property: "padding", + min: "0", + max: "32", + step: "1", + unit: "px", + }, + { + key: "user-msg-radius", + label: "User bubble border-radius", + group: "User Message", + type: "range", + initial: "6", + selector: '[data-slot="user-message-text"]', + property: "border-radius", + min: "0", + max: "24", + step: "1", + unit: "px", + }, + + // --- Tool parts --- + { + key: "bash-max-height", + label: "Shell output max-height", + group: "Tool Parts", + type: "range", + initial: "240", + selector: '[data-slot="bash-scroll"]', + property: "max-height", + min: "100", + max: "600", + step: "10", + unit: "px", + }, +] + +// --------------------------------------------------------------------------- +// Playground component +// --------------------------------------------------------------------------- +function FileStub() { + return
File viewer stub
+} + +function Playground() { + // ---- Messages & parts state ---- + const [state, setState] = createStore<{ + messages: Message[] + parts: Record + }>({ + messages: [], + parts: {}, + }) + + // ---- CSS overrides ---- + const [css, setCss] = createStore>({}) + let styleEl: HTMLStyleElement | undefined + + const updateStyle = () => { + const rules: string[] = [] + for (const ctrl of CSS_CONTROLS) { + const val = css[ctrl.key] + if (val === undefined) continue + const value = ctrl.unit ? `${val}${ctrl.unit}` : val + rules.push(`${ctrl.selector} { ${ctrl.property}: ${value} !important; }`) + } + if (styleEl) styleEl.textContent = rules.join("\n") + } + + const setCssValue = (key: string, value: string) => { + setCss(key, value) + updateStyle() + } + + const resetCss = () => { + batch(() => { + for (const ctrl of CSS_CONTROLS) { + setCss(ctrl.key, undefined as any) + } + }) + if (styleEl) styleEl.textContent = "" + } + + // ---- Derived ---- + const userMessages = createMemo(() => state.messages.filter((m): m is UserMessage => m.role === "user")) + + const data = createMemo(() => ({ + session: [{ id: SESSION_ID }], + session_status: {}, + session_diff: {}, + message: { [SESSION_ID]: state.messages }, + part: state.parts, + provider: { + all: [{ id: "anthropic", models: { "claude-sonnet-4-20250514": { name: "Claude Sonnet" } } }], + }, + })) + + // ---- Find or create the last assistant message to append parts to ---- + const lastAssistantID = createMemo(() => { + for (let i = state.messages.length - 1; i >= 0; i--) { + if (state.messages[i].role === "assistant") return state.messages[i].id + } + return undefined + }) + + /** Ensure a turn (user + assistant) exists and return the assistant message id */ + const ensureTurn = (): string => { + const id = lastAssistantID() + if (id) return id + // Create a minimal placeholder turn + const user = mkUser("...") + const asst = mkAssistant(user.message.id) + setState( + produce((draft) => { + draft.messages.push(user.message) + draft.messages.push(asst) + draft.parts[user.message.id] = user.parts + draft.parts[asst.id] = [] + }), + ) + return asst.id + } + + /** Append parts to the last assistant message */ + const appendParts = (parts: Part[]) => { + const id = ensureTurn() + setState( + produce((draft) => { + const existing = draft.parts[id] ?? [] + draft.parts[id] = [...existing, ...parts] + }), + ) + } + + // ---- User message helpers ---- + const addUser = (variant: keyof typeof USER_VARIANTS) => { + const v = USER_VARIANTS[variant] + const user = mkUser(v.text, v.parts) + const asst = mkAssistant(user.message.id) + setState( + produce((draft) => { + draft.messages.push(user.message) + draft.messages.push(asst) + draft.parts[user.message.id] = user.parts + draft.parts[asst.id] = [] + }), + ) + } + + // ---- Part helpers (append to last turn) ---- + const addText = (variant: keyof typeof MARKDOWN_SAMPLES) => { + appendParts([textPart(MARKDOWN_SAMPLES[variant])]) + } + + const addReasoning = () => { + const idx = Math.floor(Math.random() * REASONING_SAMPLES.length) + appendParts([reasoningPart(REASONING_SAMPLES[idx])]) + } + + const addTool = (name: keyof typeof TOOL_SAMPLES) => { + appendParts([toolPart(TOOL_SAMPLES[name])]) + } + + // ---- Composite helpers (create full turns with user + assistant) ---- + const addFullTurn = (userText: string, parts: Part[]) => { + const user = mkUser(userText) + const asst = mkAssistant(user.message.id) + setState( + produce((draft) => { + draft.messages.push(user.message) + draft.messages.push(asst) + draft.parts[user.message.id] = user.parts + draft.parts[asst.id] = parts + }), + ) + } + + const addContextGroupTurn = () => { + addFullTurn("Read some files", [ + toolPart(TOOL_SAMPLES.read), + toolPart(TOOL_SAMPLES.glob), + toolPart(TOOL_SAMPLES.grep), + textPart("After gathering context, here's what I found:\n\n" + LOREM[2]), + ]) + } + + const addReasoningFullTurn = () => { + addFullTurn("Make the changes described above", [ + reasoningPart(REASONING_SAMPLES[0]), + toolPart(TOOL_SAMPLES.read), + toolPart(TOOL_SAMPLES.glob), + toolPart(TOOL_SAMPLES.grep), + toolPart(TOOL_SAMPLES.edit), + toolPart(TOOL_SAMPLES.bash), + textPart(MARKDOWN_SAMPLES.mixed), + ]) + } + + const addKitchenSink = () => { + // User message variants + addUser("short") + appendParts([textPart(MARKDOWN_SAMPLES.headings)]) + addUser("medium") + appendParts([textPart(MARKDOWN_SAMPLES.lists)]) + addUser("long") + appendParts([textPart(MARKDOWN_SAMPLES.code)]) + addUser("with @file") + appendParts([textPart(MARKDOWN_SAMPLES.mixed)]) + addUser("with image") + appendParts([reasoningPart(REASONING_SAMPLES[0]), textPart(MARKDOWN_SAMPLES.table)]) + addUser("multi attachment") + appendParts([ + toolPart(TOOL_SAMPLES.read), + toolPart(TOOL_SAMPLES.glob), + toolPart(TOOL_SAMPLES.grep), + toolPart(TOOL_SAMPLES.edit), + toolPart(TOOL_SAMPLES.bash), + textPart(MARKDOWN_SAMPLES.blockquote), + ]) + addContextGroupTurn() + addReasoningFullTurn() + } + + const clearAll = () => { + setState({ messages: [], parts: {} }) + seq = 0 + } + + // ---- CSS export ---- + const exportCss = () => { + const lines: string[] = ["/* Timeline Playground CSS Overrides */", ""] + const groups = new Map() + + for (const ctrl of CSS_CONTROLS) { + const val = css[ctrl.key] + if (val === undefined) continue + const value = ctrl.unit ? `${val}${ctrl.unit}` : val + const group = ctrl.group + if (!groups.has(group)) groups.set(group, []) + groups.get(group)!.push(`/* ${ctrl.label}: ${value} */`) + groups.get(group)!.push(`${ctrl.selector} { ${ctrl.property}: ${value}; }`) + } + + if (groups.size === 0) { + lines.push("/* No overrides applied */") + } else { + for (const [group, rules] of groups) { + lines.push(`/* --- ${group} --- */`) + lines.push(...rules) + lines.push("") + } + } + + const text = lines.join("\n") + navigator.clipboard.writeText(text).catch(() => {}) + return text + } + + const [exported, setExported] = createSignal("") + + // ---- Panel collapse state ---- + const [panels, setPanels] = createStore({ + generators: true, + css: true, + export: false, + }) + + // ---- Group collapse state for CSS ---- + const [collapsed, setCollapsed] = createStore>({}) + const groups = createMemo(() => { + const result = new Map() + for (const ctrl of CSS_CONTROLS) { + if (!result.has(ctrl.group)) result.set(ctrl.group, []) + result.get(ctrl.group)!.push(ctrl) + } + return result + }) + + // ---- Shared button styles ---- + const sectionLabel = { + "font-size": "11px", + color: "var(--text-weak)", + "margin-bottom": "4px", + "text-transform": "uppercase", + "letter-spacing": "0.5px", + } as const + const btnStyle = { + padding: "4px 8px", + "border-radius": "4px", + border: "1px solid var(--border-weak-base)", + background: "var(--surface-base)", + cursor: "pointer", + "font-size": "12px", + color: "var(--text-base)", + } as const + const btnAccent = { + ...btnStyle, + border: "1px solid var(--border-interactive-base)", + background: "var(--surface-interactive-weak)", + "font-weight": "500", + color: "var(--text-interactive-base)", + } as const + const btnDanger = { + ...btnStyle, + border: "1px solid var(--border-critical-base)", + background: "transparent", + color: "var(--text-on-critical-base)", + } as const + + return ( +
+ {/* Inject dynamic style element */} +