From 229cdafcc43ef53611a12dc2e8137575669e8706 Mon Sep 17 00:00:00 2001 From: Hank Stoever Date: Thu, 5 Feb 2026 18:10:35 -0800 Subject: [PATCH 01/68] fix(config): handle $ character with {file:} pattern (#12390) Co-authored-by: Hank Stoever <1109058+hstove@users.noreply.github.com> --- packages/opencode/src/config/config.ts | 2 +- packages/opencode/test/config/config.test.ts | 19 +++++++++++++++++++ 2 files changed, 20 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index ed1b155003..c56bd6c781 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -1267,7 +1267,7 @@ export namespace Config { }) ).trim() // escape newlines/quotes, strip outer quotes - text = text.replace(match, JSON.stringify(fileContent).slice(1, -1)) + text = text.replace(match, () => JSON.stringify(fileContent).slice(1, -1)) } } diff --git a/packages/opencode/test/config/config.test.ts b/packages/opencode/test/config/config.test.ts index dee6331106..91b87f6498 100644 --- a/packages/opencode/test/config/config.test.ts +++ b/packages/opencode/test/config/config.test.ts @@ -193,6 +193,25 @@ test("handles file inclusion substitution", async () => { }) }) +test("handles file inclusion with replacement tokens", async () => { + await using tmp = await tmpdir({ + init: async (dir) => { + await Bun.write(path.join(dir, "included.md"), "const out = await Bun.$`echo hi`") + await writeConfig(dir, { + $schema: "https://opencode.ai/config.json", + theme: "{file:included.md}", + }) + }, + }) + await Instance.provide({ + directory: tmp.path, + fn: async () => { + const config = await Config.get() + expect(config.theme).toBe("const out = await Bun.$`echo hi`") + }, + }) +}) + test("validates config schema and throws on invalid fields", async () => { await using tmp = await tmpdir({ init: async (dir) => { From 683d234d805e4d1097751d3cd583117856e41de5 Mon Sep 17 00:00:00 2001 From: Akshar Patel <123344143+AksharP5@users.noreply.github.com> Date: Thu, 5 Feb 2026 21:11:08 -0500 Subject: [PATCH 02/68] feat(tui): highlight esc label on hover in dialog (#12383) --- .../cli/cmd/tui/component/dialog-provider.tsx | 14 +++++++++++--- .../src/cli/cmd/tui/component/dialog-status.tsx | 16 ++++++++++++---- .../opencode/src/cli/cmd/tui/ui/dialog-alert.tsx | 15 ++++++++++++--- .../src/cli/cmd/tui/ui/dialog-confirm.tsx | 16 ++++++++++++---- .../src/cli/cmd/tui/ui/dialog-export-options.tsx | 16 ++++++++++++---- .../opencode/src/cli/cmd/tui/ui/dialog-help.tsx | 15 ++++++++++++--- .../src/cli/cmd/tui/ui/dialog-prompt.tsx | 16 ++++++++++++---- .../src/cli/cmd/tui/ui/dialog-select.tsx | 16 ++++++++++++---- 8 files changed, 95 insertions(+), 29 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx index 93e76cbdfd..f8be5577b3 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-provider.tsx @@ -124,6 +124,7 @@ function AutoMethod(props: AutoMethodProps) { const dialog = useDialog() const sync = useSync() const toast = useToast() + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "c" && !evt.ctrl && !evt.meta) { @@ -154,9 +155,16 @@ function AutoMethod(props: AutoMethodProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + diff --git a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx index 3e6e309514..e2ab579a97 100644 --- a/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx +++ b/packages/opencode/src/cli/cmd/tui/component/dialog-status.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog } from "@tui/ui/dialog" import { useSync } from "@tui/context/sync" -import { For, Match, Switch, Show, createMemo } from "solid-js" +import { For, Match, Switch, Show, createMemo, createSignal } from "solid-js" import { Installation } from "@/installation" export type DialogStatusProps = {} @@ -11,6 +11,7 @@ export function DialogStatus() { const sync = useSync() const { theme } = useTheme() const dialog = useDialog() + const [hover, setHover] = createSignal(false) const enabledFormatters = createMemo(() => sync.data.formatter.filter((f) => f.enabled)) @@ -45,9 +46,16 @@ export function DialogStatus() { Status - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + OpenCode v{Installation.VERSION} 0} fallback={No MCP Servers}> diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx index 642c73b485..8b4b614767 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-alert.tsx @@ -2,6 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { useKeyboard } from "@opentui/solid" +import { createSignal } from "solid-js" export type DialogAlertProps = { title: string @@ -12,6 +13,7 @@ export type DialogAlertProps = { export function DialogAlert(props: DialogAlertProps) { const dialog = useDialog() const { theme } = useTheme() + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return") { @@ -25,9 +27,16 @@ export function DialogAlert(props: DialogAlertProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + {props.message} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx index b86bd43251..7d9b74cdea 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-confirm.tsx @@ -2,7 +2,7 @@ import { TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { For } from "solid-js" +import { createSignal, For } from "solid-js" import { useKeyboard } from "@opentui/solid" import { Locale } from "@/util/locale" @@ -16,6 +16,7 @@ export type DialogConfirmProps = { export function DialogConfirm(props: DialogConfirmProps) { const dialog = useDialog() const { theme } = useTheme() + const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ active: "confirm" as "confirm" | "cancel", }) @@ -37,9 +38,16 @@ export function DialogConfirm(props: DialogConfirmProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + {props.message} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx index 1e8d09bb0b..957467c5d7 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-export-options.tsx @@ -2,7 +2,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" import { createStore } from "solid-js/store" -import { onMount, Show, type JSX } from "solid-js" +import { createSignal, onMount, Show, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogExportOptionsProps = { @@ -25,6 +25,7 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { const dialog = useDialog() const { theme } = useTheme() let textarea: TextareaRenderable + const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ thinking: props.defaultThinking, toolDetails: props.defaultToolDetails, @@ -80,9 +81,16 @@ export function DialogExportOptions(props: DialogExportOptionsProps) { Export Options - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx index 4e45279303..f56347d4af 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-help.tsx @@ -3,11 +3,13 @@ import { useTheme } from "@tui/context/theme" import { useDialog } from "./dialog" import { useKeyboard } from "@opentui/solid" import { useKeybind } from "@tui/context/keybind" +import { createSignal } from "solid-js" export function DialogHelp() { const dialog = useDialog() const { theme } = useTheme() const keybind = useKeybind() + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return" || evt.name === "escape") { @@ -21,9 +23,16 @@ export function DialogHelp() { Help - dialog.clear()}> - esc/enter - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc/enter + diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx index b1b05a0f1a..03814e17d9 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-prompt.tsx @@ -1,7 +1,7 @@ import { TextareaRenderable, TextAttributes } from "@opentui/core" import { useTheme } from "../context/theme" import { useDialog, type DialogContext } from "./dialog" -import { onMount, type JSX } from "solid-js" +import { createSignal, onMount, type JSX } from "solid-js" import { useKeyboard } from "@opentui/solid" export type DialogPromptProps = { @@ -17,6 +17,7 @@ export function DialogPrompt(props: DialogPromptProps) { const dialog = useDialog() const { theme } = useTheme() let textarea: TextareaRenderable + const [hover, setHover] = createSignal(false) useKeyboard((evt) => { if (evt.name === "return") { @@ -39,9 +40,16 @@ export function DialogPrompt(props: DialogPromptProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + {props.description} diff --git a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx index 490a100721..7792900bcf 100644 --- a/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx +++ b/packages/opencode/src/cli/cmd/tui/ui/dialog-select.tsx @@ -1,7 +1,7 @@ import { InputRenderable, RGBA, ScrollBoxRenderable, TextAttributes } from "@opentui/core" import { useTheme, selectedForeground } from "@tui/context/theme" import { entries, filter, flatMap, groupBy, pipe, take } from "remeda" -import { batch, createEffect, createMemo, For, Show, type JSX, on } from "solid-js" +import { batch, createEffect, createMemo, createSignal, For, Show, type JSX, on } from "solid-js" import { createStore } from "solid-js/store" import { useKeyboard, useTerminalDimensions } from "@opentui/solid" import * as fuzzysort from "fuzzysort" @@ -49,6 +49,7 @@ export type DialogSelectRef = { export function DialogSelect(props: DialogSelectProps) { const dialog = useDialog() const { theme } = useTheme() + const [hover, setHover] = createSignal(false) const [store, setStore] = createStore({ selected: 0, filter: "", @@ -226,9 +227,16 @@ export function DialogSelect(props: DialogSelectProps) { {props.title} - dialog.clear()}> - esc - + setHover(true)} + onMouseOut={() => setHover(false)} + onMouseUp={() => dialog.clear()} + > + esc + Date: Fri, 6 Feb 2026 12:15:34 +1000 Subject: [PATCH 03/68] chore: align windows test runner to blacksmith (#12364) --- .github/actions/setup-bun/action.yml | 2 +- .github/workflows/test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/actions/setup-bun/action.yml b/.github/actions/setup-bun/action.yml index 7584334a7b..65fbf0f3d6 100644 --- a/.github/actions/setup-bun/action.yml +++ b/.github/actions/setup-bun/action.yml @@ -6,7 +6,7 @@ runs: - name: Mount Bun Cache uses: useblacksmith/stickydisk@v1 with: - key: ${{ github.repository }}-bun-cache + key: ${{ github.repository }}-bun-cache-${{ runner.os }} path: ~/.bun - name: Setup Bun diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2a36c07e14..ae08c1ed6b 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -22,7 +22,7 @@ jobs: git config --global user.name "opencode" bun turbo test - name: windows - host: windows-latest + host: blacksmith-4vcpu-windows-2025 playwright: bunx playwright install workdir: packages/app command: bun test:e2e:local From 0f1fdeceda453a07499b4fdc534b96d18a53aecd Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 6 Feb 2026 00:22:06 -0500 Subject: [PATCH 04/68] zen: fix usage graph --- .../console/app/src/routes/workspace/[id]/graph-section.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx index 93cba20420..140e1e732c 100644 --- a/packages/console/app/src/routes/workspace/[id]/graph-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/graph-section.tsx @@ -1,4 +1,4 @@ -import { and, Database, eq, gte, inArray, isNull, lte, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js" +import { and, Database, eq, gte, inArray, isNull, lt, or, sql, sum } from "@opencode-ai/console-core/drizzle/index.js" import { UsageTable } from "@opencode-ai/console-core/schema/billing.sql.js" import { KeyTable } from "@opencode-ai/console-core/schema/key.sql.js" import { UserTable } from "@opencode-ai/console-core/schema/user.sql.js" @@ -27,7 +27,7 @@ async function getCosts(workspaceID: string, year: number, month: number) { "use server" return withActor(async () => { const startDate = new Date(year, month, 1) - const endDate = new Date(year, month + 1, 0) + const endDate = new Date(year, month + 1, 1) const usageData = await Database.use((tx) => tx .select({ @@ -42,7 +42,7 @@ async function getCosts(workspaceID: string, year: number, month: number) { and( eq(UsageTable.workspaceID, workspaceID), gte(UsageTable.timeCreated, startDate), - lte(UsageTable.timeCreated, endDate), + lt(UsageTable.timeCreated, endDate), ), ) .groupBy( From 266de27a0b56c29a3bdb81a5adb211f93214f5a8 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Feb 2026 00:29:43 -0500 Subject: [PATCH 05/68] feat(skill): add skill discovery from URLs via well-known RFC Implement the Agent Skills Discovery RFC to allow fetching skills from URLs: - Add 'urls' field to config.skills for specifying skill registry URLs - Create Discovery namespace in skill/discovery.ts with pull() function - Download skills from /.well-known/skills/index.json endpoints - Cache downloaded skills to ~/.cache/opencode/skills/ - Skip re-downloading existing files for efficiency Users can now configure: { "skills": { "urls": ["https://example.com/.well-known/skills/"] } } Implements: https://github.com/cloudflare/agent-skills-discovery-rfc --- packages/opencode/src/config/config.ts | 4 ++ packages/opencode/src/skill/discovery.ts | 80 ++++++++++++++++++++++++ packages/opencode/src/skill/skill.ts | 17 +++++ 3 files changed, 101 insertions(+) create mode 100644 packages/opencode/src/skill/discovery.ts diff --git a/packages/opencode/src/config/config.ts b/packages/opencode/src/config/config.ts index c56bd6c781..6dd0592d51 100644 --- a/packages/opencode/src/config/config.ts +++ b/packages/opencode/src/config/config.ts @@ -660,6 +660,10 @@ export namespace Config { export const Skills = z.object({ paths: z.array(z.string()).optional().describe("Additional paths to skill folders"), + urls: z + .array(z.string()) + .optional() + .describe("URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)"), }) export type Skills = z.infer diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts new file mode 100644 index 0000000000..12b3cb3f77 --- /dev/null +++ b/packages/opencode/src/skill/discovery.ts @@ -0,0 +1,80 @@ +import path from "path" +import { mkdir } from "fs/promises" +import { Log } from "../util/log" +import { Global } from "@/global" + +export namespace Discovery { + const log = Log.create({ service: "skill-discovery" }) + + type Index = { + skills: Array<{ + name: string + description: string + files: string[] + }> + } + + export function dir() { + return path.join(Global.Path.cache, "skills") + } + + async function get(url: string, dest: string): Promise { + if (await Bun.file(dest).exists()) return true + try { + const response = await fetch(url) + if (!response.ok) { + log.error("failed to download", { url, status: response.status }) + return false + } + const content = await response.text() + await Bun.write(dest, content) + return true + } catch (err) { + log.error("failed to download", { url, err }) + return false + } + } + + export async function pull(url: string): Promise { + const result: string[] = [] + const indexUrl = new URL("index.json", url.endsWith("/") ? url : `${url}/`).href + const cacheDir = dir() + + try { + log.info("fetching index", { url: indexUrl }) + const response = await fetch(indexUrl) + if (!response.ok) { + log.error("failed to fetch index", { url: indexUrl, status: response.status }) + return result + } + + const index = (await response.json()) as Index + if (!index.skills || !Array.isArray(index.skills)) { + log.warn("invalid index format", { url: indexUrl }) + return result + } + + for (const skill of index.skills) { + if (!skill.name || !skill.files || !Array.isArray(skill.files)) { + log.warn("invalid skill entry", { url: indexUrl, skill }) + continue + } + + const skillDir = path.join(cacheDir, skill.name) + for (const file of skill.files) { + const fileUrl = new URL(file, `${url.replace(/\/$/, "")}/${skill.name}/`).href + const localPath = path.join(skillDir, file) + await mkdir(path.dirname(localPath), { recursive: true }) + await get(fileUrl, localPath) + } + + const skillMd = path.join(skillDir, "SKILL.md") + if (await Bun.file(skillMd).exists()) result.push(skillDir) + } + } catch (err) { + log.error("failed to fetch from URL", { url, err }) + } + + return result + } +} diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index b4f4acd527..b8eb642505 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -11,6 +11,7 @@ import { Filesystem } from "@/util/filesystem" import { Flag } from "@/flag/flag" import { Bus } from "@/bus" import { Session } from "@/session" +import { Discovery } from "./discovery" export namespace Skill { const log = Log.create({ service: "skill" }) @@ -151,6 +152,22 @@ export namespace Skill { } } + // Download and load skills from URLs + for (const skillUrl of config.skills?.urls ?? []) { + const downloadedDirs = await Discovery.pull(skillUrl) + for (const dir of downloadedDirs) { + dirs.add(dir) + for await (const match of SKILL_GLOB.scan({ + cwd: dir, + absolute: true, + onlyFiles: true, + followSymlinks: true, + })) { + await addSkill(match) + } + } + } + return { skills, dirs: Array.from(dirs), From c35bd39829e3760cfc2749a91cc0de779eb677f9 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Fri, 6 Feb 2026 00:36:08 -0500 Subject: [PATCH 06/68] tui: parallelize skill downloads for faster loading Refactored skill discovery to download skills in parallel instead of sequentially, reducing load times when multiple skills need to be fetched from remote URLs. Also updated AGENTS.md with guidance on using dev branch for diffs. --- AGENTS.md | 1 + packages/opencode/src/skill/discovery.ts | 109 +++++++++++++---------- packages/opencode/src/skill/skill.ts | 6 +- 3 files changed, 67 insertions(+), 49 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index eeec0c3418..d51134c0e2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,6 +1,7 @@ - To regenerate the JavaScript SDK, run `./packages/sdk/js/script/build.ts`. - ALWAYS USE PARALLEL TOOLS WHEN APPLICABLE. - The default branch in this repo is `dev`. +- Local `main` ref may not exist; use `dev` or `origin/dev` for diffs. - Prefer automation: execute requested actions without confirmation unless blocked by missing info or safety/irreversibility. ## Style Guide diff --git a/packages/opencode/src/skill/discovery.ts b/packages/opencode/src/skill/discovery.ts index 12b3cb3f77..a4bf97d7a1 100644 --- a/packages/opencode/src/skill/discovery.ts +++ b/packages/opencode/src/skill/discovery.ts @@ -1,7 +1,7 @@ import path from "path" import { mkdir } from "fs/promises" import { Log } from "../util/log" -import { Global } from "@/global" +import { Global } from "../global" export namespace Discovery { const log = Log.create({ service: "skill-discovery" }) @@ -20,61 +20,78 @@ export namespace Discovery { async function get(url: string, dest: string): Promise { if (await Bun.file(dest).exists()) return true - try { - const response = await fetch(url) - if (!response.ok) { - log.error("failed to download", { url, status: response.status }) + return fetch(url) + .then(async (response) => { + if (!response.ok) { + log.error("failed to download", { url, status: response.status }) + return false + } + await Bun.write(dest, await response.text()) + return true + }) + .catch((err) => { + log.error("failed to download", { url, err }) return false - } - const content = await response.text() - await Bun.write(dest, content) - return true - } catch (err) { - log.error("failed to download", { url, err }) - return false - } + }) } export async function pull(url: string): Promise { const result: string[] = [] - const indexUrl = new URL("index.json", url.endsWith("/") ? url : `${url}/`).href - const cacheDir = dir() + const base = url.endsWith("/") ? url : `${url}/` + const index = new URL("index.json", base).href + const cache = dir() + const host = base.slice(0, -1) - try { - log.info("fetching index", { url: indexUrl }) - const response = await fetch(indexUrl) - if (!response.ok) { - log.error("failed to fetch index", { url: indexUrl, status: response.status }) - return result - } - - const index = (await response.json()) as Index - if (!index.skills || !Array.isArray(index.skills)) { - log.warn("invalid index format", { url: indexUrl }) - return result - } - - for (const skill of index.skills) { - if (!skill.name || !skill.files || !Array.isArray(skill.files)) { - log.warn("invalid skill entry", { url: indexUrl, skill }) - continue + log.info("fetching index", { url: index }) + const data = await fetch(index) + .then(async (response) => { + if (!response.ok) { + log.error("failed to fetch index", { url: index, status: response.status }) + return undefined } + return response + .json() + .then((json) => json as Index) + .catch((err) => { + log.error("failed to parse index", { url: index, err }) + return undefined + }) + }) + .catch((err) => { + log.error("failed to fetch index", { url: index, err }) + return undefined + }) - const skillDir = path.join(cacheDir, skill.name) - for (const file of skill.files) { - const fileUrl = new URL(file, `${url.replace(/\/$/, "")}/${skill.name}/`).href - const localPath = path.join(skillDir, file) - await mkdir(path.dirname(localPath), { recursive: true }) - await get(fileUrl, localPath) - } - - const skillMd = path.join(skillDir, "SKILL.md") - if (await Bun.file(skillMd).exists()) result.push(skillDir) - } - } catch (err) { - log.error("failed to fetch from URL", { url, err }) + if (!data?.skills || !Array.isArray(data.skills)) { + log.warn("invalid index format", { url: index }) + return result } + const list = data.skills.filter((skill) => { + if (!skill?.name || !Array.isArray(skill.files)) { + log.warn("invalid skill entry", { url: index, skill }) + return false + } + return true + }) + + await Promise.all( + list.map(async (skill) => { + const root = path.join(cache, skill.name) + await Promise.all( + skill.files.map(async (file) => { + const link = new URL(file, `${host}/${skill.name}/`).href + const dest = path.join(root, file) + await mkdir(path.dirname(dest), { recursive: true }) + await get(link, dest) + }), + ) + + const md = path.join(root, "SKILL.md") + if (await Bun.file(md).exists()) result.push(root) + }), + ) + return result } } diff --git a/packages/opencode/src/skill/skill.ts b/packages/opencode/src/skill/skill.ts index b8eb642505..42795b7ebc 100644 --- a/packages/opencode/src/skill/skill.ts +++ b/packages/opencode/src/skill/skill.ts @@ -153,9 +153,9 @@ export namespace Skill { } // Download and load skills from URLs - for (const skillUrl of config.skills?.urls ?? []) { - const downloadedDirs = await Discovery.pull(skillUrl) - for (const dir of downloadedDirs) { + for (const url of config.skills?.urls ?? []) { + const list = await Discovery.pull(url) + for (const dir of list) { dirs.add(dir) for await (const match of SKILL_GLOB.scan({ cwd: dir, From 9f00b8c8dc67a806ce02251977abdfdb305aadab Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 05:37:00 +0000 Subject: [PATCH 07/68] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 4 ++++ packages/sdk/openapi.json | 7 +++++++ 2 files changed, 11 insertions(+) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index cb1606e3f6..81df478441 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1662,6 +1662,10 @@ export type Config = { * Additional paths to skill folders */ paths?: Array + /** + * URLs to fetch skills from (e.g., https://example.com/.well-known/skills/) + */ + urls?: Array } watcher?: { ignore?: Array diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 1425a6e9c7..feb4a29a5c 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -9547,6 +9547,13 @@ "items": { "type": "string" } + }, + "urls": { + "description": "URLs to fetch skills from (e.g., https://example.com/.well-known/skills/)", + "type": "array", + "items": { + "type": "string" + } } } }, From 26e1901bd0810e9dedce498c4f7bf35e222880f3 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 6 Feb 2026 15:30:13 +0800 Subject: [PATCH 08/68] desktop: maximize main window by default (#12433) --- packages/desktop/src-tauri/src/lib.rs | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/packages/desktop/src-tauri/src/lib.rs b/packages/desktop/src-tauri/src/lib.rs index d309b918b6..e1cb89cd7a 100644 --- a/packages/desktop/src-tauri/src/lib.rs +++ b/packages/desktop/src-tauri/src/lib.rs @@ -15,7 +15,7 @@ use std::{ sync::{Arc, Mutex}, time::{Duration, Instant}, }; -use tauri::{AppHandle, LogicalSize, Manager, RunEvent, State, WebviewWindowBuilder}; +use tauri::{AppHandle, Manager, RunEvent, State, WebviewWindowBuilder}; #[cfg(windows)] use tauri_plugin_decorum::WebviewWindowExt; #[cfg(any(target_os = "linux", all(debug_assertions, windows)))] @@ -32,7 +32,7 @@ const SETTINGS_STORE: &str = "opencode.settings.dat"; const DEFAULT_SERVER_URL_KEY: &str = "defaultServerUrl"; fn window_state_flags() -> StateFlags { - StateFlags::all() - StateFlags::DECORATIONS + StateFlags::all() - StateFlags::DECORATIONS - StateFlags::VISIBLE } #[derive(Clone, serde::Serialize, specta::Type)] @@ -326,11 +326,6 @@ pub fn run() { #[cfg(windows)] app.manage(JobObjectState::new()); - let primary_monitor = app.primary_monitor().ok().flatten(); - let size = primary_monitor - .map(|m| m.size().to_logical(m.scale_factor())) - .unwrap_or(LogicalSize::new(1920, 1080)); - let config = app .config() .app @@ -341,7 +336,7 @@ pub fn run() { let window_builder = WebviewWindowBuilder::from_config(&app, config) .expect("Failed to create window builder from config") - .inner_size(size.width as f64, size.height as f64) + .maximized(true) .initialization_script(format!( r#" window.__OPENCODE__ ??= {{}}; @@ -584,6 +579,7 @@ fn setup_window_state_listener(app: &tauri::AppHandle, window: &tauri::WebviewWi let handle = app.clone(); let app = app.clone(); let _ = handle.run_on_main_thread(move || { + println!("saving window state"); let _ = app.save_window_state(window_state_flags()); }); }; From 9b20679a610af778e4f0fb741baed524edb2514a Mon Sep 17 00:00:00 2001 From: Devin Griffin <31415269+DNGriffin@users.noreply.github.com> Date: Fri, 6 Feb 2026 01:46:21 -0600 Subject: [PATCH 09/68] fix(app): always show project menu button for mobile a11y (#11258) Co-authored-by: Brendan Allan --- packages/app/src/pages/layout.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 251984d776..c538b920cb 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -2664,7 +2664,10 @@ export default function Layout(props: ParentProps) { variant="ghost" data-action="project-menu" data-project={base64Encode(p().worktree)} - class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" + class="shrink-0 size-6 rounded-md data-[expanded]:bg-surface-base-active" + classList={{ + "opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100": !panelProps.mobile, + }} aria-label={language.t("common.moreOptions")} /> From e0e32ed3a846be03f66d2f48e2b2659a9e658a11 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 6 Feb 2026 18:10:48 +0800 Subject: [PATCH 10/68] desktop: add more basic menu bar items --- .prettierignore | 2 +- packages/app/src/app.tsx | 11 ++- packages/app/src/components/titlebar.tsx | 15 +++ packages/app/src/index.ts | 1 + packages/desktop/src/bindings.ts | 2 +- packages/desktop/src/index.tsx | 23 ++++- packages/desktop/src/menu.ts | 118 ++++++++++++++++++----- 7 files changed, 138 insertions(+), 34 deletions(-) diff --git a/.prettierignore b/.prettierignore index 5f86f710fb..a2a2776596 100644 --- a/.prettierignore +++ b/.prettierignore @@ -1,2 +1,2 @@ sst-env.d.ts -desktop/src/bindings.ts +packages/desktop/src/bindings.ts diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 11fdb57432..8a111472ba 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -30,7 +30,7 @@ import { HighlightsProvider } from "@/context/highlights" import Layout from "@/pages/layout" import DirectoryLayout from "@/pages/directory-layout" import { ErrorPage } from "./pages/error" -import { Suspense } from "solid-js" +import { Suspense, JSX } from "solid-js" const Home = lazy(() => import("@/pages/home")) const Session = lazy(() => import("@/pages/session")) @@ -84,7 +84,7 @@ function ServerKey(props: ParentProps) { ) } -export function AppInterface(props: { defaultUrl?: string }) { +export function AppInterface(props: { defaultUrl?: string; children?: JSX.Element }) { const platform = usePlatform() const stored = (() => { @@ -111,7 +111,7 @@ export function AppInterface(props: { defaultUrl?: string }) { ( + root={(routerProps) => ( @@ -119,7 +119,10 @@ export function AppInterface(props: { defaultUrl?: string }) { - {props.children} + + {props.children} + {routerProps.children} + diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 86b4fbeb1b..32e36815ee 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -82,6 +82,21 @@ export function Titlebar() { navigate(to) } + command.register(() => [ + { + id: "common.goBack", + title: language.t("common.goBack"), + category: language.t("command.category.view"), + onSelect: back, + }, + { + id: "common.goForward", + title: language.t("common.goForward"), + category: language.t("command.category.view"), + onSelect: forward, + }, + ]) + const getWin = () => { if (platform.platform !== "desktop") return diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index df3181133e..c32af728ac 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,2 +1,3 @@ export { PlatformProvider, type Platform } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" +export { useCommand } from './context/command' diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index 440e138b4f..c6ca0fec7a 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -1,6 +1,6 @@ // This file has been generated by Tauri Specta. Do not edit this file manually. -import { invoke as __TAURI_INVOKE, Channel } from "@tauri-apps/api/core" +import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core" /** Commands */ export const commands = { diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index 30cb7ba7ae..66e86bf525 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -1,7 +1,7 @@ // @refresh reload import { webviewZoom } from "./webview-zoom" import { render } from "solid-js/web" -import { AppBaseProviders, AppInterface, PlatformProvider, Platform } from "@opencode-ai/app" +import { AppBaseProviders, AppInterface, PlatformProvider, Platform, useCommand } from "@opencode-ai/app" import { open, save } from "@tauri-apps/plugin-dialog" import { getCurrent, onOpenUrl } from "@tauri-apps/plugin-deep-link" import { openPath as openerOpenPath } from "@tauri-apps/plugin-opener" @@ -18,11 +18,11 @@ import { Splash } from "@opencode-ai/ui/logo" import { createSignal, Show, Accessor, JSX, createResource, onMount, onCleanup } from "solid-js" import { UPDATER_ENABLED } from "./updater" -import { createMenu } from "./menu" import { initI18n, t } from "./i18n" import pkg from "../package.json" import "./styles.css" import { commands } from "./bindings" +import { createMenu } from "./menu" const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { @@ -342,7 +342,10 @@ const createPlatform = (password: Accessor): Platform => ({ webviewZoom, }) -createMenu() +let menuTrigger = null as null | ((id: string) => void) +createMenu((id) => { + menuTrigger?.(id) +}) void listenForDeepLinks() render(() => { @@ -373,7 +376,19 @@ render(() => { window.__OPENCODE__ ??= {} window.__OPENCODE__.serverPassword = data().password ?? undefined - return + function Inner() { + const cmd = useCommand() + + menuTrigger = (id) => cmd.trigger(id) + + return null + } + + return ( + + + + ) }} diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index d410844042..9af6d2b840 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -1,13 +1,14 @@ import { Menu, MenuItem, PredefinedMenuItem, Submenu } from "@tauri-apps/api/menu" import { type as ostype } from "@tauri-apps/plugin-os" import { relaunch } from "@tauri-apps/plugin-process" +import { openUrl } from "@tauri-apps/plugin-opener" import { runUpdater, UPDATER_ENABLED } from "./updater" import { installCli } from "./cli" import { initI18n, t } from "./i18n" import { commands } from "./bindings" -export async function createMenu() { +export async function createMenu(trigger: (id: string) => void) { if (ostype() !== "macos") return await initI18n() @@ -60,29 +61,25 @@ export async function createMenu() { }), ].filter(Boolean), }), - // await Submenu.new({ - // text: "File", - // items: [ - // await MenuItem.new({ - // enabled: false, - // text: "Open Project...", - // }), - // await PredefinedMenuItem.new({ - // item: "Separator" - // }), - // await MenuItem.new({ - // enabled: false, - // text: "New Session", - // }), - // await PredefinedMenuItem.new({ - // item: "Separator" - // }), - // await MenuItem.new({ - // enabled: false, - // text: "Close Project", - // }) - // ] - // }), + await Submenu.new({ + text: "File", + items: [ + await MenuItem.new({ + text: "New Session", + action: () => trigger("session.new"), + }), + await MenuItem.new({ + text: "Open Project...", + action: () => trigger("project.open"), + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await PredefinedMenuItem.new({ + item: "CloseWindow", + }), + ], + }), await Submenu.new({ text: "Edit", items: [ @@ -109,6 +106,79 @@ export async function createMenu() { }), ], }), + await Submenu.new({ + text: "View", + items: [ + await MenuItem.new({ + action: () => trigger("sidebar.toggle"), + text: "Toggle Sidebar", + }), + await MenuItem.new({ + action: () => trigger("terminal.toggle"), + text: "Toggle Terminal", + }), + await MenuItem.new({ + action: () => trigger("fileTree.toggle"), + text: "Toggle File Tree", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await MenuItem.new({ + action: () => trigger("common.goBack"), + text: "Back", + }), + await MenuItem.new({ + action: () => trigger("common.goForward"), + text: "Forward", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await MenuItem.new({ + action: () => trigger("session.next"), + text: "Previous Session", + }), + await MenuItem.new({ + action: () => trigger("session.previous"), + text: "Next Session", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + ], + }), + await Submenu.new({ + text: "Help", + items: [ + // missing native macos search + await MenuItem.new({ + action: () => openUrl("https://opencode.ai/docs"), + text: "OpenCode Documentation", + }), + await MenuItem.new({ + action: () => openUrl("https://discord.com/invite/opencode"), + text: "Support Forum", + }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + // await MenuItem.new({ + // text: "Release Notes", + // }), + await PredefinedMenuItem.new({ + item: "Separator", + }), + await MenuItem.new({ + action: () => openUrl("https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml"), + text: "Share Feedback", + }), + await MenuItem.new({ + action: () => openUrl("https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml"), + text: "Report a Bug", + }), + ], + }), ], }) menu.setAsAppMenu() From 5ae4463b633c96e6d6e5dd58affa057efd8c2fc7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 10:11:44 +0000 Subject: [PATCH 11/68] chore: generate --- packages/app/src/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index c32af728ac..fb66820092 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -1,3 +1,3 @@ export { PlatformProvider, type Platform } from "./context/platform" export { AppBaseProviders, AppInterface } from "./app" -export { useCommand } from './context/command' +export { useCommand } from "./context/command" From 3c5e1a98fcd704d7765b77a8e354b0d3816a94b1 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Fri, 6 Feb 2026 18:20:04 +0800 Subject: [PATCH 12/68] desktop: add key accelerators to menu itms --- packages/desktop/src/menu.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 9af6d2b840..1fc04238bd 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -66,10 +66,12 @@ export async function createMenu(trigger: (id: string) => void) { items: [ await MenuItem.new({ text: "New Session", + accelerator: "Shift+Cmd+S", action: () => trigger("session.new"), }), await MenuItem.new({ text: "Open Project...", + accelerator: "Cmd+O", action: () => trigger("project.open"), }), await PredefinedMenuItem.new({ @@ -112,10 +114,12 @@ export async function createMenu(trigger: (id: string) => void) { await MenuItem.new({ action: () => trigger("sidebar.toggle"), text: "Toggle Sidebar", + accelerator: "Cmd+B" }), await MenuItem.new({ action: () => trigger("terminal.toggle"), text: "Toggle Terminal", + accelerator: "Ctrl+`" }), await MenuItem.new({ action: () => trigger("fileTree.toggle"), @@ -136,12 +140,14 @@ export async function createMenu(trigger: (id: string) => void) { item: "Separator", }), await MenuItem.new({ - action: () => trigger("session.next"), + action: () => trigger("session.previous"), text: "Previous Session", + accelerator: "Option+ArrowUp" }), await MenuItem.new({ - action: () => trigger("session.previous"), + action: () => trigger("session.next"), text: "Next Session", + accelerator: "Option+ArrowDown" }), await PredefinedMenuItem.new({ item: "Separator", From 981b80d40b52f846e16eeddab1be30bb955d0cb8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 10:20:52 +0000 Subject: [PATCH 13/68] chore: generate --- packages/desktop/src/menu.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/desktop/src/menu.ts b/packages/desktop/src/menu.ts index 1fc04238bd..9fcb6115b1 100644 --- a/packages/desktop/src/menu.ts +++ b/packages/desktop/src/menu.ts @@ -114,12 +114,12 @@ export async function createMenu(trigger: (id: string) => void) { await MenuItem.new({ action: () => trigger("sidebar.toggle"), text: "Toggle Sidebar", - accelerator: "Cmd+B" + accelerator: "Cmd+B", }), await MenuItem.new({ action: () => trigger("terminal.toggle"), text: "Toggle Terminal", - accelerator: "Ctrl+`" + accelerator: "Ctrl+`", }), await MenuItem.new({ action: () => trigger("fileTree.toggle"), @@ -142,12 +142,12 @@ export async function createMenu(trigger: (id: string) => void) { await MenuItem.new({ action: () => trigger("session.previous"), text: "Previous Session", - accelerator: "Option+ArrowUp" + accelerator: "Option+ArrowUp", }), await MenuItem.new({ action: () => trigger("session.next"), text: "Next Session", - accelerator: "Option+ArrowDown" + accelerator: "Option+ArrowDown", }), await PredefinedMenuItem.new({ item: "Separator", From 80a5c3d7ed65e6fce599f82f7db68f3d5ac01d91 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:35:00 -0600 Subject: [PATCH 14/68] chore: cleanup --- packages/app/src/pages/session.tsx | 10 ++++------ packages/desktop/src/bindings.ts | 21 +++++++++++---------- 2 files changed, 15 insertions(+), 16 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index cb07c3b47a..433e479251 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2463,8 +2463,7 @@ export default function Page() { "sticky top-0 z-30 bg-background-stronger": true, "w-full": true, "px-4 md:px-6": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), }} >
@@ -2586,8 +2585,7 @@ export default function Page() { class="flex flex-col gap-12 items-start justify-start pb-[calc(var(--prompt-height,8rem)+64px)] md:pb-[calc(var(--prompt-height,10rem)+64px)] transition-[margin]" classList={{ "w-full": true, - "md:max-w-200 md:mx-auto 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": - centered(), + "md:max-w-200 md:mx-auto 3xl:max-w-[1200px]": centered(), "mt-0.5": centered(), "mt-0": !centered(), }} @@ -2640,7 +2638,7 @@ export default function Page() { data-message-id={message.id} classList={{ "min-w-0 w-full max-w-full": true, - "md:max-w-200 3xl:max-w-[1200px] 4xl:max-w-[1600px] 5xl:max-w-[1900px]": centered(), + "md:max-w-200 3xl:max-w-[1200px]": centered(), }} > diff --git a/packages/desktop/src/bindings.ts b/packages/desktop/src/bindings.ts index c6ca0fec7a..64d0c113d7 100644 --- a/packages/desktop/src/bindings.ts +++ b/packages/desktop/src/bindings.ts @@ -4,16 +4,17 @@ import { invoke as __TAURI_INVOKE } from "@tauri-apps/api/core" /** Commands */ export const commands = { - killSidecar: () => __TAURI_INVOKE("kill_sidecar"), - installCli: () => __TAURI_INVOKE("install_cli"), - ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), - getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), - setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), - parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), -} + killSidecar: () => __TAURI_INVOKE("kill_sidecar"), + installCli: () => __TAURI_INVOKE("install_cli"), + ensureServerReady: () => __TAURI_INVOKE("ensure_server_ready"), + getDefaultServerUrl: () => __TAURI_INVOKE("get_default_server_url"), + setDefaultServerUrl: (url: string | null) => __TAURI_INVOKE("set_default_server_url", { url }), + parseMarkdownCommand: (markdown: string) => __TAURI_INVOKE("parse_markdown_command", { markdown }), +}; /* Types */ export type ServerReadyData = { - url: string - password: string | null -} + url: string, + password: string | null, + }; + From 5d922198121338ba67ec1e809a57cf257889bb3c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:50:38 -0600 Subject: [PATCH 15/68] fix(app): retry error unwrapping (#12462) --- packages/ui/src/components/session-turn.css | 8 ++ packages/ui/src/components/session-turn.tsx | 99 ++++++++++++++++++++- 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/packages/ui/src/components/session-turn.css b/packages/ui/src/components/session-turn.css index 4b8ba8d7a0..9887ce2fc6 100644 --- a/packages/ui/src/components/session-turn.css +++ b/packages/ui/src/components/session-turn.css @@ -501,6 +501,7 @@ [data-slot="session-turn-collapsible-trigger-content"] { max-width: 100%; + min-width: 0; display: flex; align-items: center; gap: 8px; @@ -525,6 +526,10 @@ [data-slot="session-turn-retry-message"] { font-weight: 500; color: var(--syntax-critical); + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; } [data-slot="session-turn-retry-seconds"] { @@ -549,6 +554,9 @@ .error-card { color: var(--text-on-critical-base); max-height: 240px; + white-space: pre-wrap; + overflow-wrap: anywhere; + word-break: break-word; overflow-y: auto; } diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 5ea9f64bbc..c2e26b9c7b 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -27,6 +27,59 @@ import { createResizeObserver } from "@solid-primitives/resize-observer" type Translator = (key: UiI18nKey, params?: UiI18nParams) => string +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function unwrap(message: string) { + const text = message.replace(/^Error:\s*/, "").trim() + + const parse = (value: string) => { + try { + return JSON.parse(value) as unknown + } catch { + return undefined + } + } + + const read = (value: string) => { + const first = parse(value) + if (typeof first !== "string") return first + return parse(first.trim()) + } + + let json = read(text) + + if (json === undefined) { + const start = text.indexOf("{") + const end = text.lastIndexOf("}") + if (start !== -1 && end > start) { + json = read(text.slice(start, end + 1)) + } + } + + if (!record(json)) return message + + const err = record(json.error) ? json.error : undefined + if (err) { + const type = typeof err.type === "string" ? err.type : undefined + const msg = typeof err.message === "string" ? err.message : undefined + if (type && msg) return `${type}: ${msg}` + if (msg) return msg + if (type) return type + const code = typeof err.code === "string" ? err.code : undefined + if (code) return code + } + + const msg = typeof json.message === "string" ? json.message : undefined + if (msg) return msg + + const reason = typeof json.error === "string" ? json.error : undefined + if (reason) return reason + + return message +} + function computeStatusFromPart(part: PartType | undefined, t: Translator): string | undefined { if (!part) return undefined @@ -236,6 +289,12 @@ export function SessionTurn( const lastAssistantMessage = createMemo(() => assistantMessages().at(-1)) const error = createMemo(() => assistantMessages().find((m) => m.error)?.error) + const errorText = createMemo(() => { + const msg = error()?.data?.message + if (typeof msg === "string") return unwrap(msg) + if (msg === undefined || msg === null) return "" + return unwrap(String(msg)) + }) const lastTextPart = createMemo(() => { const msgs = assistantMessages() @@ -463,6 +522,39 @@ export function SessionTurn( onCleanup(() => clearInterval(timer)) }) + let retryLog = "" + createEffect(() => { + const r = retry() + if (!r) return + const key = `${r.attempt}:${r.next}:${r.message}` + if (key === retryLog) return + retryLog = key + console.warn("[session-turn] retry", { + sessionID: props.sessionID, + messageID: props.messageID, + attempt: r.attempt, + next: r.next, + raw: r.message, + parsed: unwrap(r.message), + }) + }) + + let errorLog = "" + createEffect(() => { + const value = error()?.data?.message + if (value === undefined || value === null) return + const raw = typeof value === "string" ? value : String(value) + if (!raw) return + if (raw === errorLog) return + errorLog = raw + console.warn("[session-turn] assistant-error", { + sessionID: props.sessionID, + messageID: props.messageID, + raw, + parsed: unwrap(raw), + }) + }) + createEffect(() => { const update = () => { setStore("duration", duration()) @@ -595,7 +687,8 @@ export function SessionTurn( {(() => { const r = retry() if (!r) return "" - return r.message.length > 60 ? r.message.slice(0, 60) + "..." : r.message + const msg = unwrap(r.message) + return msg.length > 60 ? msg.slice(0, 60) + "..." : msg })()} @@ -640,7 +733,7 @@ export function SessionTurn( - {error()?.data?.message as string} + {errorText()}
@@ -696,7 +789,7 @@ export function SessionTurn(
- {error()?.data?.message as string} + {errorText()} From 4afec6731d1051b6b7ea1f3a278c7e16b577736c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 05:51:01 -0600 Subject: [PATCH 16/68] ignore: refactoring and tests (#12460) --- .../src/components/titlebar-history.test.ts | 63 ++++ .../app/src/components/titlebar-history.ts | 57 +++ packages/app/src/components/titlebar.tsx | 43 +-- packages/app/src/context/command.test.ts | 25 ++ packages/app/src/context/command.tsx | 48 ++- packages/app/src/context/global-sync.test.ts | 136 ++++++++ packages/app/src/context/global-sync.tsx | 329 +++++++++++++++--- .../app/src/context/notification-index.ts | 66 ++++ packages/app/src/context/notification.test.ts | 73 ++++ packages/app/src/context/notification.tsx | 57 +-- packages/app/src/pages/layout.tsx | 294 +++++++--------- 11 files changed, 899 insertions(+), 292 deletions(-) create mode 100644 packages/app/src/components/titlebar-history.test.ts create mode 100644 packages/app/src/components/titlebar-history.ts create mode 100644 packages/app/src/context/command.test.ts create mode 100644 packages/app/src/context/global-sync.test.ts create mode 100644 packages/app/src/context/notification-index.ts create mode 100644 packages/app/src/context/notification.test.ts diff --git a/packages/app/src/components/titlebar-history.test.ts b/packages/app/src/components/titlebar-history.test.ts new file mode 100644 index 0000000000..25035d7ccf --- /dev/null +++ b/packages/app/src/components/titlebar-history.test.ts @@ -0,0 +1,63 @@ +import { describe, expect, test } from "bun:test" +import { applyPath, backPath, forwardPath, type TitlebarHistory } from "./titlebar-history" + +function history(): TitlebarHistory { + return { stack: [], index: 0, action: undefined } +} + +describe("titlebar history", () => { + test("append and trim keeps max bounded", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.stack.length).toBe(3) + expect(state.index).toBe(2) + }) + + test("back and forward indexes stay correct after trimming", () => { + let state = history() + state = applyPath(state, "/", 3) + state = applyPath(state, "/a", 3) + state = applyPath(state, "/b", 3) + state = applyPath(state, "/c", 3) + + expect(state.stack).toEqual(["/a", "/b", "/c"]) + expect(state.index).toBe(2) + + const back = backPath(state) + expect(back?.to).toBe("/b") + expect(back?.state.index).toBe(1) + + const afterBack = applyPath(back!.state, back!.to, 3) + expect(afterBack.stack).toEqual(["/a", "/b", "/c"]) + expect(afterBack.index).toBe(1) + + const forward = forwardPath(afterBack) + expect(forward?.to).toBe("/c") + expect(forward?.state.index).toBe(2) + + const afterForward = applyPath(forward!.state, forward!.to, 3) + expect(afterForward.stack).toEqual(["/a", "/b", "/c"]) + expect(afterForward.index).toBe(2) + }) + + test("action-driven navigation does not push duplicate history entries", () => { + const state: TitlebarHistory = { + stack: ["/", "/a", "/b"], + index: 2, + action: undefined, + } + + const back = backPath(state) + expect(back?.to).toBe("/a") + + const next = applyPath(back!.state, back!.to, 10) + expect(next.stack).toEqual(["/", "/a", "/b"]) + expect(next.index).toBe(1) + expect(next.action).toBeUndefined() + }) +}) diff --git a/packages/app/src/components/titlebar-history.ts b/packages/app/src/components/titlebar-history.ts new file mode 100644 index 0000000000..44dbbfa3a4 --- /dev/null +++ b/packages/app/src/components/titlebar-history.ts @@ -0,0 +1,57 @@ +export const MAX_TITLEBAR_HISTORY = 100 + +export type TitlebarAction = "back" | "forward" | undefined + +export type TitlebarHistory = { + stack: string[] + index: number + action: TitlebarAction +} + +export function applyPath(state: TitlebarHistory, current: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + if (!state.stack.length) { + const stack = current === "/" ? ["/"] : ["/", current] + return { stack, index: stack.length - 1, action: undefined } + } + + const active = state.stack[state.index] + if (current === active) { + if (!state.action) return state + return { ...state, action: undefined } + } + + if (state.action) return { ...state, action: undefined } + + return pushPath(state, current, max) +} + +export function pushPath(state: TitlebarHistory, path: string, max = MAX_TITLEBAR_HISTORY): TitlebarHistory { + const stack = state.stack.slice(0, state.index + 1).concat(path) + const next = trimHistory(stack, stack.length - 1, max) + return { ...state, ...next, action: undefined } +} + +export function trimHistory(stack: string[], index: number, max = MAX_TITLEBAR_HISTORY) { + if (stack.length <= max) return { stack, index } + const cut = stack.length - max + return { + stack: stack.slice(cut), + index: Math.max(0, index - cut), + } +} + +export function backPath(state: TitlebarHistory) { + if (state.index <= 0) return + const index = state.index - 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "back" as const }, to } +} + +export function forwardPath(state: TitlebarHistory) { + if (state.index >= state.stack.length - 1) return + const index = state.index + 1 + const to = state.stack[index] + if (!to) return + return { state: { ...state, index, action: "forward" as const }, to } +} diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 32e36815ee..d8735410a4 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -11,6 +11,7 @@ import { useLayout } from "@/context/layout" import { usePlatform } from "@/context/platform" import { useCommand } from "@/context/command" import { useLanguage } from "@/context/language" +import { applyPath, backPath, forwardPath } from "./titlebar-history" export function Titlebar() { const layout = useLayout() @@ -39,25 +40,9 @@ export function Titlebar() { const current = path() untrack(() => { - if (!history.stack.length) { - const stack = current === "/" ? ["/"] : ["/", current] - setHistory({ stack, index: stack.length - 1 }) - return - } - - const active = history.stack[history.index] - if (current === active) { - if (history.action) setHistory("action", undefined) - return - } - - if (history.action) { - setHistory("action", undefined) - return - } - - const next = history.stack.slice(0, history.index + 1).concat(current) - setHistory({ stack: next, index: next.length - 1 }) + const next = applyPath(history, current) + if (next === history) return + setHistory(next) }) }) @@ -65,21 +50,17 @@ export function Titlebar() { const canForward = createMemo(() => history.index < history.stack.length - 1) const back = () => { - if (!canBack()) return - const index = history.index - 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "back" }) - navigate(to) + const next = backPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } const forward = () => { - if (!canForward()) return - const index = history.index + 1 - const to = history.stack[index] - if (!to) return - setHistory({ index, action: "forward" }) - navigate(to) + const next = forwardPath(history) + if (!next) return + setHistory(next.state) + navigate(next.to) } command.register(() => [ diff --git a/packages/app/src/context/command.test.ts b/packages/app/src/context/command.test.ts new file mode 100644 index 0000000000..2b956287c5 --- /dev/null +++ b/packages/app/src/context/command.test.ts @@ -0,0 +1,25 @@ +import { describe, expect, test } from "bun:test" +import { upsertCommandRegistration } from "./command" + +describe("upsertCommandRegistration", () => { + test("replaces keyed registrations", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ key: "layout", options: one }], { key: "layout", options: two }) + + expect(next).toHaveLength(1) + expect(next[0]?.options).toBe(two) + }) + + test("keeps unkeyed registrations additive", () => { + const one = () => [{ id: "one", title: "One" }] + const two = () => [{ id: "two", title: "Two" }] + + const next = upsertCommandRegistration([{ options: one }], { options: two }) + + expect(next).toHaveLength(2) + expect(next[0]?.options).toBe(two) + expect(next[1]?.options).toBe(one) + }) +}) diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index 7915695840..e6a16fd4bb 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -64,6 +64,16 @@ export type CommandCatalogItem = { slash?: string } +export type CommandRegistration = { + key?: string + options: Accessor +} + +export function upsertCommandRegistration(registrations: CommandRegistration[], entry: CommandRegistration) { + if (entry.key === undefined) return [entry, ...registrations] + return [entry, ...registrations.filter((x) => x.key !== entry.key)] +} + export function parseKeybind(config: string): Keybind[] { if (!config || config === "none") return [] @@ -166,9 +176,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const settings = useSettings() const language = useLanguage() const [store, setStore] = createStore({ - registrations: [] as Accessor[], + registrations: [] as CommandRegistration[], suspendCount: 0, }) + const warnedDuplicates = new Set() const [catalog, setCatalog, _, catalogReady] = persisted( Persist.global("command.catalog.v1"), @@ -187,8 +198,14 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const all: CommandOption[] = [] for (const reg of store.registrations) { - for (const opt of reg()) { - if (seen.has(opt.id)) continue + for (const opt of reg.options()) { + if (seen.has(opt.id)) { + if (import.meta.env.DEV && !warnedDuplicates.has(opt.id)) { + warnedDuplicates.add(opt.id) + console.warn(`[command] duplicate command id \"${opt.id}\" registered; keeping first entry`) + } + continue + } seen.add(opt.id) all.push(opt) } @@ -296,14 +313,25 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex document.removeEventListener("keydown", handleKeyDown) }) + function register(cb: () => CommandOption[]): void + function register(key: string, cb: () => CommandOption[]): void + function register(key: string | (() => CommandOption[]), cb?: () => CommandOption[]) { + const id = typeof key === "string" ? key : undefined + const next = typeof key === "function" ? key : cb + if (!next) return + const options = createMemo(next) + const entry: CommandRegistration = { + key: id, + options, + } + setStore("registrations", (arr) => upsertCommandRegistration(arr, entry)) + onCleanup(() => { + setStore("registrations", (arr) => arr.filter((x) => x !== entry)) + }) + } + return { - register(cb: () => CommandOption[]) { - const results = createMemo(cb) - setStore("registrations", (arr) => [results, ...arr]) - onCleanup(() => { - setStore("registrations", (arr) => arr.filter((x) => x !== results)) - }) - }, + register, trigger(id: string, source?: "palette" | "keybind" | "slash") { run(id, source) }, diff --git a/packages/app/src/context/global-sync.test.ts b/packages/app/src/context/global-sync.test.ts new file mode 100644 index 0000000000..396b412318 --- /dev/null +++ b/packages/app/src/context/global-sync.test.ts @@ -0,0 +1,136 @@ +import { describe, expect, test } from "bun:test" +import { + canDisposeDirectory, + estimateRootSessionTotal, + loadRootSessionsWithFallback, + pickDirectoriesToEvict, +} from "./global-sync" + +describe("pickDirectoriesToEvict", () => { + test("keeps pinned stores and evicts idle stores", () => { + const now = 5_000 + const picks = pickDirectoriesToEvict({ + stores: ["a", "b", "c", "d"], + state: new Map([ + ["a", { lastAccessAt: 1_000 }], + ["b", { lastAccessAt: 4_900 }], + ["c", { lastAccessAt: 4_800 }], + ["d", { lastAccessAt: 3_000 }], + ]), + pins: new Set(["a"]), + max: 2, + ttl: 1_500, + now, + }) + + expect(picks).toEqual(["d", "c"]) + }) +}) + +describe("loadRootSessionsWithFallback", () => { + test("uses limited roots query when supported", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + let fallback = 0 + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 10, + list: async (query) => { + calls.push(query) + return { data: [] } + }, + onFallback: () => { + fallback += 1 + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(true) + expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(fallback).toBe(0) + }) + + test("falls back to full roots query on limited-query failure", async () => { + const calls: Array<{ directory: string; roots: true; limit?: number }> = [] + let fallback = 0 + + const result = await loadRootSessionsWithFallback({ + directory: "dir", + limit: 25, + list: async (query) => { + calls.push(query) + if (query.limit) throw new Error("unsupported") + return { data: [] } + }, + onFallback: () => { + fallback += 1 + }, + }) + + expect(result.data).toEqual([]) + expect(result.limited).toBe(false) + expect(calls).toEqual([ + { directory: "dir", roots: true, limit: 25 }, + { directory: "dir", roots: true }, + ]) + expect(fallback).toBe(1) + }) +}) + +describe("estimateRootSessionTotal", () => { + test("keeps exact total for full fetches", () => { + expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) + }) + + test("marks has-more for full-limit limited fetches", () => { + expect(estimateRootSessionTotal({ count: 10, limit: 10, limited: true })).toBe(11) + }) + + test("keeps exact total when limited fetch is under limit", () => { + expect(estimateRootSessionTotal({ count: 9, limit: 10, limited: true })).toBe(9) + }) +}) + +describe("canDisposeDirectory", () => { + test("rejects pinned or inflight directories", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: true, + booting: false, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: true, + loadingSessions: false, + }), + ).toBe(false) + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: true, + }), + ).toBe(false) + }) + + test("accepts idle unpinned directory store", () => { + expect( + canDisposeDirectory({ + directory: "dir", + hasStore: true, + pinned: false, + booting: false, + loadingSessions: false, + }), + ).toBe(true) + }) +}) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 0facbdfff4..0d6b5dfff9 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -27,6 +27,7 @@ import type { InitError } from "../pages/error" import { batch, createContext, + createRoot, createEffect, untrack, getOwner, @@ -131,6 +132,96 @@ function normalizeProviderList(input: ProviderListResponse): ProviderListRespons } } +const MAX_DIR_STORES = 30 +const DIR_IDLE_TTL_MS = 20 * 60 * 1000 + +type DirState = { + lastAccessAt: number +} + +type EvictPlan = { + stores: string[] + state: Map + pins: Set + max: number + ttl: number + now: number +} + +export function pickDirectoriesToEvict(input: EvictPlan) { + const overflow = Math.max(0, input.stores.length - input.max) + let pendingOverflow = overflow + const sorted = input.stores + .filter((dir) => !input.pins.has(dir)) + .slice() + .sort((a, b) => (input.state.get(a)?.lastAccessAt ?? 0) - (input.state.get(b)?.lastAccessAt ?? 0)) + + const output: string[] = [] + for (const dir of sorted) { + const last = input.state.get(dir)?.lastAccessAt ?? 0 + const idle = input.now - last >= input.ttl + if (!idle && pendingOverflow <= 0) continue + output.push(dir) + if (pendingOverflow > 0) pendingOverflow -= 1 + } + return output +} + +type RootLoadArgs = { + directory: string + limit: number + list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> + onFallback: () => void +} + +type RootLoadResult = { + data?: Session[] + limit: number + limited: boolean +} + +export async function loadRootSessionsWithFallback(input: RootLoadArgs) { + try { + const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) + return { + data: result.data, + limit: input.limit, + limited: true, + } satisfies RootLoadResult + } catch { + input.onFallback() + const result = await input.list({ directory: input.directory, roots: true }) + return { + data: result.data, + limit: input.limit, + limited: false, + } satisfies RootLoadResult + } +} + +export function estimateRootSessionTotal(input: { count: number; limit: number; limited: boolean }) { + if (!input.limited) return input.count + if (input.count < input.limit) return input.count + return input.count + 1 +} + +type DisposeCheck = { + directory: string + hasStore: boolean + pinned: boolean + booting: boolean + loadingSessions: boolean +} + +export function canDisposeDirectory(input: DisposeCheck) { + if (!input.directory) return false + if (!input.hasStore) return false + if (input.pinned) return false + if (input.booting) return false + if (input.loadingSessions) return false + return true +} + function createGlobalSync() { const globalSDK = useGlobalSDK() const platform = usePlatform() @@ -140,8 +231,133 @@ function createGlobalSync() { const vcsCache = new Map() const metaCache = new Map() const iconCache = new Map() + const lifecycle = new Map() + const pins = new Map() + const ownerPins = new WeakMap>() + const disposers = new Map void>() + const stats = { + evictions: 0, + loadSessionsFallback: 0, + } const sdkCache = new Map>() + + const updateStats = () => { + if (!import.meta.env.DEV) return + ;( + globalThis as { + __OPENCODE_GLOBAL_SYNC_STATS?: { + activeDirectoryStores: number + evictions: number + loadSessionsFullFetchFallback: number + } + } + ).__OPENCODE_GLOBAL_SYNC_STATS = { + activeDirectoryStores: Object.keys(children).length, + evictions: stats.evictions, + loadSessionsFullFetchFallback: stats.loadSessionsFallback, + } + } + + const mark = (directory: string) => { + if (!directory) return + lifecycle.set(directory, { lastAccessAt: Date.now() }) + runEviction() + } + + const pin = (directory: string) => { + if (!directory) return + pins.set(directory, (pins.get(directory) ?? 0) + 1) + mark(directory) + } + + const unpin = (directory: string) => { + if (!directory) return + const next = (pins.get(directory) ?? 0) - 1 + if (next > 0) { + pins.set(directory, next) + return + } + pins.delete(directory) + runEviction() + } + + const pinned = (directory: string) => (pins.get(directory) ?? 0) > 0 + + const pinForOwner = (directory: string) => { + const current = getOwner() + if (!current) return + if (current === owner) return + const key = current as object + const set = ownerPins.get(key) + if (set?.has(directory)) return + if (set) set.add(directory) + else ownerPins.set(key, new Set([directory])) + pin(directory) + onCleanup(() => { + const set = ownerPins.get(key) + if (set) { + set.delete(directory) + if (set.size === 0) ownerPins.delete(key) + } + unpin(directory) + }) + } + + function disposeDirectory(directory: string) { + if ( + !canDisposeDirectory({ + directory, + hasStore: !!children[directory], + pinned: pinned(directory), + booting: booting.has(directory), + loadingSessions: sessionLoads.has(directory), + }) + ) { + return false + } + + queued.delete(directory) + sessionMeta.delete(directory) + sdkCache.delete(directory) + vcsCache.delete(directory) + metaCache.delete(directory) + iconCache.delete(directory) + lifecycle.delete(directory) + + const dispose = disposers.get(directory) + if (dispose) { + dispose() + disposers.delete(directory) + } + + delete children[directory] + updateStats() + return true + } + + function runEviction() { + const stores = Object.keys(children) + if (stores.length === 0) return + const list = pickDirectoriesToEvict({ + stores, + state: lifecycle, + pins: new Set(stores.filter(pinned)), + max: MAX_DIR_STORES, + ttl: DIR_IDLE_TTL_MS, + now: Date.now(), + }) + + if (list.length === 0) return + let changed = false + for (const directory of list) { + if (!disposeDirectory(directory)) continue + stats.evictions += 1 + changed = true + } + if (changed) updateStats() + } + const sdkFor = (directory: string) => { const cached = sdkCache.get(directory) if (cached) return cached @@ -379,52 +595,56 @@ function createGlobalSync() { if (!icon) throw new Error("Failed to create persisted project icon") iconCache.set(directory, { store: icon[0], setStore: icon[1], ready: icon[3] }) - const init = () => { - const child = createStore({ - project: "", - projectMeta: meta[0].value, - icon: icon[0].value, - provider: { all: [], connected: [], default: {} }, - config: {}, - path: { state: "", config: "", worktree: "", directory: "", home: "" }, - status: "loading" as const, - agent: [], - command: [], - session: [], - sessionTotal: 0, - session_status: {}, - session_diff: {}, - todo: {}, - permission: {}, - question: {}, - mcp: {}, - lsp: [], - vcs: vcsStore.value, - limit: 5, - message: {}, - part: {}, - }) + const init = () => + createRoot((dispose) => { + const child = createStore({ + project: "", + projectMeta: meta[0].value, + icon: icon[0].value, + provider: { all: [], connected: [], default: {} }, + config: {}, + path: { state: "", config: "", worktree: "", directory: "", home: "" }, + status: "loading" as const, + agent: [], + command: [], + session: [], + sessionTotal: 0, + session_status: {}, + session_diff: {}, + todo: {}, + permission: {}, + question: {}, + mcp: {}, + lsp: [], + vcs: vcsStore.value, + limit: 5, + message: {}, + part: {}, + }) - children[directory] = child + children[directory] = child + disposers.set(directory, dispose) - createEffect(() => { - if (!vcsReady()) return - const cached = vcsStore.value - if (!cached?.branch) return - child[1]("vcs", (value) => value ?? cached) - }) + createEffect(() => { + if (!vcsReady()) return + const cached = vcsStore.value + if (!cached?.branch) return + child[1]("vcs", (value) => value ?? cached) + }) - createEffect(() => { - child[1]("projectMeta", meta[0].value) - }) + createEffect(() => { + child[1]("projectMeta", meta[0].value) + }) - createEffect(() => { - child[1]("icon", icon[0].value) + createEffect(() => { + child[1]("icon", icon[0].value) + }) }) - } runWithOwner(owner, init) + updateStats() } + mark(directory) const childStore = children[directory] if (!childStore) throw new Error("Failed to create store") return childStore @@ -432,6 +652,7 @@ function createGlobalSync() { function child(directory: string, options: ChildOptions = {}) { const childStore = ensureChild(directory) + pinForOwner(directory) const shouldBootstrap = options.bootstrap ?? true if (shouldBootstrap && childStore[0].status === "loading") { void bootstrapInstance(directory) @@ -443,6 +664,7 @@ function createGlobalSync() { const pending = sessionLoads.get(directory) if (pending) return pending + pin(directory) const [store, setStore] = child(directory, { bootstrap: false }) const meta = sessionMeta.get(directory) if (meta && meta.limit >= store.limit) { @@ -450,11 +672,20 @@ function createGlobalSync() { if (next.length !== store.session.length) { setStore("session", reconcile(next, { key: "id" })) } + unpin(directory) return } - const promise = globalSDK.client.session - .list({ directory, roots: true }) + const limit = Math.max(store.limit + sessionRecentLimit, sessionRecentLimit) + const promise = loadRootSessionsWithFallback({ + directory, + limit, + list: (query) => globalSDK.client.session.list(query), + onFallback: () => { + stats.loadSessionsFallback += 1 + updateStats() + }, + }) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -468,8 +699,13 @@ function createGlobalSync() { const children = store.session.filter((s) => !!s.parentID) const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission }) - // Store total session count (used for "load more" pagination) - setStore("sessionTotal", nonArchived.length) + // Store root session total for "load more" pagination. + // For limited root queries, preserve has-more behavior by treating + // full-limit responses as "potentially more". + setStore( + "sessionTotal", + estimateRootSessionTotal({ count: nonArchived.length, limit: x.limit, limited: x.limited }), + ) setStore("session", reconcile(sessions, { key: "id" })) sessionMeta.set(directory, { limit }) }) @@ -482,6 +718,7 @@ function createGlobalSync() { sessionLoads.set(directory, promise) promise.finally(() => { sessionLoads.delete(directory) + unpin(directory) }) return promise } @@ -491,6 +728,7 @@ function createGlobalSync() { const pending = booting.get(directory) if (pending) return pending + pin(directory) const promise = (async () => { const [store, setStore] = ensureChild(directory) const cache = vcsCache.get(directory) @@ -605,6 +843,7 @@ function createGlobalSync() { booting.set(directory, promise) promise.finally(() => { booting.delete(directory) + unpin(directory) }) return promise } @@ -670,6 +909,7 @@ function createGlobalSync() { const existing = children[directory] if (!existing) return + mark(directory) const [store, setStore] = existing @@ -955,6 +1195,11 @@ function createGlobalSync() { if (!timer) return clearTimeout(timer) }) + onCleanup(() => { + for (const directory of Object.keys(children)) { + disposeDirectory(directory) + } + }) async function bootstrap() { const health = await globalSDK.client.global diff --git a/packages/app/src/context/notification-index.ts b/packages/app/src/context/notification-index.ts new file mode 100644 index 0000000000..0b316e7ec1 --- /dev/null +++ b/packages/app/src/context/notification-index.ts @@ -0,0 +1,66 @@ +type NotificationIndexItem = { + directory?: string + session?: string + viewed: boolean + type: string +} + +export function buildNotificationIndex(list: T[]) { + const sessionAll = new Map() + const sessionUnseen = new Map() + const sessionUnseenCount = new Map() + const sessionUnseenHasError = new Map() + const projectAll = new Map() + const projectUnseen = new Map() + const projectUnseenCount = new Map() + const projectUnseenHasError = new Map() + + for (const notification of list) { + const session = notification.session + if (session) { + const all = sessionAll.get(session) + if (all) all.push(notification) + else sessionAll.set(session, [notification]) + + if (!notification.viewed) { + const unseen = sessionUnseen.get(session) + if (unseen) unseen.push(notification) + else sessionUnseen.set(session, [notification]) + + sessionUnseenCount.set(session, (sessionUnseenCount.get(session) ?? 0) + 1) + if (notification.type === "error") sessionUnseenHasError.set(session, true) + } + } + + const directory = notification.directory + if (directory) { + const all = projectAll.get(directory) + if (all) all.push(notification) + else projectAll.set(directory, [notification]) + + if (!notification.viewed) { + const unseen = projectUnseen.get(directory) + if (unseen) unseen.push(notification) + else projectUnseen.set(directory, [notification]) + + projectUnseenCount.set(directory, (projectUnseenCount.get(directory) ?? 0) + 1) + if (notification.type === "error") projectUnseenHasError.set(directory, true) + } + } + } + + return { + session: { + all: sessionAll, + unseen: sessionUnseen, + unseenCount: sessionUnseenCount, + unseenHasError: sessionUnseenHasError, + }, + project: { + all: projectAll, + unseen: projectUnseen, + unseenCount: projectUnseenCount, + unseenHasError: projectUnseenHasError, + }, + } +} diff --git a/packages/app/src/context/notification.test.ts b/packages/app/src/context/notification.test.ts new file mode 100644 index 0000000000..44bacb7049 --- /dev/null +++ b/packages/app/src/context/notification.test.ts @@ -0,0 +1,73 @@ +import { describe, expect, test } from "bun:test" +import { buildNotificationIndex } from "./notification-index" + +type Notification = { + type: "turn-complete" | "error" + session: string + directory: string + viewed: boolean + time: number +} + +const turn = (session: string, directory: string, viewed = false): Notification => ({ + type: "turn-complete", + session, + directory, + viewed, + time: 1, +}) + +const error = (session: string, directory: string, viewed = false): Notification => ({ + type: "error", + session, + directory, + viewed, + time: 1, +}) + +describe("buildNotificationIndex", () => { + test("builds unseen counts and unseen error flags", () => { + const list = [ + turn("s1", "d1", false), + error("s1", "d1", false), + turn("s1", "d1", true), + turn("s2", "d1", false), + error("s3", "d2", true), + ] + + const index = buildNotificationIndex(list) + + expect(index.session.all.get("s1")?.length).toBe(3) + expect(index.session.unseen.get("s1")?.length).toBe(2) + expect(index.session.unseenCount.get("s1")).toBe(2) + expect(index.session.unseenHasError.get("s1")).toBe(true) + + expect(index.session.unseenCount.get("s2")).toBe(1) + expect(index.session.unseenHasError.get("s2") ?? false).toBe(false) + expect(index.session.unseenCount.get("s3") ?? 0).toBe(0) + expect(index.session.unseenHasError.get("s3") ?? false).toBe(false) + + expect(index.project.unseenCount.get("d1")).toBe(3) + expect(index.project.unseenHasError.get("d1")).toBe(true) + expect(index.project.unseenCount.get("d2") ?? 0).toBe(0) + expect(index.project.unseenHasError.get("d2") ?? false).toBe(false) + }) + + test("updates selectors after viewed transitions", () => { + const list = [turn("s1", "d1", false), error("s1", "d1", false), turn("s2", "d1", false)] + const next = list.map((item) => (item.session === "s1" ? { ...item, viewed: true } : item)) + + const before = buildNotificationIndex(list) + const after = buildNotificationIndex(next) + + expect(before.session.unseenCount.get("s1")).toBe(2) + expect(before.session.unseenHasError.get("s1")).toBe(true) + expect(before.project.unseenCount.get("d1")).toBe(3) + expect(before.project.unseenHasError.get("d1")).toBe(true) + + expect(after.session.unseenCount.get("s1") ?? 0).toBe(0) + expect(after.session.unseenHasError.get("s1") ?? false).toBe(false) + expect(after.project.unseenCount.get("d1")).toBe(1) + expect(after.project.unseenHasError.get("d1") ?? false).toBe(false) + }) +}) diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 6c110cae14..b876bd8627 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -13,6 +13,7 @@ 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 { buildNotificationIndex } from "./notification-index" type NotificationBase = { directory?: string @@ -81,49 +82,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi setStore("list", (list) => pruneNotifications([...list, notification])) } - const index = createMemo(() => { - const sessionAll = new Map() - const sessionUnseen = new Map() - const projectAll = new Map() - const projectUnseen = new Map() - - for (const notification of store.list) { - const session = notification.session - if (session) { - const list = sessionAll.get(session) - if (list) list.push(notification) - else sessionAll.set(session, [notification]) - if (!notification.viewed) { - const unseen = sessionUnseen.get(session) - if (unseen) unseen.push(notification) - else sessionUnseen.set(session, [notification]) - } - } - - const directory = notification.directory - if (directory) { - const list = projectAll.get(directory) - if (list) list.push(notification) - else projectAll.set(directory, [notification]) - if (!notification.viewed) { - const unseen = projectUnseen.get(directory) - if (unseen) unseen.push(notification) - else projectUnseen.set(directory, [notification]) - } - } - } - - return { - session: { - all: sessionAll, - unseen: sessionUnseen, - }, - project: { - all: projectAll, - unseen: projectUnseen, - }, - } - }) + const index = createMemo(() => buildNotificationIndex(store.list)) const unsub = globalSDK.event.listen((e) => { const event = e.details @@ -208,6 +167,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi unseen(session: string) { return index().session.unseen.get(session) ?? empty }, + unseenCount(session: string) { + return index().session.unseenCount.get(session) ?? 0 + }, + unseenHasError(session: string) { + return index().session.unseenHasError.get(session) ?? false + }, markViewed(session: string) { setStore("list", (n) => n.session === session, "viewed", true) }, @@ -219,6 +184,12 @@ export const { use: useNotification, provider: NotificationProvider } = createSi unseen(directory: string) { return index().project.unseen.get(directory) ?? empty }, + unseenCount(directory: string) { + return index().project.unseenCount.get(directory) ?? 0 + }, + unseenHasError(directory: string) { + return index().project.unseenHasError.get(directory) ?? false + }, markViewed(directory: string) { setStore("list", (n) => n.directory === directory, "viewed", true) }, diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index c538b920cb..3b66258c97 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -76,6 +76,44 @@ import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" +const OPENCODE_PROJECT_ID = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" + +const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") + +function sortSessions(now: number) { + const oneMinuteAgo = now - 60 * 1000 + return (a: Session, b: Session) => { + const aUpdated = a.time.updated ?? a.time.created + const bUpdated = b.time.updated ?? b.time.created + const aRecent = aUpdated > oneMinuteAgo + const bRecent = bUpdated > oneMinuteAgo + if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + if (aRecent && !bRecent) return -1 + if (!aRecent && bRecent) return 1 + return bUpdated - aUpdated + } +} + +const isRootVisibleSession = (session: Session, directory: string) => + workspaceKey(session.directory) === workspaceKey(directory) && !session.parentID && !session.time?.archived + +const sortedRootSessions = (store: { session: Session[]; path: { directory: string } }, now: number) => + store.session.filter((session) => isRootVisibleSession(session, store.path.directory)).toSorted(sortSessions(now)) + +const childMapByParent = (sessions: Session[]) => { + const map = new Map() + for (const session of sessions) { + if (!session.parentID) continue + const existing = map.get(session.parentID) + if (existing) { + existing.push(session.id) + continue + } + map.set(session.parentID, [session.id]) + } + return map +} + export default function Layout(props: ParentProps) { const [store, setStore, , ready] = persisted( Persist.global("layout.page", ["layout.page.v1"]), @@ -119,6 +157,7 @@ export default function Layout(props: ParentProps) { dark: "theme.scheme.dark", } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const currentDir = createMemo(() => decode64(params.dir) ?? "") const [state, setState] = createStore({ autoselect: !initialDirectory, @@ -143,8 +182,6 @@ export default function Layout(props: ParentProps) { }) } const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) - const editorRef = { current: undefined as HTMLInputElement | undefined } - const navLeave = { current: undefined as number | undefined } const aim = createAim({ @@ -289,7 +326,6 @@ export default function Layout(props: ParentProps) { > { - editorRef.current = el requestAnimationFrame(() => el.focus()) }} value={editorValue()} @@ -466,10 +502,9 @@ export default function Layout(props: ParentProps) { } } - const currentDir = decode64(params.dir) const currentSession = params.id - if (directory === currentDir && props.sessionID === currentSession) return - if (directory === currentDir && session?.parentID === currentSession) return + if (directory === currentDir() && props.sessionID === currentSession) return + if (directory === currentDir() && session?.parentID === currentSession) return const existingToastId = toastBySession.get(sessionKey) if (existingToastId !== undefined) toaster.dismiss(existingToastId) @@ -495,20 +530,19 @@ export default function Layout(props: ParentProps) { onCleanup(unsub) createEffect(() => { - const currentDir = decode64(params.dir) const currentSession = params.id - if (!currentDir || !currentSession) return - const sessionKey = `${currentDir}:${currentSession}` + if (!currentDir() || !currentSession) return + const sessionKey = `${currentDir()}:${currentSession}` const toastId = toastBySession.get(sessionKey) if (toastId !== undefined) { toaster.dismiss(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } - const [store] = globalSync.child(currentDir, { bootstrap: false }) + const [store] = globalSync.child(currentDir(), { bootstrap: false }) const childSessions = store.session.filter((s) => s.parentID === currentSession) for (const child of childSessions) { - const childKey = `${currentDir}:${child.id}` + const childKey = `${currentDir()}:${child.id}` const childToastId = toastBySession.get(childKey) if (childToastId !== undefined) { toaster.dismiss(childToastId) @@ -519,20 +553,6 @@ export default function Layout(props: ParentProps) { }) }) - function sortSessions(now: number) { - const oneMinuteAgo = now - 60 * 1000 - return (a: Session, b: Session) => { - const aUpdated = a.time.updated ?? a.time.created - const bUpdated = b.time.updated ?? b.time.created - const aRecent = aUpdated > oneMinuteAgo - const bRecent = bUpdated > oneMinuteAgo - if (aRecent && bRecent) return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - if (aRecent && !bRecent) return -1 - if (!aRecent && bRecent) return 1 - return bUpdated - aUpdated - } - } - function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return if (state.scrollSessionKey === sessionKey) return @@ -549,7 +569,7 @@ export default function Layout(props: ParentProps) { } const currentProject = createMemo(() => { - const directory = decode64(params.dir) + const directory = currentDir() if (!directory) return const projects = layout.projects.list() @@ -614,8 +634,6 @@ export default function Layout(props: ParentProps) { ), ) - const workspaceKey = (directory: string) => directory.replace(/[\\/]+$/, "") - const workspaceName = (directory: string, projectId?: string, branch?: string) => { const key = workspaceKey(directory) const direct = store.workspaceName[key] ?? store.workspaceName[directory] @@ -687,29 +705,23 @@ export default function Layout(props: ParentProps) { const currentSessions = createMemo(() => { const project = currentProject() if (!project) return [] as Session[] - const compare = sortSessions(Date.now()) + const now = Date.now() if (workspaceSetting()) { const dirs = workspaceIds(project) - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const result: Session[] = [] for (const dir of dirs) { const expanded = store.workspaceExpanded[dir] ?? dir === project.worktree const active = dir === activeDir if (!expanded && !active) continue const [dirStore] = globalSync.child(dir, { bootstrap: true }) - const dirSessions = dirStore.session - .filter((session) => session.directory === dirStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + const dirSessions = sortedRootSessions(dirStore, now) result.push(...dirSessions) } return result } const [projectStore] = globalSync.child(project.worktree) - return projectStore.session - .filter((session) => session.directory === projectStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(compare) + return sortedRootSessions(projectStore, now) }) type PrefetchQueue = { @@ -951,7 +963,7 @@ export default function Layout(props: ParentProps) { const sessions = currentSessions() if (sessions.length === 0) return - const hasUnseen = sessions.some((session) => notification.session.unseen(session.id).length > 0) + const hasUnseen = sessions.some((session) => notification.session.unseenCount(session.id) > 0) if (!hasUnseen) return const activeIndex = params.id ? sessions.findIndex((s) => s.id === params.id) : -1 @@ -961,7 +973,7 @@ export default function Layout(props: ParentProps) { const index = offset > 0 ? (start + i) % sessions.length : (start - i + sessions.length) % sessions.length const session = sessions[index] if (!session) continue - if (notification.session.unseen(session.id).length === 0) continue + if (notification.session.unseenCount(session.id) === 0) continue prefetchSession(session, "high") @@ -1019,7 +1031,7 @@ export default function Layout(props: ParentProps) { } } - command.register(() => { + command.register("layout", () => { const commands: CommandOption[] = [ { id: "sidebar.toggle", @@ -1093,6 +1105,18 @@ export default function Layout(props: ParentProps) { if (session) archiveSession(session) }, }, + { + id: "workspace.new", + title: language.t("workspace.new"), + category: language.t("command.category.workspace"), + keybind: "mod+shift+w", + disabled: !workspaceSetting(), + onSelect: () => { + const project = currentProject() + if (!project) return + return createWorkspace(project) + }, + }, { id: "workspace.toggle", title: language.t("command.workspace.toggle"), @@ -1344,7 +1368,7 @@ export default function Layout(props: ParentProps) { layout.projects.close(directory) layout.projects.open(root) - if (params.dir && decode64(params.dir) === directory) { + if (params.dir && currentDir() === directory) { navigateToProject(root) } } @@ -1584,7 +1608,7 @@ export default function Layout(props: ParentProps) { if (!project) return if (workspaceSetting()) { - const activeDir = decode64(params.dir) ?? "" + const activeDir = currentDir() const dirs = [project.worktree, ...(project.sandboxes ?? [])] for (const directory of dirs) { const expanded = store.workspaceExpanded[directory] ?? directory === project.worktree @@ -1634,7 +1658,7 @@ export default function Layout(props: ParentProps) { const local = project.worktree const dirs = [local, ...(project.sandboxes ?? [])] const active = currentProject() - const directory = active?.worktree === project.worktree ? decode64(params.dir) : undefined + const directory = active?.worktree === project.worktree ? currentDir() : undefined const extra = directory && directory !== local && !dirs.includes(directory) ? directory : undefined const pending = extra ? WorktreeState.get(extra)?.status === "pending" : false @@ -1688,23 +1712,25 @@ export default function Layout(props: ParentProps) { const ProjectIcon = (props: { project: LocalProject; class?: string; notify?: boolean }): JSX.Element => { const notification = useNotification() - const notifications = createMemo(() => notification.project.unseen(props.project.worktree)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const unseenCount = createMemo(() => notification.project.unseenCount(props.project.worktree)) + const hasError = createMemo(() => notification.project.unseenHasError(props.project.worktree)) const name = createMemo(() => props.project.name || getFilename(props.project.worktree)) - const opencode = "4b0ea68d7af9a6031a7ffda7ad66e0cb83315750" - return (
0 && props.notify }} + classList={{ "badge-mask": unseenCount() > 0 && props.notify }} />
- 0 && props.notify}> + 0 && props.notify}>
+ children: Map }): JSX.Element => { const notification = useNotification() - const notifications = createMemo(() => notification.session.unseen(props.session.id)) - const hasError = createMemo(() => notifications().some((n) => n.type === "error")) + const unseenCount = createMemo(() => notification.session.unseenCount(props.session.id)) + const hasError = createMemo(() => notification.session.unseenHasError(props.session.id)) const [sessionStore] = globalSync.child(props.session.directory) const hasPermissions = createMemo(() => { const permissions = sessionStore.permission?.[props.session.id] ?? [] if (permissions.length > 0) return true - const childIDs = props.children?.get(props.session.id) - if (childIDs) { - for (const id of childIDs) { - const childPermissions = sessionStore.permission?.[id] ?? [] - if (childPermissions.length > 0) return true - } - return false - } - - const childSessions = sessionStore.session.filter((s) => s.parentID === props.session.id) - for (const child of childSessions) { - const childPermissions = sessionStore.permission?.[child.id] ?? [] + for (const id of props.children.get(props.session.id) ?? []) { + const childPermissions = sessionStore.permission?.[id] ?? [] if (childPermissions.length > 0) return true } return false @@ -1758,10 +1774,13 @@ export default function Layout(props: ParentProps) { const tint = createMemo(() => { const messages = sessionStore.message[props.session.id] if (!messages) return undefined - const user = messages - .slice() - .reverse() - .find((m) => m.role === "user") + let user: Message | undefined + for (let i = messages.length - 1; i >= 0; i--) { + const message = messages[i] + if (message.role !== "user") continue + user = message + break + } if (!user?.agent) return undefined const agent = sessionStore.agent.find((a) => a.name === user.agent) @@ -1828,7 +1847,7 @@ export default function Layout(props: ParentProps) {
- 0}> + 0}>
@@ -2023,30 +2042,10 @@ export default function Layout(props: ParentProps) { pendingRename: false, }) const slug = createMemo(() => base64Encode(props.directory)) - const sessions = createMemo(() => - workspaceStore.session - .filter((session) => session.directory === workspaceStore.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())), - ) - const children = createMemo(() => { - const map = new Map() - for (const session of workspaceStore.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) + const sessions = createMemo(() => sortedRootSessions(workspaceStore, Date.now())) + const children = createMemo(() => childMapByParent(workspaceStore.session)) const local = createMemo(() => props.directory === props.project.worktree) - const active = createMemo(() => { - const current = decode64(params.dir) ?? "" - return current === props.directory - }) + const active = createMemo(() => currentDir() === props.directory) const workspaceValue = createMemo(() => { const branch = workspaceStore.vcs?.branch const name = branch ?? getFilename(props.directory) @@ -2257,7 +2256,7 @@ export default function Layout(props: ParentProps) { const SortableProject = (props: { project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.project.worktree) const selected = createMemo(() => { - const current = decode64(params.dir) ?? "" + const current = currentDir() return props.project.worktree === current || props.project.sandboxes?.includes(current) }) @@ -2288,25 +2287,16 @@ export default function Layout(props: ParentProps) { return `${kind} : ${name}` } - const sessions = (directory: string) => { + const projectStore = createMemo(() => globalSync.child(props.project.worktree, { bootstrap: false })[0]) + const projectSessions = createMemo(() => sortedRootSessions(projectStore(), Date.now()).slice(0, 2)) + const projectChildren = createMemo(() => childMapByParent(projectStore().session)) + const workspaceSessions = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) + return sortedRootSessions(data, Date.now()).slice(0, 2) } - - const projectSessions = () => { - const directory = props.project.worktree + const workspaceChildren = (directory: string) => { const [data] = globalSync.child(directory, { bootstrap: false }) - const root = workspaceKey(directory) - return data.session - .filter((session) => workspaceKey(session.directory) === root) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - .slice(0, 2) + return childMapByParent(data.session) } const projectName = () => props.project.name || getFilename(props.project.worktree) @@ -2435,33 +2425,39 @@ export default function Layout(props: ParentProps) { dense mobile={props.mobile} popover={false} + children={projectChildren()} /> )} } > - {(directory) => ( -
-
-
- + {(directory) => { + const sessions = createMemo(() => workspaceSessions(directory)) + const children = createMemo(() => workspaceChildren(directory)) + return ( +
+
+
+ +
+ {label(directory)}
- {label(directory)} + + {(session) => ( + + )} +
- - {(session) => ( - - )} - -
- )} + ) + }}
@@ -2494,27 +2490,8 @@ export default function Layout(props: ParentProps) { return { store, setStore } }) const slug = createMemo(() => base64Encode(props.project.worktree)) - const sessions = createMemo(() => { - const store = workspace().store - return store.session - .filter((session) => session.directory === store.path.directory) - .filter((session) => !session.parentID && !session.time?.archived) - .toSorted(sortSessions(Date.now())) - }) - const children = createMemo(() => { - const store = workspace().store - const map = new Map() - for (const session of store.session) { - if (!session.parentID) continue - const existing = map.get(session.parentID) - if (existing) { - existing.push(session.id) - continue - } - map.set(session.parentID, [session.id]) - } - return map - }) + const sessions = createMemo(() => sortedRootSessions(workspace().store, Date.now())) + const children = createMemo(() => childMapByParent(workspace().store.session)) const booted = createMemo((prev) => prev || workspace().store.status === "complete", false) const loading = createMemo(() => !booted() && sessions().length === 0) const hasMore = createMemo(() => workspace().store.sessionTotal > sessions().length) @@ -2819,21 +2796,6 @@ export default function Layout(props: ParentProps) { const SidebarContent = (sidebarProps: { mobile?: boolean }) => { const expanded = () => sidebarProps.mobile || layout.sidebar.opened() - command.register(() => [ - { - id: "workspace.new", - title: language.t("workspace.new"), - category: language.t("command.category.workspace"), - keybind: "mod+shift+w", - disabled: !workspaceSetting(), - onSelect: () => { - const project = currentProject() - if (!project) return - return createWorkspace(project) - }, - }, - ]) - return (
From 6c0dce67110d811cc5df918cad1c3b50e6b308ce Mon Sep 17 00:00:00 2001 From: Cameron Date: Fri, 6 Feb 2026 12:13:24 +0000 Subject: [PATCH 17/68] fix(desktop): support desktop titlebar double-click maximize (#12459) --- packages/app/src/components/titlebar.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index d8735410a4..2e22dc6331 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -83,7 +83,14 @@ export function Titlebar() { const tauri = ( window as unknown as { - __TAURI__?: { window?: { getCurrentWindow?: () => { startDragging?: () => Promise } } } + __TAURI__?: { + window?: { + getCurrentWindow?: () => { + startDragging?: () => Promise + toggleMaximize?: () => Promise + } + } + } } ).__TAURI__ if (!tauri?.window?.getCurrentWindow) return @@ -129,10 +136,23 @@ export function Titlebar() { void win.startDragging().catch(() => undefined) } + const maximize = (e: MouseEvent) => { + if (platform.platform !== "desktop") return + if (interactive(e.target)) return + if (e.target instanceof Element && e.target.closest("[data-tauri-decorum-tb]")) return + + const win = getWin() + if (!win?.toggleMaximize) return + + e.preventDefault() + void win.toggleMaximize().catch(() => undefined) + } + return (
Date: Fri, 6 Feb 2026 07:18:45 -0500 Subject: [PATCH 18/68] fix(ui): add Windows File Explorer icon for session header (#12386) --- packages/app/src/components/session/session-header.tsx | 2 +- packages/ui/src/assets/icons/app/file-explorer.svg | 1 + packages/ui/src/components/app-icon.tsx | 2 ++ packages/ui/src/components/app-icons/types.ts | 1 + 4 files changed, 5 insertions(+), 1 deletion(-) create mode 100644 packages/ui/src/assets/icons/app/file-explorer.svg diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 43057d63b9..61bc26e350 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -102,7 +102,7 @@ export function SessionHeader() { { id: "vscode", label: "VS Code", icon: "vscode", openWith: "code" }, { id: "cursor", label: "Cursor", icon: "cursor", openWith: "cursor" }, { id: "zed", label: "Zed", icon: "zed", openWith: "zed" }, - { id: "finder", label: "File Explorer", icon: "finder" }, + { id: "finder", label: "File Explorer", icon: "file-explorer" }, { id: "powershell", label: "PowerShell", icon: "powershell", openWith: "powershell" }, ] as const } diff --git a/packages/ui/src/assets/icons/app/file-explorer.svg b/packages/ui/src/assets/icons/app/file-explorer.svg new file mode 100644 index 0000000000..316cbab35d --- /dev/null +++ b/packages/ui/src/assets/icons/app/file-explorer.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/packages/ui/src/components/app-icon.tsx b/packages/ui/src/components/app-icon.tsx index f58b5d38ce..c698f32c69 100644 --- a/packages/ui/src/components/app-icon.tsx +++ b/packages/ui/src/components/app-icon.tsx @@ -5,6 +5,7 @@ import type { IconName } from "./app-icons/types" import androidStudio from "../assets/icons/app/android-studio.svg" import antigravity from "../assets/icons/app/antigravity.svg" import cursor from "../assets/icons/app/cursor.svg" +import fileExplorer from "../assets/icons/app/file-explorer.svg" import finder from "../assets/icons/app/finder.png" import ghostty from "../assets/icons/app/ghostty.svg" import iterm2 from "../assets/icons/app/iterm2.svg" @@ -19,6 +20,7 @@ const icons = { vscode, cursor, zed, + "file-explorer": fileExplorer, finder, terminal, iterm2, diff --git a/packages/ui/src/components/app-icons/types.ts b/packages/ui/src/components/app-icons/types.ts index 81964b8dac..0ad9f83d15 100644 --- a/packages/ui/src/components/app-icons/types.ts +++ b/packages/ui/src/components/app-icons/types.ts @@ -4,6 +4,7 @@ export const iconNames = [ "vscode", "cursor", "zed", + "file-explorer", "finder", "terminal", "iterm2", From ac88c6b637e3d4f2b1022abd06f1a052d644deca Mon Sep 17 00:00:00 2001 From: Alex Yaroshuk <34632190+alexyaroshuk@users.noreply.github.com> Date: Fri, 6 Feb 2026 20:19:25 +0800 Subject: [PATCH 19/68] feat(app): session last updated time display in command pallete's search (#12376) --- packages/app/src/components/dialog-select-file.tsx | 10 ++++++++++ packages/app/src/utils/time.ts | 14 ++++++++++++++ 2 files changed, 24 insertions(+) create mode 100644 packages/app/src/utils/time.ts diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 36448dd3e6..8e221577b9 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -15,6 +15,7 @@ import { useLayout } from "@/context/layout" import { useFile } from "@/context/file" import { useLanguage } from "@/context/language" import { decode64 } from "@/utils/base64" +import { getRelativeTime } from "@/utils/time" type EntryType = "command" | "file" | "session" @@ -30,6 +31,7 @@ type Entry = { directory?: string sessionID?: string archived?: number + updated?: number } type DialogSelectFileMode = "all" | "files" @@ -120,6 +122,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil title: string description: string archived?: number + updated?: number }): Entry => ({ id: `session:${input.directory}:${input.id}`, type: "session", @@ -129,6 +132,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil directory: input.directory, sessionID: input.id, archived: input.archived, + updated: input.updated, }) const list = createMemo(() => allowed().map(commandItem)) @@ -214,6 +218,7 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil description, directory, archived: s.time?.archived, + updated: s.time?.updated, })), ) .catch(() => [] as { id: string; title: string; description: string; directory: string; archived?: number }[]) @@ -384,6 +389,11 @@ export function DialogSelectFile(props: { mode?: DialogSelectFileMode; onOpenFil
+ + + {getRelativeTime(new Date(item.updated!).toISOString())} + +
diff --git a/packages/app/src/utils/time.ts b/packages/app/src/utils/time.ts new file mode 100644 index 0000000000..ac709d86dd --- /dev/null +++ b/packages/app/src/utils/time.ts @@ -0,0 +1,14 @@ +export function getRelativeTime(dateString: string): string { + const date = new Date(dateString) + const now = new Date() + const diffMs = now.getTime() - date.getTime() + const diffSeconds = Math.floor(diffMs / 1000) + const diffMinutes = Math.floor(diffSeconds / 60) + const diffHours = Math.floor(diffMinutes / 60) + const diffDays = Math.floor(diffHours / 24) + + if (diffSeconds < 60) return "Just now" + if (diffMinutes < 60) return `${diffMinutes}m ago` + if (diffHours < 24) return `${diffHours}h ago` + return `${diffDays}d ago` +} From 400bc7973a11835f95c1f3326e6e0d24d6cba096 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 6 Feb 2026 13:20:03 +0100 Subject: [PATCH 20/68] fix(desktop): update server removal logic to clear default server URL if removed (#12372) --- packages/app/src/components/dialog-select-server.tsx | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index e9e7646d5a..94596fa877 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -369,6 +369,9 @@ export function DialogSelectServer() { async function handleRemove(url: string) { server.remove(url) + if (await platform.getDefaultServerUrl?.() === url) { + platform.setDefaultServerUrl?.(null) + } } return ( From 732a3dab8c5f4fbb2c767c4d57ca6c9e498b819e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 6 Feb 2026 12:20:53 +0000 Subject: [PATCH 21/68] chore: generate --- packages/app/src/components/dialog-select-server.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 94596fa877..3d8f5b8466 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -369,7 +369,7 @@ export function DialogSelectServer() { async function handleRemove(url: string) { server.remove(url) - if (await platform.getDefaultServerUrl?.() === url) { + if ((await platform.getDefaultServerUrl?.()) === url) { platform.setDefaultServerUrl?.(null) } } From 0e73869580f18eb1f19d19f2fb6d7004dcbe4f32 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 06:28:06 -0600 Subject: [PATCH 22/68] fix(www): z icon color --- packages/console/app/src/routes/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/index.tsx b/packages/console/app/src/routes/index.tsx index 252dcbb974..4c5b50a375 100644 --- a/packages/console/app/src/routes/index.tsx +++ b/packages/console/app/src/routes/index.tsx @@ -810,7 +810,7 @@ export default function Home() {
From 0ec5f6608bdfea5be62dbbdc4c04a61de6d3e67c Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 07:10:23 -0600 Subject: [PATCH 23/68] fix(app): hide 'open in app' button on narrow viewports --- .../src/components/session/session-header.tsx | 138 +++++++++--------- 1 file changed, 71 insertions(+), 67 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 61bc26e350..ec2a231e45 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -268,74 +268,78 @@ export function SessionHeader() {
- - - {language.t("session.header.open.copyPath")} - - } - > -
- - - -
-
+ class="rounded-sm h-[24px] py-1.5 pr-3 pl-2 gap-2 border-none shadow-none" + onClick={copyPath} + aria-label={language.t("session.header.open.copyPath")} + > + + + {language.t("session.header.open.copyPath")} + + + } + > +
+ + + + + + + {language.t("session.header.openIn")} + { + if (!OPEN_APPS.includes(value as OpenApp)) return + setPrefs("app", value as OpenApp) + }} + > + {options().map((o) => ( + openDir(o.id)}> + + {o.label} + + + + + ))} + + + + + + + {language.t("session.header.open.copyPath")} + + + + + +
+
+
From 812597bb8b101896a8988493d37261ff851ae502 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Fri, 6 Feb 2026 08:54:51 -0600 Subject: [PATCH 24/68] feat(web): i18n (#12471) --- packages/console/app/src/app.tsx | 20 +- .../app/src/component/email-signup.tsx | 14 +- packages/console/app/src/component/footer.tsx | 16 +- packages/console/app/src/component/header.tsx | 66 +- .../app/src/component/language-picker.css | 135 ++++ .../app/src/component/language-picker.tsx | 34 + packages/console/app/src/component/legal.tsx | 12 +- packages/console/app/src/context/i18n.tsx | 27 + packages/console/app/src/context/language.tsx | 68 ++ packages/console/app/src/entry-server.tsx | 39 +- packages/console/app/src/i18n/ar.ts | 531 ++++++++++++++++ packages/console/app/src/i18n/br.ts | 466 ++++++++++++++ packages/console/app/src/i18n/da.ts | 463 ++++++++++++++ packages/console/app/src/i18n/de.ts | 468 ++++++++++++++ packages/console/app/src/i18n/en.ts | 591 ++++++++++++++++++ packages/console/app/src/i18n/es.ts | 465 ++++++++++++++ packages/console/app/src/i18n/fr.ts | 471 ++++++++++++++ packages/console/app/src/i18n/index.ts | 43 ++ packages/console/app/src/i18n/it.ts | 464 ++++++++++++++ packages/console/app/src/i18n/ja.ts | 502 +++++++++++++++ packages/console/app/src/i18n/ko.ts | 494 +++++++++++++++ packages/console/app/src/i18n/no.ts | 462 ++++++++++++++ packages/console/app/src/i18n/pl.ts | 466 ++++++++++++++ packages/console/app/src/i18n/ru.ts | 539 ++++++++++++++++ packages/console/app/src/i18n/th.ts | 541 ++++++++++++++++ packages/console/app/src/i18n/tr.ts | 467 ++++++++++++++ packages/console/app/src/i18n/zh.ts | 473 ++++++++++++++ packages/console/app/src/i18n/zht.ts | 473 ++++++++++++++ packages/console/app/src/lib/form-error.ts | 83 +++ packages/console/app/src/lib/language.ts | 175 ++++++ packages/console/app/src/routes/[...404].tsx | 14 +- .../console/app/src/routes/bench/[id].tsx | 74 ++- .../console/app/src/routes/bench/index.tsx | 12 +- packages/console/app/src/routes/black.tsx | 41 +- .../console/app/src/routes/black/common.tsx | 13 +- .../console/app/src/routes/black/index.tsx | 34 +- .../app/src/routes/black/subscribe/[plan].tsx | 80 ++- .../app/src/routes/black/workspace.tsx | 41 +- .../console/app/src/routes/brand/index.css | 1 + .../console/app/src/routes/brand/index.tsx | 12 +- .../app/src/routes/changelog/index.css | 1 + .../app/src/routes/changelog/index.tsx | 20 +- .../console/app/src/routes/docs/[...path].ts | 8 +- packages/console/app/src/routes/docs/index.ts | 8 +- .../console/app/src/routes/download/index.css | 1 + .../console/app/src/routes/download/index.tsx | 116 ++-- .../app/src/routes/enterprise/index.css | 1 + .../app/src/routes/enterprise/index.tsx | 61 +- packages/console/app/src/routes/index.css | 1 + packages/console/app/src/routes/index.tsx | 151 +++-- packages/console/app/src/routes/s/[id].ts | 8 +- .../console/app/src/routes/t/[...path].tsx | 8 +- packages/console/app/src/routes/temp.tsx | 45 +- packages/console/app/src/routes/user-menu.tsx | 4 +- .../app/src/routes/workspace-picker.tsx | 14 +- .../console/app/src/routes/workspace/[id].css | 29 + .../console/app/src/routes/workspace/[id].tsx | 28 +- .../[id]/billing/billing-section.tsx | 35 +- .../workspace/[id]/billing/black-section.tsx | 62 +- .../[id]/billing/monthly-limit-section.tsx | 40 +- .../[id]/billing/payment-section.tsx | 25 +- .../workspace/[id]/billing/reload-section.tsx | 46 +- .../routes/workspace/[id]/graph-section.tsx | 44 +- .../app/src/routes/workspace/[id]/index.tsx | 12 +- .../workspace/[id]/keys/key-section.tsx | 39 +- .../workspace/[id]/members/member-section.tsx | 109 ++-- .../workspace/[id]/members/role-dropdown.tsx | 6 +- .../routes/workspace/[id]/model-section.tsx | 16 +- .../workspace/[id]/new-user-section.tsx | 30 +- .../workspace/[id]/provider-section.tsx | 40 +- .../[id]/settings/settings-section.tsx | 27 +- .../routes/workspace/[id]/usage-section.tsx | 34 +- .../app/src/routes/workspace/common.tsx | 2 +- packages/console/app/src/routes/zen/index.css | 1 + packages/console/app/src/routes/zen/index.tsx | 132 ++-- 75 files changed, 9868 insertions(+), 726 deletions(-) create mode 100644 packages/console/app/src/component/language-picker.css create mode 100644 packages/console/app/src/component/language-picker.tsx create mode 100644 packages/console/app/src/context/i18n.tsx create mode 100644 packages/console/app/src/context/language.tsx create mode 100644 packages/console/app/src/i18n/ar.ts create mode 100644 packages/console/app/src/i18n/br.ts create mode 100644 packages/console/app/src/i18n/da.ts create mode 100644 packages/console/app/src/i18n/de.ts create mode 100644 packages/console/app/src/i18n/en.ts create mode 100644 packages/console/app/src/i18n/es.ts create mode 100644 packages/console/app/src/i18n/fr.ts create mode 100644 packages/console/app/src/i18n/index.ts create mode 100644 packages/console/app/src/i18n/it.ts create mode 100644 packages/console/app/src/i18n/ja.ts create mode 100644 packages/console/app/src/i18n/ko.ts create mode 100644 packages/console/app/src/i18n/no.ts create mode 100644 packages/console/app/src/i18n/pl.ts create mode 100644 packages/console/app/src/i18n/ru.ts create mode 100644 packages/console/app/src/i18n/th.ts create mode 100644 packages/console/app/src/i18n/tr.ts create mode 100644 packages/console/app/src/i18n/zh.ts create mode 100644 packages/console/app/src/i18n/zht.ts create mode 100644 packages/console/app/src/lib/form-error.ts create mode 100644 packages/console/app/src/lib/language.ts diff --git a/packages/console/app/src/app.tsx b/packages/console/app/src/app.tsx index cde2f01876..3d16a64ab7 100644 --- a/packages/console/app/src/app.tsx +++ b/packages/console/app/src/app.tsx @@ -6,19 +6,25 @@ import { Favicon } from "@opencode-ai/ui/favicon" import { Font } from "@opencode-ai/ui/font" import "@ibm/plex/css/ibm-plex.css" import "./app.css" +import { LanguageProvider } from "~/context/language" +import { I18nProvider } from "~/context/i18n" export default function App() { return ( ( - - opencode - - - - {props.children} - + + + + opencode + + + + {props.children} + + + )} > diff --git a/packages/console/app/src/component/email-signup.tsx b/packages/console/app/src/component/email-signup.tsx index 65f81b5fc6..bd33e92006 100644 --- a/packages/console/app/src/component/email-signup.tsx +++ b/packages/console/app/src/component/email-signup.tsx @@ -2,6 +2,7 @@ import { action, useSubmission } from "@solidjs/router" import dock from "../asset/lander/dock.png" import { Resource } from "@opencode-ai/console-resource" import { Show } from "solid-js" +import { useI18n } from "~/context/i18n" const emailSignup = action(async (formData: FormData) => { "use server" @@ -23,22 +24,21 @@ const emailSignup = action(async (formData: FormData) => { export function EmailSignup() { const submission = useSubmission(emailSignup) + const i18n = useI18n() return (
-

Be the first to know when we release new products

-

Join the waitlist for early access.

+

{i18n.t("email.title")}

+

{i18n.t("email.subtitle")}

- +
-
- Almost done, check your inbox and confirm your email address -
+
{i18n.t("email.success")}
{submission.error}
diff --git a/packages/console/app/src/component/footer.tsx b/packages/console/app/src/component/footer.tsx index 27f8ddd65f..45dae87ecf 100644 --- a/packages/console/app/src/component/footer.tsx +++ b/packages/console/app/src/component/footer.tsx @@ -2,12 +2,16 @@ import { createAsync } from "@solidjs/router" import { createMemo } from "solid-js" import { github } from "~/lib/github" import { config } from "~/config" +import { useLanguage } from "~/context/language" +import { useI18n } from "~/context/i18n" export function Footer() { + const language = useLanguage() + const i18n = useI18n() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars - ? new Intl.NumberFormat("en-US", { + ? new Intl.NumberFormat(language.tag(language.locale()), { notation: "compact", compactDisplay: "short", }).format(githubData()!.stars!) @@ -18,20 +22,20 @@ export function Footer() { ) diff --git a/packages/console/app/src/component/header.tsx b/packages/console/app/src/component/header.tsx index 72e9d04189..3eca8b88c1 100644 --- a/packages/console/app/src/component/header.tsx +++ b/packages/console/app/src/component/header.tsx @@ -19,6 +19,7 @@ import { createStore } from "solid-js/store" import { github } from "~/lib/github" import { createEffect, onCleanup } from "solid-js" import { config } from "~/config" +import { useI18n } from "~/context/i18n" import "./header-context-menu.css" const isDarkMode = () => window.matchMedia("(prefers-color-scheme: dark)").matches @@ -36,12 +37,14 @@ const fetchSvgContent = async (svgPath: string): Promise => { export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { const navigate = useNavigate() + const i18n = useI18n() const githubData = createAsync(() => github()) const starCount = createMemo(() => githubData()?.stars ? new Intl.NumberFormat("en-US", { notation: "compact", compactDisplay: "short", + maximumFractionDigits: 0, }).format(githubData()?.stars!) : config.github.starsFormatted.compact, ) @@ -119,8 +122,8 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) {
@@ -130,49 +133,56 @@ export function Header(props: { zen?: boolean; hideGetStarted?: boolean }) { style={`left: ${store.contextMenuPosition.x}px; top: ${store.contextMenuPosition.y}px;`} >