From ab3268896d2387a1dba36df2881a10a2ebf8dce6 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sun, 25 Jan 2026 20:56:37 -0500 Subject: [PATCH 001/294] Add highlight tag parsing for changelog with video support --- .../console/app/src/routes/changelog.json.ts | 110 ++++++++++++++++++ .../app/src/routes/changelog/index.css | 35 ++++++ .../app/src/routes/changelog/index.tsx | 83 ++++++++++++- 3 files changed, 227 insertions(+), 1 deletion(-) create mode 100644 packages/console/app/src/routes/changelog.json.ts diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts new file mode 100644 index 0000000000..b668229f81 --- /dev/null +++ b/packages/console/app/src/routes/changelog.json.ts @@ -0,0 +1,110 @@ +type Release = { + tag_name: string + name: string + body: string + published_at: string + html_url: string +} + +type Highlight = { + source: string + title: string + description: string + shortDescription?: string + image?: { + src: string + width: string + height: string + } + video?: string +} + +function parseHighlights(body: string): Highlight[] { + const highlights: Highlight[] = [] + const regex = /([\s\S]*?)<\/highlight>/g + let match + + while ((match = regex.exec(body)) !== null) { + const source = match[1] + const content = match[2] + + const titleMatch = content.match(/

([^<]+)<\/h2>/) + const pMatch = content.match(/([^<]+)<\/p>/) + const imgMatch = content.match(/ { + const parsed = parseMarkdown(release.body || "") + return { + tag: release.tag_name, + name: release.name, + date: release.published_at, + url: release.html_url, + highlights: parsed.highlights, + sections: parsed.sections, + } + }), + } +} diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css index a445c74474..a06fb00554 100644 --- a/packages/console/app/src/routes/changelog/index.css +++ b/packages/console/app/src/routes/changelog/index.css @@ -465,6 +465,41 @@ } } } + + [data-component="highlights"] { + display: flex; + flex-direction: column; + gap: 2rem; + margin-bottom: 1.5rem; + } + + [data-component="highlight"] { + h4 { + font-size: 14px; + font-weight: 600; + color: var(--color-text-strong); + margin-bottom: 8px; + } + + p[data-slot="title"] { + font-weight: 500; + color: var(--color-text-strong); + margin-bottom: 4px; + } + + p { + color: var(--color-text); + line-height: 1.5; + margin-bottom: 12px; + } + + img, + video { + max-width: 100%; + height: auto; + border-radius: 4px; + } + } } a { diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index c1b931fe3e..34fd5f83b7 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -40,6 +40,59 @@ function formatDate(dateString: string) { }) } +type Highlight = { + source: string + title: string + description: string + shortDescription?: string + image?: { + src: string + width: string + height: string + } + video?: string +} + +function parseHighlights(body: string): Highlight[] { + const highlights: Highlight[] = [] + const regex = /([\s\S]*?)<\/highlight>/g + let match + + while ((match = regex.exec(body)) !== null) { + const source = match[1] + const content = match[2] + + const titleMatch = content.match(/

([^<]+)<\/h2>/) + const pMatch = content.match(/([^<]+)<\/p>/) + const imgMatch = content.match(/ +

{props.highlight.source}

+

{props.highlight.title}

+

{props.highlight.description}

+ + + + {props.highlight.title} + + + ) +} + export default function Changelog() { const releases = createAsync(() => getReleases()) @@ -120,6 +196,11 @@ export default function Changelog() {
+ 0}> +
+ {(highlight) => } +
+
{(section) => (
From eaad75b1765745865c7d9d0b4826e3b30463882e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Sun, 25 Jan 2026 21:05:18 -0500 Subject: [PATCH 002/294] tweak: adjust tui syncing logic to help prevent case where agents would be undefined / missing --- .../opencode/src/cli/cmd/tui/context/sync.tsx | 55 ++++++++++++++----- 1 file changed, 40 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/cli/cmd/tui/context/sync.tsx b/packages/opencode/src/cli/cmd/tui/context/sync.tsx index 392cfb7f12..eb8ed2d9bb 100644 --- a/packages/opencode/src/cli/cmd/tui/context/sync.tsx +++ b/packages/opencode/src/cli/cmd/tui/context/sync.tsx @@ -333,32 +333,57 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const start = Date.now() - 30 * 24 * 60 * 60 * 1000 const sessionListPromise = sdk.client.session .list({ start: start }) - .then((x) => setStore("session", reconcile((x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))))) + .then((x) => (x.data ?? []).toSorted((a, b) => a.id.localeCompare(b.id))) // blocking - include session.list when continuing a session + const providersPromise = sdk.client.config.providers({}, { throwOnError: true }) + const providerListPromise = sdk.client.provider.list({}, { throwOnError: true }) + const agentsPromise = sdk.client.app.agents({}, { throwOnError: true }) + const configPromise = sdk.client.config.get({}, { throwOnError: true }) const blockingRequests: Promise[] = [ - sdk.client.config.providers({}, { throwOnError: true }).then((x) => { - batch(() => { - setStore("provider", reconcile(x.data!.providers)) - setStore("provider_default", reconcile(x.data!.default)) - }) - }), - sdk.client.provider.list({}, { throwOnError: true }).then((x) => { - batch(() => { - setStore("provider_next", reconcile(x.data!)) - }) - }), - sdk.client.app.agents({}, { throwOnError: true }).then((x) => setStore("agent", reconcile(x.data ?? []))), - sdk.client.config.get({}, { throwOnError: true }).then((x) => setStore("config", reconcile(x.data!))), + providersPromise, + providerListPromise, + agentsPromise, + configPromise, ...(args.continue ? [sessionListPromise] : []), ] await Promise.all(blockingRequests) + .then(() => { + const providersResponse = providersPromise.then((x) => x.data!) + const providerListResponse = providerListPromise.then((x) => x.data!) + const agentsResponse = agentsPromise.then((x) => x.data ?? []) + const configResponse = configPromise.then((x) => x.data!) + const sessionListResponse = args.continue ? sessionListPromise : undefined + + return Promise.all([ + providersResponse, + providerListResponse, + agentsResponse, + configResponse, + ...(sessionListResponse ? [sessionListResponse] : []), + ]).then((responses) => { + const providers = responses[0] + const providerList = responses[1] + const agents = responses[2] + const config = responses[3] + const sessions = responses[4] + + batch(() => { + setStore("provider", reconcile(providers.providers)) + setStore("provider_default", reconcile(providers.default)) + setStore("provider_next", reconcile(providerList)) + setStore("agent", reconcile(agents)) + setStore("config", reconcile(config)) + if (sessions !== undefined) setStore("session", reconcile(sessions)) + }) + }) + }) .then(() => { if (store.status !== "complete") setStore("status", "partial") // non-blocking Promise.all([ - ...(args.continue ? [] : [sessionListPromise]), + ...(args.continue ? [] : [sessionListPromise.then((sessions) => setStore("session", reconcile(sessions)))]), sdk.client.command.list().then((x) => setStore("command", reconcile(x.data ?? []))), sdk.client.lsp.status().then((x) => setStore("lsp", reconcile(x.data!))), sdk.client.mcp.status().then((x) => setStore("mcp", reconcile(x.data!))), From cc0085676b193ea77fd18bff08fcb4987a32af79 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Sun, 25 Jan 2026 21:18:26 -0500 Subject: [PATCH 003/294] Add collapsible sections, sticky version header, and style refinements for changelog highlights --- .../console/app/src/routes/changelog.json.ts | 54 ++++--- .../app/src/routes/changelog/index.css | 114 ++++++++++++-- .../app/src/routes/changelog/index.tsx | 148 +++++++++++------- 3 files changed, 223 insertions(+), 93 deletions(-) diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts index b668229f81..a23f205032 100644 --- a/packages/console/app/src/routes/changelog.json.ts +++ b/packages/console/app/src/routes/changelog.json.ts @@ -6,21 +6,22 @@ type Release = { html_url: string } -type Highlight = { - source: string +type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } + +type HighlightItem = { title: string description: string shortDescription?: string - image?: { - src: string - width: string - height: string - } - video?: string + media: HighlightMedia } -function parseHighlights(body: string): Highlight[] { - const highlights: Highlight[] = [] +type HighlightGroup = { + source: string + items: HighlightItem[] +} + +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map() const regex = /([\s\S]*?)<\/highlight>/g let match @@ -30,29 +31,32 @@ function parseHighlights(body: string): Highlight[] { const titleMatch = content.match(/

([^<]+)<\/h2>/) const pMatch = content.match(/([^<]+)<\/p>/) - const imgMatch = content.match(/ ({ source, items })) } function parseMarkdown(body: string) { diff --git a/packages/console/app/src/routes/changelog/index.css b/packages/console/app/src/routes/changelog/index.css index a06fb00554..233d85cc0e 100644 --- a/packages/console/app/src/routes/changelog/index.css +++ b/packages/console/app/src/routes/changelog/index.css @@ -367,11 +367,18 @@ display: flex; flex-direction: column; gap: 4px; + position: sticky; + top: 80px; + align-self: start; + background: var(--color-background); + padding: 8px 0; @media (max-width: 50rem) { + position: static; flex-direction: row; align-items: center; gap: 12px; + padding: 0; } [data-slot="version"] { @@ -402,24 +409,26 @@ [data-component="section"] { h3 { - font-size: 14px; + font-size: 13px; font-weight: 600; color: var(--color-text-strong); - margin-bottom: 8px; + margin-bottom: 6px; } ul { list-style: none; padding: 0; margin: 0; + padding-left: 16px; display: flex; flex-direction: column; - gap: 6px; + gap: 4px; li { color: var(--color-text); + font-size: 13px; line-height: 1.5; - padding-left: 16px; + padding-left: 12px; position: relative; &::before { @@ -431,7 +440,7 @@ [data-slot="author"] { color: var(--color-text-weak); - font-size: 13px; + font-size: 12px; margin-left: 4px; text-decoration: none; @@ -473,6 +482,72 @@ margin-bottom: 1.5rem; } + [data-component="collapsible-sections"] { + display: flex; + flex-direction: column; + gap: 0; + } + + [data-component="collapsible-section"] { + [data-slot="toggle"] { + display: flex; + align-items: center; + gap: 6px; + background: none; + border: none; + padding: 6px 0; + cursor: pointer; + font-family: inherit; + font-size: 13px; + font-weight: 600; + color: var(--color-text-weak); + + &:hover { + color: var(--color-text); + } + + [data-slot="icon"] { + font-size: 10px; + } + } + + ul { + list-style: none; + padding: 0; + margin: 0; + padding-left: 16px; + padding-bottom: 8px; + + li { + color: var(--color-text); + font-size: 13px; + line-height: 1.5; + padding-left: 12px; + position: relative; + + &::before { + content: "-"; + position: absolute; + left: 0; + color: var(--color-text-weak); + } + + [data-slot="author"] { + color: var(--color-text-weak); + font-size: 12px; + margin-left: 4px; + text-decoration: none; + + &:hover { + text-decoration: underline; + text-underline-offset: 2px; + text-decoration-thickness: 1px; + } + } + } + } + } + [data-component="highlight"] { h4 { font-size: 14px; @@ -481,16 +556,29 @@ margin-bottom: 8px; } - p[data-slot="title"] { - font-weight: 500; - color: var(--color-text-strong); - margin-bottom: 4px; + hr { + border: none; + border-top: 1px solid var(--color-border-weak); + margin-bottom: 16px; } - p { - color: var(--color-text); - line-height: 1.5; - margin-bottom: 12px; + [data-slot="highlight-item"] { + margin-bottom: 24px; + + &:last-child { + margin-bottom: 0; + } + + p[data-slot="title"] { + font-weight: 600; + font-size: 16px; + margin-bottom: 4px; + } + + p { + font-size: 14px; + margin-bottom: 12px; + } } img, diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index 34fd5f83b7..87e021ec88 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -5,7 +5,7 @@ import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { config } from "~/config" -import { For, Show } from "solid-js" +import { For, Show, createSignal } from "solid-js" type Release = { tag_name: string @@ -40,21 +40,22 @@ function formatDate(dateString: string) { }) } -type Highlight = { - source: string +type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } + +type HighlightItem = { title: string description: string shortDescription?: string - image?: { - src: string - width: string - height: string - } - video?: string + media: HighlightMedia } -function parseHighlights(body: string): Highlight[] { - const highlights: Highlight[] = [] +type HighlightGroup = { + source: string + items: HighlightItem[] +} + +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map() const regex = /([\s\S]*?)<\/highlight>/g let match @@ -64,33 +65,32 @@ function parseHighlights(body: string): Highlight[] { const titleMatch = content.match(/

([^<]+)<\/h2>/) const pMatch = content.match(/([^<]+)<\/p>/) - const imgMatch = content.match(/ ({ source, items })) } function parseMarkdown(body: string) { @@ -142,27 +142,60 @@ function ReleaseItem(props: { item: string }) { ) } -function HighlightCard(props: { highlight: Highlight }) { +function HighlightSection(props: { group: HighlightGroup }) { return (
-

{props.highlight.source}

-

{props.highlight.title}

-

{props.highlight.description}

- - - - {props.highlight.title} +

{props.group.source}

+
+ + {(item) => ( +
+

{item.title}

+

{item.description}

+ + + + {item.title} + +
+ )} +
+
+ ) +} + +function CollapsibleSection(props: { section: { title: string; items: string[] } }) { + const [open, setOpen] = createSignal(false) + + return ( +
+ + +
    + {(item) => } +
) } +function CollapsibleSections(props: { sections: { title: string; items: string[] }[] }) { + return ( +
+ {(section) => } +
+ ) +} + export default function Changelog() { const releases = createAsync(() => getReleases()) @@ -198,19 +231,24 @@ export default function Changelog() {
0}>
- {(highlight) => } + {(group) => }
- - {(section) => ( -
-

{section.title}

-
    - {(item) => } -
-
- )} -
+ 0 && parsed().sections.length > 0}> + + + + + {(section) => ( +
+

{section.title}

+
    + {(item) => } +
+
+ )} +
+
) From a5b72a7d994618555467b4269f48b0c000e2db84 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:32:56 -0600 Subject: [PATCH 004/294] fix(ui): tab click hit area --- packages/ui/src/components/tabs.css | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 3f5e710b6c..cebbd3b4fa 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -239,6 +239,7 @@ background-color: transparent; [data-slot="tabs-trigger"] { + height: 100%; padding: 0 8px; gap: 8px; justify-content: flex-start; @@ -333,6 +334,7 @@ gap: 12px; justify-content: flex-start; width: 100%; + height: 100%; } [data-component="icon"] { From 03d884797c19e7cbe92d7ef237c4b28d51b58f18 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Sun, 25 Jan 2026 15:59:56 -0600 Subject: [PATCH 005/294] wip(app): provider settings --- .../app/src/components/dialog-settings.tsx | 68 ++++---- .../app/src/components/settings-providers.tsx | 149 +++++++++++++++++- packages/app/src/i18n/en.ts | 10 ++ packages/opencode/src/server/server.ts | 30 ++++ packages/sdk/js/src/v2/gen/sdk.gen.ts | 32 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 29 ++++ packages/sdk/openapi.json | 50 ++++++ 7 files changed, 322 insertions(+), 46 deletions(-) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index dbbc8fa7ad..0b8d108d0a 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -39,16 +39,30 @@ export const DialogSettings: Component = () => { "padding-top": "12px", }} > - {language.t("settings.section.desktop")} -
- - - {language.t("settings.tab.general")} - - - - {language.t("settings.tab.shortcuts")} - +
+
+ {language.t("settings.section.desktop")} +
+ + + {language.t("settings.tab.general")} + + + + {language.t("settings.tab.shortcuts")} + +
+
+ +
+ {language.t("settings.section.server")} +
+ + + {language.t("settings.providers.title")} + +
+
@@ -56,31 +70,6 @@ export const DialogSettings: Component = () => { v{platform.version}

- {/* Server */} - {/* */} - {/* */} - {/* Permissions */} - {/* */} - {/* */} - {/* */} - {/* Providers */} - {/* */} - {/* */} - {/* */} - {/* Models */} - {/* */} - {/* */} - {/* */} - {/* Agents */} - {/* */} - {/* */} - {/* */} - {/* Commands */} - {/* */} - {/* */} - {/* */} - {/* MCP */} - {/* */} @@ -88,12 +77,9 @@ export const DialogSettings: Component = () => { - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} - {/* */} + + + {/* */} {/* */} {/* */} diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 7b6ca19392..b175a570b0 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -1,14 +1,153 @@ -import { Component } from "solid-js" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Tag } from "@opencode-ai/ui/tag" +import { showToast } from "@opencode-ai/ui/toast" +import type { IconName } from "@opencode-ai/ui/icons/provider" +import { popularProviders, useProviders } from "@/hooks/use-providers" +import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" +import { useGlobalSDK } from "@/context/global-sdk" +import { DialogConnectProvider } from "./dialog-connect-provider" +import { DialogSelectProvider } from "./dialog-select-provider" + +type ProviderSource = "env" | "api" | "config" | "custom" +type ProviderMeta = { source?: ProviderSource } export const SettingsProviders: Component = () => { + const dialog = useDialog() const language = useLanguage() + const globalSDK = useGlobalSDK() + const providers = useProviders() + + const connected = createMemo(() => providers.connected()) + const popular = createMemo(() => { + const items = providers.popular().slice() + items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) + return items + }) + + const source = (item: unknown) => (item as ProviderMeta).source + + const disconnect = async (providerID: string, name: string) => { + await globalSDK.client.auth + .remove({ providerID }) + .then(async () => { + await globalSDK.client.global.dispose() + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("provider.disconnect.toast.disconnected.title", { provider: name }), + description: language.t("provider.disconnect.toast.disconnected.description", { provider: name }), + }) + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + } return ( -
-
-

{language.t("settings.providers.title")}

-

{language.t("settings.providers.description")}

+
+
+
+
+

{language.t("settings.providers.title")}

+
+
+
+ +
+
+

{language.t("settings.providers.section.connected")}

+
+ 0} + fallback={ +
+ {language.t("settings.providers.connected.empty")} +
+ } + > + + {(item) => ( +
+
+ + {item.name} + + {language.t("settings.providers.tag.environment")} + + + {language.t("provider.connect.method.apiKey")} + +
+ + + +
+ )} +
+
+
+
+ +
+

{language.t("settings.providers.section.popular")}

+
+ + {(item) => ( +
+
+ + {item.name} + + {language.t("dialog.provider.tag.recommended")} + + +
{language.t("dialog.provider.anthropic.note")}
+
+ +
{language.t("dialog.provider.openai.note")}
+
+ +
{language.t("dialog.provider.copilot.note")}
+
+
+ +
+ )} +
+
+ + +
) diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index b32f034855..a34c8ef218 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -137,6 +137,9 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} connected", "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", + "provider.disconnect.toast.disconnected.title": "{{provider}} disconnected", + "provider.disconnect.toast.disconnected.description": "{{provider}} models are no longer available.", + "model.tag.free": "Free", "model.tag.latest": "Latest", "model.provider.anthropic": "Anthropic", @@ -159,6 +162,8 @@ export const dict = { "common.loading": "Loading", "common.loading.ellipsis": "...", "common.cancel": "Cancel", + "common.connect": "Connect", + "common.disconnect": "Disconnect", "common.submit": "Submit", "common.save": "Save", "common.saving": "Saving...", @@ -491,6 +496,7 @@ export const dict = { "sidebar.project.viewAllSessions": "View all sessions", "settings.section.desktop": "Desktop", + "settings.section.server": "Server", "settings.tab.general": "General", "settings.tab.shortcuts": "Shortcuts", @@ -599,6 +605,10 @@ export const dict = { "settings.providers.title": "Providers", "settings.providers.description": "Provider settings will be configurable here.", + "settings.providers.section.connected": "Connected providers", + "settings.providers.connected.empty": "No connected providers", + "settings.providers.section.popular": "Popular providers", + "settings.providers.tag.environment": "Environment", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", "settings.agents.title": "Agents", diff --git a/packages/opencode/src/server/server.ts b/packages/opencode/src/server/server.ts index fa646f21ea..302c5376d2 100644 --- a/packages/opencode/src/server/server.ts +++ b/packages/opencode/src/server/server.ts @@ -441,6 +441,36 @@ export namespace Server { return c.json(true) }, ) + .delete( + "/auth/:providerID", + describeRoute({ + summary: "Remove auth credentials", + description: "Remove authentication credentials", + operationId: "auth.remove", + responses: { + 200: { + description: "Successfully removed authentication credentials", + content: { + "application/json": { + schema: resolver(z.boolean()), + }, + }, + }, + ...errors(400), + }, + }), + validator( + "param", + z.object({ + providerID: z.string(), + }), + ), + async (c) => { + const providerID = c.req.valid("param").providerID + await Auth.remove(providerID) + return c.json(true) + }, + ) .get( "/event", describeRoute({ diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 67e7ac80cb..d39dd2b348 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -9,6 +9,8 @@ import type { AppLogResponses, AppSkillsResponses, Auth as Auth3, + AuthRemoveErrors, + AuthRemoveResponses, AuthSetErrors, AuthSetResponses, CommandListResponses, @@ -3054,6 +3056,36 @@ export class Formatter extends HeyApiClient { } export class Auth2 extends HeyApiClient { + /** + * Remove auth credentials + * + * Remove authentication credentials + */ + public remove( + parameters: { + providerID: string + directory?: string + }, + options?: Options, + ) { + const params = buildClientParams( + [parameters], + [ + { + args: [ + { in: "path", key: "providerID" }, + { in: "query", key: "directory" }, + ], + }, + ], + ) + return (options?.client ?? this.client).delete({ + url: "/auth/{providerID}", + ...options, + ...params, + }) + } + /** * Set auth credentials * diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 38a52b325a..9258bc0cde 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -4867,6 +4867,35 @@ export type FormatterStatusResponses = { export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses] +export type AuthRemoveData = { + body?: never + path: { + providerID: string + } + query?: { + directory?: string + } + url: "/auth/{providerID}" +} + +export type AuthRemoveErrors = { + /** + * Bad request + */ + 400: BadRequestError +} + +export type AuthRemoveError = AuthRemoveErrors[keyof AuthRemoveErrors] + +export type AuthRemoveResponses = { + /** + * Successfully removed authentication credentials + */ + 200: boolean +} + +export type AuthRemoveResponse = AuthRemoveResponses[keyof AuthRemoveResponses] + export type AuthSetData = { body?: Auth path: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index a029d0ef0e..8808bcf7d8 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -5709,6 +5709,56 @@ "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.set({\n ...\n})" } ] + }, + "delete": { + "operationId": "auth.remove", + "parameters": [ + { + "in": "query", + "name": "directory", + "schema": { + "type": "string" + } + }, + { + "in": "path", + "name": "providerID", + "schema": { + "type": "string" + }, + "required": true + } + ], + "summary": "Remove auth credentials", + "description": "Remove authentication credentials", + "responses": { + "200": { + "description": "Successfully removed authentication credentials", + "content": { + "application/json": { + "schema": { + "type": "boolean" + } + } + } + }, + "400": { + "description": "Bad request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/BadRequestError" + } + } + } + } + }, + "x-codeSamples": [ + { + "lang": "js", + "source": "import { createOpencodeClient } from \"@opencode-ai/sdk\n\nconst client = createOpencodeClient()\nawait client.auth.remove({\n ...\n})" + } + ] } }, "/event": { From c323d96deb3f1d98bc62160f4e954aad1a807253 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Sun, 25 Jan 2026 17:05:24 -0600 Subject: [PATCH 006/294] wip(app): provider settings --- .../app/src/components/settings-providers.tsx | 36 ++++++++++--------- packages/app/src/context/global-sync.tsx | 1 + packages/app/src/i18n/en.ts | 3 ++ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index b175a570b0..4d842037fa 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -22,13 +22,28 @@ export const SettingsProviders: Component = () => { const connected = createMemo(() => providers.connected()) const popular = createMemo(() => { - const items = providers.popular().slice() + const connectedIDs = new Set(connected().map((p) => p.id)) + const items = providers + .popular() + .filter((p) => !connectedIDs.has(p.id)) + .slice() items.sort((a, b) => popularProviders.indexOf(a.id) - popularProviders.indexOf(b.id)) return items }) const source = (item: unknown) => (item as ProviderMeta).source + const type = (item: unknown) => { + const current = source(item) + if (current === "env") return language.t("settings.providers.tag.environment") + if (current === "api") return language.t("provider.connect.method.apiKey") + if (current === "config") return language.t("settings.providers.tag.config") + if (current === "custom") return language.t("settings.providers.tag.custom") + return language.t("settings.providers.tag.other") + } + + const canDisconnect = (item: unknown) => source(item) !== "env" + const disconnect = async (providerID: string, name: string) => { await globalSDK.client.auth .remove({ providerID }) @@ -48,14 +63,8 @@ export const SettingsProviders: Component = () => { } return ( -
-
+
+

{language.t("settings.providers.title")}

@@ -81,14 +90,9 @@ export const SettingsProviders: Component = () => {
{item.name} - - {language.t("settings.providers.tag.environment")} - - - {language.t("provider.connect.method.apiKey")} - + {type(item)}
- + diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 37d4d68912..0da7cafa5f 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -392,6 +392,7 @@ function createGlobalSync() { project: () => sdk.project.current().then((x) => setStore("project", x.data!.id)), provider: () => sdk.provider.list().then((x) => { + console.log("provider", x) setStore("provider", normalizeProviderList(x.data!)) }), agent: () => sdk.app.agents().then((x) => setStore("agent", x.data ?? [])), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index a34c8ef218..44bd40d095 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -609,6 +609,9 @@ export const dict = { "settings.providers.connected.empty": "No connected providers", "settings.providers.section.popular": "Popular providers", "settings.providers.tag.environment": "Environment", + "settings.providers.tag.config": "Config", + "settings.providers.tag.custom": "Custom", + "settings.providers.tag.other": "Other", "settings.models.title": "Models", "settings.models.description": "Model settings will be configurable here.", "settings.agents.title": "Agents", From 5993a098b4b1ec245c0795f4f79ab2fef1213487 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Sun, 25 Jan 2026 19:40:28 -0600 Subject: [PATCH 007/294] fix(core): don't override source in custom provider loaders --- packages/opencode/src/provider/provider.ts | 23 +++++++++---------- .../opencode/test/provider/provider.test.ts | 7 +++--- 2 files changed, 15 insertions(+), 15 deletions(-) diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index fdd4ccdfb6..f898d3be43 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -854,10 +854,9 @@ export namespace Provider { // Load for the main provider if auth exists if (auth) { const options = await plugin.auth.loader(() => Auth.get(providerID) as any, database[plugin.auth.provider]) - mergeProvider(plugin.auth.provider, { - source: "custom", - options: options, - }) + const opts = options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) } // If this is github-copilot plugin, also register for github-copilot-enterprise if auth exists @@ -870,10 +869,11 @@ export namespace Provider { () => Auth.get(enterpriseProviderID) as any, database[enterpriseProviderID], ) - mergeProvider(enterpriseProviderID, { - source: "custom", - options: enterpriseOptions, - }) + const opts = enterpriseOptions ?? {} + const patch: Partial = providers[enterpriseProviderID] + ? { options: opts } + : { source: "custom", options: opts } + mergeProvider(enterpriseProviderID, patch) } } } @@ -889,10 +889,9 @@ export namespace Provider { const result = await fn(data) if (result && (result.autoload || providers[providerID])) { if (result.getModel) modelLoaders[providerID] = result.getModel - mergeProvider(providerID, { - source: "custom", - options: result.options, - }) + const opts = result.options ?? {} + const patch: Partial = providers[providerID] ? { options: opts } : { source: "custom", options: opts } + mergeProvider(providerID, patch) } } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 8a2009646e..482587d8ac 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -46,9 +46,10 @@ test("provider loaded from env variable", async () => { fn: async () => { const providers = await Provider.list() expect(providers["anthropic"]).toBeDefined() - // Note: source becomes "custom" because CUSTOM_LOADERS run after env loading - // and anthropic has a custom loader that merges additional options - expect(providers["anthropic"].source).toBe("custom") + // Provider should retain its connection source even if custom loaders + // merge additional options. + expect(providers["anthropic"].source).toBe("env") + expect(providers["anthropic"].options.headers["anthropic-beta"]).toBeDefined() }, }) }) From 00d960d0807b9d1c0e620cd03a763227af178bd3 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Sun, 25 Jan 2026 20:17:53 -0600 Subject: [PATCH 008/294] chore: cleanup --- packages/app/src/components/settings-providers.tsx | 13 +++++-------- packages/app/src/i18n/ar.ts | 2 +- packages/app/src/i18n/br.ts | 2 +- packages/app/src/i18n/da.ts | 2 +- packages/app/src/i18n/de.ts | 2 +- packages/app/src/i18n/en.ts | 2 +- packages/app/src/i18n/es.ts | 2 +- packages/app/src/i18n/fr.ts | 2 +- packages/app/src/i18n/ja.ts | 2 +- packages/app/src/i18n/ko.ts | 2 +- packages/app/src/i18n/no.ts | 2 +- packages/app/src/i18n/pl.ts | 2 +- packages/app/src/i18n/ru.ts | 2 +- packages/app/src/i18n/zh.ts | 2 +- packages/app/src/i18n/zht.ts | 2 +- 15 files changed, 19 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 4d842037fa..b10ac5d390 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -63,12 +63,10 @@ export const SettingsProviders: Component = () => { } return ( -
+
-
-
-

{language.t("settings.providers.title")}

-
+
+

{language.t("settings.providers.title")}

@@ -127,7 +125,7 @@ export const SettingsProviders: Component = () => {
    - (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5)} - > + {(project) => ( diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 89056b2c84..b97f70fea7 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -41,11 +41,11 @@ export type Platform = { /** Fetch override */ fetch?: typeof fetch - /** Get the configured default server URL (desktop only) */ - getDefaultServerUrl?(): Promise + /** Get the configured default server URL (platform-specific) */ + getDefaultServerUrl?(): Promise | string | null - /** Set the default server URL to use on app startup (desktop only) */ - setDefaultServerUrl?(url: string | null): Promise + /** Set the default server URL to use on app startup (platform-specific) */ + setDefaultServerUrl?(url: string | null): Promise | void /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ parseMarkdown?(markdown: string): Promise diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index df8547636b..7fe03bb6af 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -6,6 +6,8 @@ import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" import pkg from "../package.json" +const DEFAULT_SERVER_URL_KEY = "opencode.settings.dat:defaultServerUrl" + const root = document.getElementById("root") if (import.meta.env.DEV && !(root instanceof HTMLElement)) { const locale = (() => { @@ -62,6 +64,26 @@ const platform: Platform = { }) .catch(() => undefined) }, + getDefaultServerUrl: () => { + if (typeof localStorage === "undefined") return null + try { + return localStorage.getItem(DEFAULT_SERVER_URL_KEY) + } catch { + return null + } + }, + setDefaultServerUrl: (url) => { + if (typeof localStorage === "undefined") return + try { + if (url) { + localStorage.setItem(DEFAULT_SERVER_URL_KEY, url) + return + } + localStorage.removeItem(DEFAULT_SERVER_URL_KEY) + } catch { + return + } + }, } render( From 3296b90372a86bc969a894b036eeafc2ef62adf9 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:00:12 -0600 Subject: [PATCH 023/294] fix(app): handle non-tool call permissions --- packages/app/src/pages/session.tsx | 201 ++++++++++++++++++++--------- 1 file changed, 143 insertions(+), 58 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5f743dcbaf..47b9d76c53 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -17,6 +17,7 @@ import { Tabs } from "@opencode-ai/ui/tabs" import { useCodeComponent } from "@opencode-ai/ui/context/code" import { LineComment as LineCommentView, LineCommentEditor } from "@opencode-ai/ui/line-comment" import { SessionTurn } from "@opencode-ai/ui/session-turn" +import { BasicTool } from "@opencode-ai/ui/basic-tool" import { createAutoScroll } from "@opencode-ai/ui/hooks" import { SessionReview } from "@opencode-ai/ui/session-review" import { Mark } from "@opencode-ai/ui/logo" @@ -184,6 +185,40 @@ export default function Page() { const prompt = usePrompt() const comments = useComments() const permission = usePermission() + + const request = createMemo(() => { + const sessionID = params.id + if (!sessionID) return + const next = sync.data.permission[sessionID]?.[0] + if (!next) return + if (next.tool) return + return next + }) + + const [responding, setResponding] = createSignal(false) + + createEffect( + on( + () => request()?.id, + () => setResponding(false), + { defer: true }, + ), + ) + + const decide = (response: "once" | "always" | "reject") => { + const perm = request() + if (!perm) return + if (responding()) return + + setResponding(true) + sdk.client.permission + .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err) + showToast({ title: language.t("common.requestFailed"), description: message }) + }) + .finally(() => setResponding(false)) + } const [pendingMessage, setPendingMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) @@ -730,7 +765,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => {}) + await sdk.client.session.abort({ sessionID }).catch(() => { }) } const revert = info()?.revert?.messageID // Find the last user message that's not already reverted @@ -813,69 +848,69 @@ export default function Page() { }, ...(sync.data.config.share !== "disabled" ? [ - { - id: "session.share", - title: "Share session", - description: "Share this session and copy the URL to clipboard", - category: "Session", - slash: "share", - disabled: !params.id || !!info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => { - navigator.clipboard.writeText(res.data!.share!.url).catch(() => - showToast({ - title: "Failed to copy URL to clipboard", - variant: "error", - }), - ) - }) - .then(() => + { + id: "session.share", + title: "Share session", + description: "Share this session and copy the URL to clipboard", + category: "Session", + slash: "share", + disabled: !params.id || !!info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => showToast({ - title: "Session shared", - description: "Share URL copied to clipboard!", - variant: "success", - }), - ) - .catch(() => - showToast({ - title: "Failed to share session", - description: "An error occurred while sharing the session", + title: "Failed to copy URL to clipboard", variant: "error", }), ) - }, + }) + .then(() => + showToast({ + title: "Session shared", + description: "Share URL copied to clipboard!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to share session", + description: "An error occurred while sharing the session", + variant: "error", + }), + ) }, - { - id: "session.unshare", - title: "Unshare session", - description: "Stop sharing this session", - category: "Session", - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: "Session unshared", - description: "Session unshared successfully!", - variant: "success", - }), - ) - .catch(() => - showToast({ - title: "Failed to unshare session", - description: "An error occurred while unsharing the session", - variant: "error", - }), - ) - }, + }, + { + id: "session.unshare", + title: "Unshare session", + description: "Stop sharing this session", + category: "Session", + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: "Session unshared", + description: "Session unshared successfully!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to unshare session", + description: "An error occurred while unsharing the session", + variant: "error", + }), + ) }, - ] + }, + ] : []), ]) @@ -1690,6 +1725,56 @@ export default function Page() { "md:max-w-200": !showTabs(), }} > + + {(perm) => ( +
    + + 0}> +
    + + {(pattern) => {pattern}} + +
    +
    + +
    + {language.t("settings.permissions.tool.doom_loop.description")} +
    +
    +
    +
    +
    + + + +
    +
    +
    + )} +
    + Date: Mon, 26 Jan 2026 07:11:58 -0600 Subject: [PATCH 024/294] fix(app): line selection waits on ready --- packages/ui/src/components/code.tsx | 53 ++++++++++-- packages/ui/src/components/diff-ssr.tsx | 75 ++++++++++++++++- packages/ui/src/components/diff.tsx | 107 ++++++++++++++++-------- 3 files changed, 190 insertions(+), 45 deletions(-) diff --git a/packages/ui/src/components/code.tsx b/packages/ui/src/components/code.tsx index a7687444f6..dbf942dbb6 100644 --- a/packages/ui/src/components/code.tsx +++ b/packages/ui/src/components/code.tsx @@ -128,20 +128,56 @@ export function Code(props: CodeProps) { } } - const notifyRendered = () => { - if (!local.onRendered) return + const lineCount = () => { + const text = local.file.contents + const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0) + return Math.max(1, total) + } + const applySelection = (range: SelectedLineRange | null) => { + const root = getRoot() + if (!root) return false + + const lines = lineCount() + if (root.querySelectorAll("[data-line]").length < lines) return false + + if (!range) { + file().setSelectedLines(null) + return true + } + + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + + if (start < 1 || end > lines) { + file().setSelectedLines(null) + return true + } + + if (!root.querySelector(`[data-line="${start}"]`) || !root.querySelector(`[data-line="${end}"]`)) { + file().setSelectedLines(null) + return true + } + + const normalized = (() => { + if (range.endSide != null) return { start: range.start, end: range.end } + if (range.side !== "deletions") return range + if (root.querySelector("[data-deletions]") != null) return range + return { start: range.start, end: range.end } + })() + + file().setSelectedLines(normalized) + return true + } + + const notifyRendered = () => { observer?.disconnect() observer = undefined renderToken++ const token = renderToken - const lines = (() => { - const text = local.file.contents - const total = text.split("\n").length - (text.endsWith("\n") ? 1 : 0) - return Math.max(1, total) - })() + const lines = lineCount() const isReady = (root: ShadowRoot) => root.querySelectorAll("[data-line]").length >= lines @@ -152,6 +188,7 @@ export function Code(props: CodeProps) { observer = undefined requestAnimationFrame(() => { if (token !== renderToken) return + applySelection(lastSelection) local.onRendered?.() }) } @@ -241,7 +278,7 @@ export function Code(props: CodeProps) { const setSelectedLines = (range: SelectedLineRange | null) => { lastSelection = range - file().setSelectedLines(range) + applySelection(range) } const scheduleSelectionUpdate = () => { diff --git a/packages/ui/src/components/diff-ssr.tsx b/packages/ui/src/components/diff-ssr.tsx index 4ab6320082..602e59a2f5 100644 --- a/packages/ui/src/components/diff-ssr.tsx +++ b/packages/ui/src/components/diff-ssr.tsx @@ -38,6 +38,77 @@ export function Diff(props: SSRDiffProps) { fileDiffRef.removeAttribute("data-color-scheme") } + const lineIndex = (split: boolean, element: HTMLElement) => { + const raw = element.dataset.lineIndex + if (!raw) return + const values = raw + .split(",") + .map((value) => parseInt(value, 10)) + .filter((value) => !Number.isNaN(value)) + if (values.length === 0) return + if (!split) return values[0] + if (values.length === 2) return values[1] + return values[0] + } + + const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: "additions" | "deletions" | undefined) => { + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (nodes.length === 0) return + + const targetSide = side ?? "additions" + + for (const node of nodes) { + if (findSide(node) === targetSide) return lineIndex(split, node) + if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node) + } + } + + const fixSelection = (range: SelectedLineRange | null) => { + if (!range) return range + const root = getRoot() + if (!root) return + + const diffs = root.querySelector("[data-diffs]") + if (!(diffs instanceof HTMLElement)) return + + const split = diffs.dataset.type === "split" + + const start = rowIndex(root, split, range.start, range.side) + const end = rowIndex(root, split, range.end, range.endSide ?? range.side) + + if (start === undefined || end === undefined) { + if (root.querySelector("[data-line], [data-alt-line]") == null) return + return null + } + if (start <= end) return range + + const side = range.endSide ?? range.side + const swapped: SelectedLineRange = { + start: range.end, + end: range.start, + } + if (side) swapped.side = side + if (range.endSide && range.side) swapped.endSide = range.side + + return swapped + } + + const setSelectedLines = (range: SelectedLineRange | null, attempt = 0) => { + const diff = fileDiffInstance + if (!diff) return + + const fixed = fixSelection(range) + if (fixed === undefined) { + if (attempt >= 120) return + requestAnimationFrame(() => setSelectedLines(range, attempt + 1)) + return + } + + diff.setSelectedLines(fixed) + } + const findSide = (element: HTMLElement): "additions" | "deletions" => { const line = element.closest("[data-line], [data-alt-line]") if (line instanceof HTMLElement) { @@ -159,14 +230,14 @@ export function Diff(props: SSRDiffProps) { containerWrapper: container, }) - fileDiffInstance.setSelectedLines(local.selectedLines ?? null) + setSelectedLines(local.selectedLines ?? null) createEffect(() => { fileDiffInstance?.setLineAnnotations(local.annotations ?? []) }) createEffect(() => { - fileDiffInstance?.setSelectedLines(local.selectedLines ?? null) + setSelectedLines(local.selectedLines ?? null) }) createEffect(() => { diff --git a/packages/ui/src/components/diff.tsx b/packages/ui/src/components/diff.tsx index c97744a9f5..21dada5350 100644 --- a/packages/ui/src/components/diff.tsx +++ b/packages/ui/src/components/diff.tsx @@ -115,9 +115,64 @@ export function Diff(props: DiffProps) { host.removeAttribute("data-color-scheme") } - const notifyRendered = () => { - if (!local.onRendered) return + const lineIndex = (split: boolean, element: HTMLElement) => { + const raw = element.dataset.lineIndex + if (!raw) return + const values = raw + .split(",") + .map((value) => parseInt(value, 10)) + .filter((value) => !Number.isNaN(value)) + if (values.length === 0) return + if (!split) return values[0] + if (values.length === 2) return values[1] + return values[0] + } + const rowIndex = (root: ShadowRoot, split: boolean, line: number, side: SelectionSide | undefined) => { + const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( + (node): node is HTMLElement => node instanceof HTMLElement, + ) + if (nodes.length === 0) return + + const targetSide = side ?? "additions" + + for (const node of nodes) { + if (findSide(node) === targetSide) return lineIndex(split, node) + if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(split, node) + } + } + + const fixSelection = (range: SelectedLineRange | null) => { + if (!range) return range + const root = getRoot() + if (!root) return + + const diffs = root.querySelector("[data-diffs]") + if (!(diffs instanceof HTMLElement)) return + + const split = diffs.dataset.type === "split" + + const start = rowIndex(root, split, range.start, range.side) + const end = rowIndex(root, split, range.end, range.endSide ?? range.side) + if (start === undefined || end === undefined) { + if (root.querySelector("[data-line], [data-alt-line]") == null) return + return null + } + if (start <= end) return range + + const side = range.endSide ?? range.side + const swapped: SelectedLineRange = { + start: range.end, + end: range.start, + } + + if (side) swapped.side = side + if (range.endSide && range.side) swapped.endSide = range.side + + return swapped + } + + const notifyRendered = () => { observer?.disconnect() observer = undefined renderToken++ @@ -134,6 +189,7 @@ export function Diff(props: DiffProps) { observer = undefined requestAnimationFrame(() => { if (token !== renderToken) return + setSelectedLines(lastSelection) local.onRendered?.() }) } @@ -173,7 +229,8 @@ export function Diff(props: DiffProps) { const root = getRoot() if (typeof MutationObserver === "undefined") { if (!root || !isReady(root)) return - local.onRendered() + setSelectedLines(lastSelection) + local.onRendered?.() return } @@ -214,41 +271,14 @@ export function Diff(props: DiffProps) { ) if (code.length === 0) return - const lineIndex = (element: HTMLElement) => { - const raw = element.dataset.lineIndex - if (!raw) return - const values = raw - .split(",") - .map((value) => parseInt(value, 10)) - .filter((value) => !Number.isNaN(value)) - if (values.length === 0) return - if (!split) return values[0] - if (values.length === 2) return values[1] - return values[0] - } - - const rowIndex = (line: number, side: SelectionSide | undefined) => { - const nodes = Array.from(root.querySelectorAll(`[data-line="${line}"], [data-alt-line="${line}"]`)).filter( - (node): node is HTMLElement => node instanceof HTMLElement, - ) - if (nodes.length === 0) return - - const targetSide = side ?? "additions" - - for (const node of nodes) { - if (findSide(node) === targetSide) return lineIndex(node) - if (parseInt(node.dataset.altLine ?? "", 10) === line) return lineIndex(node) - } - } - for (const range of ranges) { - const start = rowIndex(range.start, range.side) + const start = rowIndex(root, split, range.start, range.side) if (start === undefined) continue const end = (() => { const same = range.end === range.start && (range.endSide == null || range.endSide === range.side) if (same) return start - return rowIndex(range.end, range.endSide ?? range.side) + return rowIndex(root, split, range.end, range.endSide ?? range.side) })() if (end === undefined) continue @@ -258,7 +288,7 @@ export function Diff(props: DiffProps) { for (const block of code) { for (const element of Array.from(block.children)) { if (!(element instanceof HTMLElement)) continue - const idx = lineIndex(element) + const idx = lineIndex(split, element) if (idx === undefined) continue if (idx > last) break if (idx < first) continue @@ -275,8 +305,15 @@ export function Diff(props: DiffProps) { const setSelectedLines = (range: SelectedLineRange | null) => { const active = current() if (!active) return - lastSelection = range - active.setSelectedLines(range) + + const fixed = fixSelection(range) + if (fixed === undefined) { + lastSelection = range + return + } + + lastSelection = fixed + active.setSelectedLines(fixed) } const updateSelection = () => { From 1934ee13d8439ed38b9bcebb4e26e4d2d01f6f08 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:23:01 -0600 Subject: [PATCH 025/294] wip(app): model settings --- .../app/src/components/dialog-settings.tsx | 14 +- .../app/src/components/settings-models.tsx | 131 +++++++++++++++++- 2 files changed, 133 insertions(+), 12 deletions(-) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 9dd6efd688..5efee5a3c6 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -6,12 +6,8 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { SettingsGeneral } from "./settings-general" import { SettingsKeybinds } from "./settings-keybinds" -import { SettingsPermissions } from "./settings-permissions" import { SettingsProviders } from "./settings-providers" import { SettingsModels } from "./settings-models" -import { SettingsAgents } from "./settings-agents" -import { SettingsCommands } from "./settings-commands" -import { SettingsMcp } from "./settings-mcp" export const DialogSettings: Component = () => { const language = useLanguage() @@ -45,6 +41,10 @@ export const DialogSettings: Component = () => { {language.t("settings.providers.title")} + + + {language.t("settings.models.title")} +
@@ -64,9 +64,9 @@ export const DialogSettings: Component = () => { - {/* */} - {/* */} - {/* */} + + + {/* */} {/* */} {/* */} diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index 6a636879d0..a3ba45f611 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -1,14 +1,135 @@ -import { Component } from "solid-js" +import { useFilteredList } from "@opencode-ai/ui/hooks" +import { ProviderIcon } from "@opencode-ai/ui/provider-icon" +import { Switch } from "@opencode-ai/ui/switch" +import { Icon } from "@opencode-ai/ui/icon" +import { IconButton } from "@opencode-ai/ui/icon-button" +import { TextField } from "@opencode-ai/ui/text-field" +import type { IconName } from "@opencode-ai/ui/icons/provider" +import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" +import { type ModelKey, useLocal } from "@/context/local" +import { popularProviders } from "@/hooks/use-providers" + +type ModelItem = ReturnType["model"]["list"]>[number] export const SettingsModels: Component = () => { + const local = useLocal() const language = useLanguage() + const list = useFilteredList({ + items: (_filter) => local.model.list(), + key: (x) => `${x.provider.id}:${x.id}`, + filterKeys: ["provider.name", "name", "id"], + sortBy: (a, b) => a.name.localeCompare(b.name), + groupBy: (x) => x.provider.id, + sortGroupsBy: (a, b) => { + const aIndex = popularProviders.indexOf(a.category) + const bIndex = popularProviders.indexOf(b.category) + const aPopular = aIndex >= 0 + const bPopular = bIndex >= 0 + + if (aPopular && !bPopular) return -1 + if (!aPopular && bPopular) return 1 + if (aPopular && bPopular) return aIndex - bIndex + + const aName = a.items[0].provider.name + const bName = b.items[0].provider.name + return aName.localeCompare(bName) + }, + }) + return ( -
-
-

{language.t("settings.models.title")}

-

{language.t("settings.models.description")}

+
+
+
+

{language.t("settings.models.title")}

+
+ + + + + +
+
+
+ +
+ + + {language.t("common.loading")} + {language.t("common.loading.ellipsis")} + +
+ } + > + 0} + fallback={ +
+ {language.t("dialog.model.empty")} + + "{list.filter()}" + +
+ } + > + + {(group) => ( +
+
+ + {group.items[0].provider.name} +
+
+ + {(item) => { + const key: ModelKey = { providerID: item.provider.id, modelID: item.id } + return ( +
+
+ {item.name} +
+
+ { + local.model.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
+
+ ) + }} +
+
+
+ )} +
+
+
) From 84b12a8fb74c8b0ba3b3d8c07516826df0793e89 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 08:14:33 -0600 Subject: [PATCH 026/294] feat(app): model settings --- packages/app/src/app.tsx | 9 +- .../app/src/components/settings-models.tsx | 14 +- packages/app/src/context/local.tsx | 117 ++------------- packages/app/src/context/models.tsx | 140 ++++++++++++++++++ 4 files changed, 166 insertions(+), 114 deletions(-) create mode 100644 packages/app/src/context/models.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 6b9f887fa4..0980bedb93 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -21,6 +21,7 @@ import { PromptProvider } from "@/context/prompt" import { FileProvider } from "@/context/file" import { CommentsProvider } from "@/context/comments" import { NotificationProvider } from "@/context/notification" +import { ModelsProvider } from "@/context/models" import { DialogProvider } from "@opencode-ai/ui/context/dialog" import { CommandProvider } from "@/context/command" import { LanguageProvider, useLanguage } from "@/context/language" @@ -116,9 +117,11 @@ export function AppInterface(props: { defaultUrl?: string }) { - - {props.children} - + + + {props.children} + + diff --git a/packages/app/src/components/settings-models.tsx b/packages/app/src/components/settings-models.tsx index a3ba45f611..3a73a42de7 100644 --- a/packages/app/src/components/settings-models.tsx +++ b/packages/app/src/components/settings-models.tsx @@ -7,17 +7,17 @@ import { TextField } from "@opencode-ai/ui/text-field" import type { IconName } from "@opencode-ai/ui/icons/provider" import { type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { type ModelKey, useLocal } from "@/context/local" +import { useModels } from "@/context/models" import { popularProviders } from "@/hooks/use-providers" -type ModelItem = ReturnType["model"]["list"]>[number] +type ModelItem = ReturnType["list"]>[number] export const SettingsModels: Component = () => { - const local = useLocal() const language = useLanguage() + const models = useModels() const list = useFilteredList({ - items: (_filter) => local.model.list(), + items: (_filter) => models.list(), key: (x) => `${x.provider.id}:${x.id}`, filterKeys: ["provider.name", "name", "id"], sortBy: (a, b) => a.name.localeCompare(b.name), @@ -103,7 +103,7 @@ export const SettingsModels: Component = () => {
{(item) => { - const key: ModelKey = { providerID: item.provider.id, modelID: item.id } + const key = { providerID: item.provider.id, modelID: item.id } return (
@@ -111,9 +111,9 @@ export const SettingsModels: Component = () => {
{ - local.model.setVisibility(key, checked) + models.setVisibility(key, checked) }} hideLabel > diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 43d47e1b1b..4b27e6d37a 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,14 +1,12 @@ import { createStore, produce, reconcile } from "solid-js/store" import { batch, createEffect, createMemo, onCleanup } from "solid-js" -import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" import { useSync } from "./sync" import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" -import { DateTime } from "luxon" -import { Persist, persisted } from "@/utils/persist" +import { useModels } from "@/context/models" import { showToast } from "@opencode-ai/ui/toast" import { useLanguage } from "@/context/language" @@ -112,18 +110,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ })() const model = (() => { - const [store, setStore, _, modelReady] = persisted( - Persist.global("model", ["model.v1"]), - createStore<{ - user: (ModelKey & { visibility: "show" | "hide"; favorite?: boolean })[] - recent: ModelKey[] - variant?: Record - }>({ - user: [], - recent: [], - variant: {}, - }), - ) + const models = useModels() const [ephemeral, setEphemeral] = createStore<{ model: Record @@ -131,57 +118,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ model: {}, }) - const available = createMemo(() => - providers.connected().flatMap((p) => - Object.values(p.models).map((m) => ({ - ...m, - provider: p, - })), - ), - ) - - const latest = createMemo(() => - pipe( - available(), - filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), - groupBy((x) => x.provider.id), - mapValues((models) => - pipe( - models, - groupBy((x) => x.family), - values(), - (groups) => - groups.flatMap((g) => { - const first = firstBy(g, [(x) => x.release_date, "desc"]) - return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] - }), - ), - ), - values(), - flat(), - ), - ) - - const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`))) - - const userVisibilityMap = createMemo(() => { - const map = new Map() - for (const item of store.user) { - map.set(`${item.providerID}:${item.modelID}`, item.visibility) - } - return map - }) - - const list = createMemo(() => - available().map((m) => ({ - ...m, - name: m.name.replace("(latest)", "").trim(), - latest: m.name.includes("(latest)"), - })), - ) - - const find = (key: ModelKey) => list().find((m) => m.id === key?.modelID && m.provider.id === key.providerID) - const fallbackModel = createMemo(() => { if (sync.data.config.model) { const [providerID, modelID] = sync.data.config.model.split("/") @@ -193,7 +129,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } } - for (const item of store.recent) { + for (const item of models.recent.list()) { if (isModelValid(item)) { return item } @@ -225,10 +161,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ fallbackModel, ) if (!key) return undefined - return find(key) + return models.find(key) }) - const recent = createMemo(() => store.recent.map(find).filter(Boolean)) + const recent = createMemo(() => models.recent.list().map(models.find).filter(Boolean)) const cycle = (direction: 1 | -1) => { const recentList = recent() @@ -253,54 +189,32 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ }) } - function updateVisibility(model: ModelKey, visibility: "show" | "hide") { - const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) - if (index >= 0) { - setStore("user", index, { visibility }) - } else { - setStore("user", store.user.length, { ...model, visibility }) - } - } - return { - ready: modelReady, + ready: models.ready, current, recent, - list, + list: models.list, cycle, set(model: ModelKey | undefined, options?: { recent?: boolean }) { batch(() => { const currentAgent = agent.current() const next = model ?? fallbackModel() if (currentAgent) setEphemeral("model", currentAgent.name, next) - if (model) updateVisibility(model, "show") - if (options?.recent && model) { - const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) - if (uniq.length > 5) uniq.pop() - setStore("recent", uniq) - } + if (model) models.setVisibility(model, true) + if (options?.recent && model) models.recent.push(model) }) }, visible(model: ModelKey) { - const key = `${model.providerID}:${model.modelID}` - const visibility = userVisibilityMap().get(key) - if (visibility === "hide") return false - if (visibility === "show") return true - if (latestSet().has(key)) return true - // For models without valid release_date (e.g. custom models), show by default - const m = find(model) - if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true - return false + return models.visible(model) }, setVisibility(model: ModelKey, visible: boolean) { - updateVisibility(model, visible ? "show" : "hide") + models.setVisibility(model, visible) }, variant: { current() { const m = current() if (!m) return undefined - const key = `${m.provider.id}/${m.id}` - return store.variant?.[key] + return models.variant.get({ providerID: m.provider.id, modelID: m.id }) }, list() { const m = current() @@ -311,12 +225,7 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ set(value: string | undefined) { const m = current() if (!m) return - const key = `${m.provider.id}/${m.id}` - if (!store.variant) { - setStore("variant", { [key]: value }) - } else { - setStore("variant", key, value) - } + models.variant.set({ providerID: m.provider.id, modelID: m.id }, value) }, cycle() { const variants = this.list() diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx new file mode 100644 index 0000000000..fee3c10c6d --- /dev/null +++ b/packages/app/src/context/models.tsx @@ -0,0 +1,140 @@ +import { createMemo } from "solid-js" +import { createStore } from "solid-js/store" +import { DateTime } from "luxon" +import { filter, firstBy, flat, groupBy, mapValues, pipe, uniqueBy, values } from "remeda" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { useProviders } from "@/hooks/use-providers" +import { Persist, persisted } from "@/utils/persist" + +export type ModelKey = { providerID: string; modelID: string } + +type Visibility = "show" | "hide" +type User = ModelKey & { visibility: Visibility; favorite?: boolean } +type Store = { + user: User[] + recent: ModelKey[] + variant?: Record +} + +export const { use: useModels, provider: ModelsProvider } = createSimpleContext({ + name: "Models", + init: () => { + const providers = useProviders() + + const [store, setStore, _, ready] = persisted( + Persist.global("model", ["model.v1"]), + createStore({ + user: [], + recent: [], + variant: {}, + }), + ) + + const available = createMemo(() => + providers.connected().flatMap((p) => + Object.values(p.models).map((m) => ({ + ...m, + provider: p, + })), + ), + ) + + const latest = createMemo(() => + pipe( + available(), + filter((x) => Math.abs(DateTime.fromISO(x.release_date).diffNow().as("months")) < 6), + groupBy((x) => x.provider.id), + mapValues((models) => + pipe( + models, + groupBy((x) => x.family), + values(), + (groups) => + groups.flatMap((g) => { + const first = firstBy(g, [(x) => x.release_date, "desc"]) + return first ? [{ modelID: first.id, providerID: first.provider.id }] : [] + }), + ), + ), + values(), + flat(), + ), + ) + + const latestSet = createMemo(() => new Set(latest().map((x) => `${x.providerID}:${x.modelID}`))) + + const visibility = createMemo(() => { + const map = new Map() + for (const item of store.user) map.set(`${item.providerID}:${item.modelID}`, item.visibility) + return map + }) + + const list = createMemo(() => + available().map((m) => ({ + ...m, + name: m.name.replace("(latest)", "").trim(), + latest: m.name.includes("(latest)"), + })), + ) + + const find = (key: ModelKey) => list().find((m) => m.id === key.modelID && m.provider.id === key.providerID) + + function update(model: ModelKey, state: Visibility) { + const index = store.user.findIndex((x) => x.modelID === model.modelID && x.providerID === model.providerID) + if (index >= 0) { + setStore("user", index, { visibility: state }) + return + } + setStore("user", store.user.length, { ...model, visibility: state }) + } + + const visible = (model: ModelKey) => { + const key = `${model.providerID}:${model.modelID}` + const state = visibility().get(key) + if (state === "hide") return false + if (state === "show") return true + if (latestSet().has(key)) return true + const m = find(model) + if (!m?.release_date || !DateTime.fromISO(m.release_date).isValid) return true + return false + } + + const setVisibility = (model: ModelKey, state: boolean) => { + update(model, state ? "show" : "hide") + } + + const push = (model: ModelKey) => { + const uniq = uniqueBy([model, ...store.recent], (x) => x.providerID + x.modelID) + if (uniq.length > 5) uniq.pop() + setStore("recent", uniq) + } + + const variantKey = (model: ModelKey) => `${model.providerID}/${model.modelID}` + const getVariant = (model: ModelKey) => store.variant?.[variantKey(model)] + + const setVariant = (model: ModelKey, value: string | undefined) => { + const key = variantKey(model) + if (!store.variant) { + setStore("variant", { [key]: value }) + return + } + setStore("variant", key, value) + } + + return { + ready, + list, + find, + visible, + setVisibility, + recent: { + list: createMemo(() => store.recent), + push, + }, + variant: { + get: getVariant, + set: setVariant, + }, + } + }, +}) From 7f75f71f6b970c714b9e74f5715073e07ca8e431 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 26 Jan 2026 14:15:46 +0000 Subject: [PATCH 027/294] chore: generate --- packages/app/src/pages/session.tsx | 116 ++++++++++++++--------------- 1 file changed, 58 insertions(+), 58 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 47b9d76c53..bc89b2a48f 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -765,7 +765,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return if (status()?.type !== "idle") { - await sdk.client.session.abort({ sessionID }).catch(() => { }) + await sdk.client.session.abort({ sessionID }).catch(() => {}) } const revert = info()?.revert?.messageID // Find the last user message that's not already reverted @@ -848,69 +848,69 @@ export default function Page() { }, ...(sync.data.config.share !== "disabled" ? [ - { - id: "session.share", - title: "Share session", - description: "Share this session and copy the URL to clipboard", - category: "Session", - slash: "share", - disabled: !params.id || !!info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .share({ sessionID: params.id }) - .then((res) => { - navigator.clipboard.writeText(res.data!.share!.url).catch(() => + { + id: "session.share", + title: "Share session", + description: "Share this session and copy the URL to clipboard", + category: "Session", + slash: "share", + disabled: !params.id || !!info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .share({ sessionID: params.id }) + .then((res) => { + navigator.clipboard.writeText(res.data!.share!.url).catch(() => + showToast({ + title: "Failed to copy URL to clipboard", + variant: "error", + }), + ) + }) + .then(() => showToast({ - title: "Failed to copy URL to clipboard", + title: "Session shared", + description: "Share URL copied to clipboard!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to share session", + description: "An error occurred while sharing the session", variant: "error", }), ) - }) - .then(() => - showToast({ - title: "Session shared", - description: "Share URL copied to clipboard!", - variant: "success", - }), - ) - .catch(() => - showToast({ - title: "Failed to share session", - description: "An error occurred while sharing the session", - variant: "error", - }), - ) + }, }, - }, - { - id: "session.unshare", - title: "Unshare session", - description: "Stop sharing this session", - category: "Session", - slash: "unshare", - disabled: !params.id || !info()?.share?.url, - onSelect: async () => { - if (!params.id) return - await sdk.client.session - .unshare({ sessionID: params.id }) - .then(() => - showToast({ - title: "Session unshared", - description: "Session unshared successfully!", - variant: "success", - }), - ) - .catch(() => - showToast({ - title: "Failed to unshare session", - description: "An error occurred while unsharing the session", - variant: "error", - }), - ) + { + id: "session.unshare", + title: "Unshare session", + description: "Stop sharing this session", + category: "Session", + slash: "unshare", + disabled: !params.id || !info()?.share?.url, + onSelect: async () => { + if (!params.id) return + await sdk.client.session + .unshare({ sessionID: params.id }) + .then(() => + showToast({ + title: "Session unshared", + description: "Session unshared successfully!", + variant: "success", + }), + ) + .catch(() => + showToast({ + title: "Failed to unshare session", + description: "An error occurred while unsharing the session", + variant: "error", + }), + ) + }, }, - }, - ] + ] : []), ]) From af3d8c383e5fe2feba79ace5bb2e1082195a459d Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 06:28:25 -0600 Subject: [PATCH 028/294] wip(app): sidebar hover full --- packages/app/src/pages/layout.tsx | 776 ++++++++++++++++-------------- 1 file changed, 418 insertions(+), 358 deletions(-) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 2702f119ba..a14b4a8e50 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -135,6 +135,21 @@ export default function Layout(props: ParentProps) { const editorRef = { current: undefined as HTMLInputElement | undefined } const [hoverSession, setHoverSession] = createSignal() + const [hoverProject, setHoverProject] = createSignal() + + const sidebarHovering = createMemo(() => !layout.sidebar.opened() && hoverProject() !== undefined) + const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) + + const hoverProjectData = createMemo(() => { + const id = hoverProject() + if (!id) return + return layout.projects.list().find((project) => project.worktree === id) + }) + + createEffect(() => { + if (!layout.sidebar.opened()) return + setHoverProject(undefined) + }) const autoselecting = createMemo(() => { if (params.dir) return false @@ -1119,15 +1134,13 @@ export default function Layout(props: ParentProps) { return language.t("common.requestFailed") } - const deleteWorkspace = async (directory: string) => { - const current = currentProject() - if (!current) return - if (directory === current.worktree) return + const deleteWorkspace = async (root: string, directory: string) => { + if (directory === root) return setBusy(directory, true) const result = await globalSDK.client.worktree - .remove({ directory: current.worktree, worktreeRemoveInput: { directory } }) + .remove({ directory: root, worktreeRemoveInput: { directory } }) .then((x) => x.data) .catch((err) => { showToast({ @@ -1142,17 +1155,15 @@ export default function Layout(props: ParentProps) { if (!result) return layout.projects.close(directory) - layout.projects.open(current.worktree) + layout.projects.open(root) if (params.dir && base64Decode(params.dir) === directory) { - navigateToProject(current.worktree) + navigateToProject(root) } } - const resetWorkspace = async (directory: string) => { - const current = currentProject() - if (!current) return - if (directory === current.worktree) return + const resetWorkspace = async (root: string, directory: string) => { + if (directory === root) return setBusy(directory, true) const progress = showToast({ @@ -1168,7 +1179,7 @@ export default function Layout(props: ParentProps) { .catch(() => []) const result = await globalSDK.client.worktree - .reset({ directory: current.worktree, worktreeResetInput: { directory } }) + .reset({ directory: root, worktreeResetInput: { directory } }) .then((x) => x.data) .catch((err) => { showToast({ @@ -1251,7 +1262,7 @@ export default function Layout(props: ParentProps) { ) } - function DialogDeleteWorkspace(props: { directory: string }) { + function DialogDeleteWorkspace(props: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [data, setData] = createStore({ status: "loading" as "loading" | "ready" | "error", @@ -1259,12 +1270,6 @@ export default function Layout(props: ParentProps) { }) onMount(() => { - const current = currentProject() - if (!current) { - setData({ status: "error", dirty: false }) - return - } - globalSDK.client.file .status({ directory: props.directory }) .then((x) => { @@ -1279,7 +1284,7 @@ export default function Layout(props: ParentProps) { const handleDelete = () => { dialog.close() - void deleteWorkspace(props.directory) + void deleteWorkspace(props.root, props.directory) } const description = () => { @@ -1311,7 +1316,7 @@ export default function Layout(props: ParentProps) { ) } - function DialogResetWorkspace(props: { directory: string }) { + function DialogResetWorkspace(props: { root: string; directory: string }) { const name = createMemo(() => getFilename(props.directory)) const [state, setState] = createStore({ status: "loading" as "loading" | "ready" | "error", @@ -1329,12 +1334,6 @@ export default function Layout(props: ParentProps) { } onMount(() => { - const current = currentProject() - if (!current) { - setState({ status: "error", dirty: false }) - return - } - globalSDK.client.file .status({ directory: props.directory }) .then((x) => { @@ -1350,7 +1349,7 @@ export default function Layout(props: ParentProps) { const handleReset = () => { dialog.close() - void resetWorkspace(props.directory) + void resetWorkspace(props.root, props.directory) } const archivedCount = () => state.sessions.length @@ -1444,6 +1443,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return + setHoverProject(undefined) setStore("activeProject", id) } @@ -1483,6 +1483,13 @@ export default function Layout(props: ParentProps) { return [...merged, extra] } + const sidebarProject = createMemo(() => { + if (layout.sidebar.opened()) return currentProject() + const hovered = hoverProjectData() + if (hovered) return hovered + return currentProject() + }) + function handleWorkspaceDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return @@ -1493,7 +1500,7 @@ export default function Layout(props: ParentProps) { const { draggable, droppable } = event if (!draggable || !droppable) return - const project = currentProject() + const project = sidebarProject() if (!project) return const ids = workspaceIds(project) @@ -1593,7 +1600,7 @@ export default function Layout(props: ParentProps) { sessionStore.message[props.session.id]?.filter((message) => message.role === "user"), ) const hoverReady = createMemo(() => sessionStore.message[props.session.id] !== undefined) - const hoverAllowed = createMemo(() => !props.mobile && layout.sidebar.opened()) + const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) const [menuOpen, setMenuOpen] = createSignal(false) @@ -1611,7 +1618,11 @@ export default function Layout(props: ParentProps) { class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none transition-[padding] ${menuOpen() ? "pr-7" : ""} group-hover/session:pr-7 group-focus-within/session:pr-7 group-active/session:pr-7 ${props.dense ? "py-0.5" : "py-1"}`} onMouseEnter={() => prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} - onClick={() => setHoverSession(undefined)} + onClick={() => { + setHoverSession(undefined) + if (layout.sidebar.opened()) return + queueMicrotask(() => setHoverProject(undefined)) + }} >
{ const label = language.t("command.session.new") - const tooltip = () => props.mobile || !layout.sidebar.opened() + const tooltip = () => props.mobile || !sidebarExpanded() const item = ( setHoverSession(undefined)} + onClick={() => { + setHoverSession(undefined) + if (layout.sidebar.opened()) return + queueMicrotask(() => setHoverProject(undefined)) + }} >
@@ -1814,7 +1829,7 @@ export default function Layout(props: ParentProps) { const WorkspaceDragOverlay = (): JSX.Element => { const label = createMemo(() => { - const project = currentProject() + const project = sidebarProject() if (!project) return const directory = store.activeWorkspace if (!directory) return @@ -1985,13 +2000,21 @@ export default function Layout(props: ParentProps) { dialog.show(() => )} + onSelect={() => + dialog.show(() => ( + + )) + } > {language.t("common.reset")} dialog.show(() => )} + onSelect={() => + dialog.show(() => ( + + )) + } > {language.t("common.delete")} @@ -2005,9 +2028,7 @@ export default function Layout(props: ParentProps) {
+ +
-
- -
- openTab(file.tab(node.path))} /> -
+
+ + + + Changes + + + All files + + + + + + Loading...
} + > + d.file)} + onFileClick={(node) => focusReviewDiff(node.path)} + /> + + + +
No changes
+
+ + + + openTab(file.tab(node.path))} /> + +
- - - - -
- - - -
- - - -
-
{language.t("session.tab.review")}
- -
- {info()?.summary?.files ?? 0} -
-
-
-
-
-
- - - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
- - {(tab) => } - -
- - dialog.show(() => )} - aria-label={language.t("command.file.open")} - /> - -
-
-
- - - -
- - - - {language.t("session.review.loadingChanges")} -
- } - > - addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> -
- - -
- -
No changes in this session yet
-
-
- -
- - - - - - -
- -
-
-
-
- - {(tab) => { - let scroll: HTMLDivElement | undefined - let scrollFrame: number | undefined - let pending: { x: number; y: number } | undefined - let codeScroll: HTMLElement[] = [] - let focusToken = 0 - - const path = createMemo(() => file.pathFromTab(tab)) - const state = createMemo(() => { - const p = path() - if (!p) return - return file.get(p) - }) - const contents = createMemo(() => state()?.content?.content ?? "") - const cacheKey = createMemo(() => checksum(contents())) - const isImage = createMemo(() => { - const c = state()?.content - return ( - c?.encoding === "base64" && c?.mimeType?.startsWith("image/") && c?.mimeType !== "image/svg+xml" - ) - }) - const isSvg = createMemo(() => { - const c = state()?.content - return c?.mimeType === "image/svg+xml" - }) - const svgContent = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return base64Decode(c.content) - return c.content - }) - const svgPreviewUrl = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` - }) - const imageDataUrl = createMemo(() => { - if (!isImage()) return - const c = state()?.content - return `data:${c?.mimeType};base64,${c?.content}` - }) - const selectedLines = createMemo(() => { - const p = path() - if (!p) return null - if (file.ready()) return file.selectedLines(p) ?? null - return handoff.files[p] ?? null - }) - - let wrap: HTMLDivElement | undefined - - const fileComments = createMemo(() => { - const p = path() - if (!p) return [] - return comments.list(p) - }) - - const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) - - const [openedComment, setOpenedComment] = createSignal(null) - const [commenting, setCommenting] = createSignal(null) - const [draft, setDraft] = createSignal("") - const [positions, setPositions] = createSignal>({}) - const [draftTop, setDraftTop] = createSignal(undefined) - - const empty = {} as Record - - const commentLabel = (range: SelectedLineRange) => { - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - if (start === end) return `line ${start}` - return `lines ${start}-${end}` - } - - const getRoot = () => { - const el = wrap - if (!el) return - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const root = host.shadowRoot - if (!root) return - - return root - } - - const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const node = root.querySelector(`[data-line="${line}"]`) - if (!(node instanceof HTMLElement)) return - return node - } - - const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { - const wrapperRect = wrapper.getBoundingClientRect() - const rect = marker.getBoundingClientRect() - return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) - } - - const equal = (a: Record, b: Record) => { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (a[key] !== b[key]) return false - } - return true - } - - const updateComments = () => { - const el = wrap - const root = getRoot() - if (!el || !root) { - setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty)) - setDraftTop((prev) => (prev === undefined ? prev : undefined)) - return - } - - const next: Record = {} - for (const comment of fileComments()) { - const marker = findMarker(root, comment.selection) - if (!marker) continue - next[comment.id] = markerTop(el, marker) - } - - setPositions((prev) => (equal(prev, next) ? prev : next)) - - const range = commenting() - if (!range) { - setDraftTop(undefined) - return - } - - const marker = findMarker(root, range) - if (!marker) { - setDraftTop(undefined) - return - } - - const nextTop = markerTop(el, marker) - setDraftTop((prev) => (prev === nextTop ? prev : nextTop)) - } - - let commentFrame: number | undefined - - const scheduleComments = () => { - if (commentFrame !== undefined) return - commentFrame = requestAnimationFrame(() => { - commentFrame = undefined - updateComments() - }) - } - - createEffect(() => { - fileComments() - scheduleComments() - }) - - createEffect(() => { - commenting() - scheduleComments() - }) - - createEffect(() => { - const range = commenting() - if (!range) return - setDraft("") - }) - - createEffect(() => { - const focus = comments.focus() - const p = path() - if (!focus || !p) return - if (focus.file !== p) return - if (activeTab() !== tab) return - - const target = fileComments().find((comment) => comment.id === focus.id) - if (!target) return - - focusToken++ - const token = focusToken - - setOpenedComment(target.id) - setCommenting(null) - file.setSelectedLines(p, target.selection) - - const scrollTo = (attempt: number) => { - if (token !== focusToken) return - - const root = scroll - if (!root) { - if (attempt >= 120) return - requestAnimationFrame(() => scrollTo(attempt + 1)) - return - } - - const anchor = root.querySelector(`[data-comment-id="${target.id}"]`) - const ready = - anchor instanceof HTMLElement && - anchor.style.pointerEvents !== "none" && - anchor.style.opacity !== "0" - - const shadow = getRoot() - const marker = shadow ? findMarker(shadow, target.selection) : undefined - const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined - if (!node) { - if (attempt >= 120) return - requestAnimationFrame(() => scrollTo(attempt + 1)) - return - } - - const rootRect = root.getBoundingClientRect() - const targetRect = node.getBoundingClientRect() - const offset = targetRect.top - rootRect.top - const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 - root.scrollTop = Math.max(0, next) - - if (ready || marker) return - if (attempt >= 120) return - requestAnimationFrame(() => scrollTo(attempt + 1)) - } - - requestAnimationFrame(() => scrollTo(0)) - requestAnimationFrame(() => comments.clearFocus()) - }) - - const renderCode = (source: string, wrapperClass: string) => ( -
{ - wrap = el - scheduleComments() - }} - class={`relative overflow-hidden ${wrapperClass}`} - > - { - requestAnimationFrame(restoreScroll) - requestAnimationFrame(scheduleComments) - }} - onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - if (!range) setCommenting(null) - }} - onLineSelectionEnd={(range: SelectedLineRange | null) => { - if (!range) { - setCommenting(null) - return - } - - setOpenedComment(null) - setCommenting(range) - }} - overflow="scroll" - class="select-text" - /> - - {(comment) => ( - { - const p = path() - if (!p) return - file.setSelectedLines(p, comment.selection) - }} - onClick={() => { - const p = path() - if (!p) return - setCommenting(null) - setOpenedComment((current) => (current === comment.id ? null : comment.id)) - file.setSelectedLines(p, comment.selection) - }} - comment={comment.comment} - selection={commentLabel(comment.selection)} - /> - )} - - - {(range) => ( - - setCommenting(null)} - onSubmit={(comment) => { - const p = path() - if (!p) return - addCommentToContext({ - file: p, - selection: range(), - comment, - origin: "file", - }) - setCommenting(null) - }} - onPopoverFocusOut={(e) => { - const target = e.relatedTarget as Node | null - if (target && e.currentTarget.contains(target)) return - // Delay to allow click handlers to fire first - setTimeout(() => { - if (!document.activeElement || !e.currentTarget.contains(document.activeElement)) { - setCommenting(null) - } - }, 0) - }} - /> - - )} - -
- ) - - const getCodeScroll = () => { - const el = scroll - if (!el) return [] - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return [] - - const root = host.shadowRoot - if (!root) return [] - - return Array.from(root.querySelectorAll("[data-code]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, - ) - } - - const queueScrollUpdate = (next: { x: number; y: number }) => { - pending = next - if (scrollFrame !== undefined) return - - scrollFrame = requestAnimationFrame(() => { - scrollFrame = undefined - - const next = pending - pending = undefined - if (!next) return - - view().setScroll(tab, next) - }) - } - - const handleCodeScroll = (event: Event) => { - const el = scroll - if (!el) return - - const target = event.currentTarget - if (!(target instanceof HTMLElement)) return - - queueScrollUpdate({ - x: target.scrollLeft, - y: el.scrollTop, - }) - } - - const syncCodeScroll = () => { - const next = getCodeScroll() - if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return - - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - codeScroll = next - - for (const item of codeScroll) { - item.addEventListener("scroll", handleCodeScroll) - } - } - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = view()?.scroll(tab) - if (!s) return - - syncCodeScroll() - - if (codeScroll.length > 0) { - for (const item of codeScroll) { - if (item.scrollLeft !== s.x) item.scrollLeft = s.x - } - } - - if (el.scrollTop !== s.y) el.scrollTop = s.y - - if (codeScroll.length > 0) return - - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - if (codeScroll.length === 0) syncCodeScroll() - - queueScrollUpdate({ - x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - }) - } - - createEffect( - on( - () => state()?.loaded, - (loaded) => { - if (!loaded) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => file.ready(), - (ready) => { - if (!ready) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => tabs().active() === tab, - (active) => { - if (!active) return - if (!state()?.loaded) return - requestAnimationFrame(restoreScroll) - }, - ), - ) - - onCleanup(() => { - if (commentFrame !== undefined) cancelAnimationFrame(commentFrame) - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - if (scrollFrame === undefined) return - cancelAnimationFrame(scrollFrame) - }) - - return ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- {renderCode(contents(), "pb-40")} - -
{language.t("common.loading")}...
-
- - {(err) =>
{err()}
} -
-
-
- ) - }} -
- - - - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab())) - return ( -
- {(p) => } -
- ) - }} -
-
-
diff --git a/packages/ui/src/components/resize-handle.css b/packages/ui/src/components/resize-handle.css index 6aac4c2fd0..c309ff838b 100644 --- a/packages/ui/src/components/resize-handle.css +++ b/packages/ui/src/components/resize-handle.css @@ -21,6 +21,12 @@ transform: translateX(50%); cursor: col-resize; + &[data-edge="start"] { + inset-inline-start: 0; + inset-inline-end: auto; + transform: translateX(-50%); + } + &::after { width: 3px; inset-block: 0; @@ -36,6 +42,12 @@ transform: translateY(-50%); cursor: row-resize; + &[data-edge="end"] { + inset-block-start: auto; + inset-block-end: 0; + transform: translateY(50%); + } + &::after { height: 3px; inset-inline: 0; diff --git a/packages/ui/src/components/resize-handle.tsx b/packages/ui/src/components/resize-handle.tsx index 3ad01e27f9..e2eed1bb7c 100644 --- a/packages/ui/src/components/resize-handle.tsx +++ b/packages/ui/src/components/resize-handle.tsx @@ -2,6 +2,7 @@ import { splitProps, type JSX } from "solid-js" export interface ResizeHandleProps extends Omit, "onResize"> { direction: "horizontal" | "vertical" + edge?: "start" | "end" size: number min: number max: number @@ -13,6 +14,7 @@ export interface ResizeHandleProps extends Omit { e.preventDefault() + const edge = local.edge ?? (local.direction === "vertical" ? "start" : "end") const start = local.direction === "horizontal" ? e.clientX : e.clientY const startSize = local.size let current = startSize @@ -34,7 +37,14 @@ export function ResizeHandle(props: ResizeHandleProps) { const onMouseMove = (moveEvent: MouseEvent) => { const pos = local.direction === "horizontal" ? moveEvent.clientX : moveEvent.clientY - const delta = local.direction === "vertical" ? start - pos : pos - start + const delta = + local.direction === "vertical" + ? edge === "end" + ? pos - start + : start - pos + : edge === "start" + ? start - pos + : pos - start current = startSize + delta const clamped = Math.min(local.max, Math.max(local.min, current)) local.onResize(clamped) @@ -61,6 +71,7 @@ export function ResizeHandle(props: ResizeHandleProps) { {...rest} data-component="resize-handle" data-direction={local.direction} + data-edge={local.edge ?? (local.direction === "vertical" ? "start" : "end")} classList={{ ...(local.classList ?? {}), [local.class ?? ""]: !!local.class, diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 60ac0d5160..9a337c4538 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -9,6 +9,7 @@ import { StickyAccordionHeader } from "./sticky-accordion-header" import { useDiffComponent } from "../context/diff" import { useI18n } from "../context/i18n" import { getDirectory, getFilename } from "@opencode-ai/util/path" +import { checksum } from "@opencode-ai/util/encode" import { createEffect, createMemo, createSignal, For, Match, Show, Switch, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type FileDiff } from "@opencode-ai/sdk/v2" @@ -118,6 +119,12 @@ function dataUrlFromValue(value: unknown): string | undefined { return `data:${mime};base64,${content}` } +function diffId(file: string): string | undefined { + const sum = checksum(file) + if (!sum) return + return `session-review-diff-${sum}` +} + type SessionReviewSelection = { file: string range: SelectedLineRange @@ -489,7 +496,12 @@ export const SessionReview = (props: SessionReviewProps) => { } return ( - +
From 801eb5d2cb868d0a14c056439e3898b110f4cc21 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:39:25 -0600 Subject: [PATCH 047/294] wip(app): file tree mode --- packages/app/src/components/file-tree.tsx | 85 ++++++++++++++++------- packages/app/src/pages/session.tsx | 65 +++++++++++++---- packages/ui/src/components/tabs.css | 53 ++++++++++++++ packages/ui/src/components/tabs.tsx | 2 +- 4 files changed, 165 insertions(+), 40 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index ac435095d4..a48f0039f0 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -9,6 +9,7 @@ import { Match, splitProps, Switch, + untrack, type ComponentProps, type ParentProps, } from "solid-js" @@ -21,10 +22,14 @@ export default function FileTree(props: { nodeClass?: string level?: number allowed?: readonly string[] + draggable?: boolean + tooltip?: boolean onFileClick?: (file: FileNode) => void }) { const file = useFile() const level = props.level ?? 0 + const draggable = () => props.draggable ?? true + const tooltip = () => props.tooltip ?? true const filter = createMemo(() => { const allowed = props.allowed @@ -45,6 +50,18 @@ export default function FileTree(props: { return { files, dirs } }) + createEffect(() => { + const current = filter() + if (!current) return + if (level !== 0) return + + for (const dir of current.dirs) { + const expanded = untrack(() => file.tree.state(dir)?.expanded) ?? false + if (expanded) continue + file.tree.expand(dir) + } + }) + createEffect(() => { void file.tree.list(props.path) }) @@ -78,8 +95,9 @@ export default function FileTree(props: { [props.nodeClass ?? ""]: !!props.nodeClass, }} style={`padding-left: ${8 + level * 12}px`} - draggable={true} + draggable={draggable()} onDragStart={(e: DragEvent) => { + if (!draggable()) return e.dataTransfer?.setData("text/plain", `file:${local.node.path}`) e.dataTransfer?.setData("text/uri-list", `file://${local.node.path}`) if (e.dataTransfer) e.dataTransfer.effectAllowed = "copy" @@ -123,41 +141,54 @@ export default function FileTree(props: { {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false + const Wrapper = (p: ParentProps) => { + if (!tooltip()) return p.children + return ( + + {p.children} + + ) + } + return ( - - - - (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} - > - + + + (open ? file.tree.expand(node.path) : file.tree.collapse(node.path))} + > + + - - - - - - - + + + + + + + + + props.onFileClick?.(node)}>
- - - + + + ) }} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5160c52887..5ce03f4034 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1037,7 +1037,7 @@ export default function Page() { return `session-review-diff-${sum}` } - const scrollToReviewDiff = (path: string, behavior: ScrollBehavior) => { + const reviewDiffTop = (path: string) => { const root = reviewScroll() if (!root) return @@ -1050,15 +1050,25 @@ export default function Page() { const a = el.getBoundingClientRect() const b = root.getBoundingClientRect() - const top = a.top - b.top + root.scrollTop - root.scrollTo({ top, behavior }) + return a.top - b.top + root.scrollTop + } + + const scrollToReviewDiff = (path: string) => { + const root = reviewScroll() + if (!root) return false + + const top = reviewDiffTop(path) + if (top === undefined) return false + + view().setScroll("review", { x: root.scrollLeft, y: top }) + root.scrollTo({ top, behavior: "auto" }) + return true } const focusReviewDiff = (path: string) => { const current = view().review.open() ?? [] if (!current.includes(path)) view().review.setOpen([...current, path]) setPendingDiff(path) - requestAnimationFrame(() => scrollToReviewDiff(path, "smooth")) } createEffect(() => { @@ -1067,10 +1077,39 @@ export default function Page() { if (!reviewScroll()) return if (!diffsReady()) return - requestAnimationFrame(() => { - scrollToReviewDiff(pending, "smooth") - setPendingDiff(undefined) - }) + const attempt = (count: number) => { + if (pendingDiff() !== pending) return + if (count > 60) { + setPendingDiff(undefined) + return + } + + const root = reviewScroll() + if (!root) { + requestAnimationFrame(() => attempt(count + 1)) + return + } + + if (!scrollToReviewDiff(pending)) { + requestAnimationFrame(() => attempt(count + 1)) + return + } + + const top = reviewDiffTop(pending) + if (top === undefined) { + requestAnimationFrame(() => attempt(count + 1)) + return + } + + if (Math.abs(root.scrollTop - top) <= 1) { + setPendingDiff(undefined) + return + } + + requestAnimationFrame(() => attempt(count + 1)) + } + + requestAnimationFrame(() => attempt(0)) }) const activeTab = createMemo(() => { @@ -2605,12 +2644,12 @@ export default function Page() {
- - - + + + Changes - + All files @@ -2624,6 +2663,8 @@ export default function Page() { d.file)} + draggable={false} + tooltip={false} onFileClick={(node) => focusReviewDiff(node.path)} /> diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index cebbd3b4fa..2f3c914e14 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -212,6 +212,59 @@ /* } */ } + &[data-variant="pill"][data-orientation="horizontal"] { + background-color: transparent; + + [data-slot="tabs-list"] { + height: auto; + padding: 6px; + gap: 4px; + border-bottom: 1px solid var(--border-weak-base); + background-color: var(--background-base); + + &::after { + display: none; + } + } + + [data-slot="tabs-trigger-wrapper"] { + height: 32px; + border: none; + border-radius: 999px; + background-color: transparent; + gap: 0; + + /* text-13-medium */ + font-family: var(--font-family-sans); + font-size: var(--font-size-small); + font-style: normal; + font-weight: var(--font-weight-medium); + line-height: var(--line-height-large); + letter-spacing: var(--letter-spacing-normal); + + [data-slot="tabs-trigger"] { + height: 100%; + width: 100%; + padding: 0 12px; + background-color: transparent; + } + + &:hover:not(:disabled) { + background-color: var(--surface-raised-base-hover); + color: var(--text-strong); + } + + &:has([data-selected]) { + background-color: var(--surface-raised-base-active); + color: var(--text-strong); + + &:hover:not(:disabled) { + background-color: var(--surface-raised-base-active); + } + } + } + } + &[data-orientation="vertical"] { flex-direction: row; diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index 825bfa8594..ddd22ec51e 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -3,7 +3,7 @@ import { Show, splitProps, type JSX } from "solid-js" import type { ComponentProps, ParentProps, Component } from "solid-js" export interface TabsProps extends ComponentProps { - variant?: "normal" | "alt" | "settings" + variant?: "normal" | "alt" | "pill" | "settings" orientation?: "horizontal" | "vertical" } export interface TabsListProps extends ComponentProps {} From b8e8d82323f87080de377b4ac227356bac3e9726 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 09:55:37 -0600 Subject: [PATCH 048/294] chore: cleanup --- packages/app/src/components/file-tree.tsx | 15 +- packages/app/src/pages/session.tsx | 1278 ++++++++++----------- packages/ui/src/components/tabs.css | 3 +- 3 files changed, 622 insertions(+), 674 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index a48f0039f0..c27ccbe6dd 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -22,6 +22,7 @@ export default function FileTree(props: { nodeClass?: string level?: number allowed?: readonly string[] + modified?: readonly string[] draggable?: boolean tooltip?: boolean onFileClick?: (file: FileNode) => void @@ -50,6 +51,12 @@ export default function FileTree(props: { return { files, dirs } }) + const marks = createMemo(() => { + const modified = props.modified + if (!modified || modified.length === 0) return + return new Set(modified) + }) + createEffect(() => { const current = filter() if (!current) return @@ -89,7 +96,7 @@ export default function FileTree(props: { {local.node.name} + {local.node.type === "file" && marks()?.has(local.node.path) ? ( +
+ ) : null} ) } @@ -173,6 +183,7 @@ export default function FileTree(props: { path={node.path} level={level + 1} allowed={props.allowed} + modified={props.modified} draggable={props.draggable} tooltip={props.tooltip} onFileClick={props.onFileClick} diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 5ce03f4034..458decfc41 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -425,6 +425,8 @@ export default function Page() { } const diffs = createMemo(() => (params.id ? (sync.data.session_diff[params.id] ?? []) : [])) + const emptyDiffFiles: string[] = [] + const diffFiles = createMemo(() => diffs().map((d) => d.file), emptyDiffFiles, { equals: same }) const diffsReady = createMemo(() => { const id = params.id if (!id) return true @@ -1934,7 +1936,609 @@ export default function Page() { class="relative flex-1 min-w-0 h-full border-l border-border-weak-base flex" >
- + + + + +
+ + + +
+ + + +
+
{language.t("session.tab.review")}
+ +
+ {info()?.summary?.files ?? 0} +
+
+
+
+
+
+ + + tabs().close("context")} + aria-label={language.t("common.closeTab")} + /> + + } + hideCloseButton + onMiddleClick={() => tabs().close("context")} + > +
+ +
{language.t("session.tab.context")}
+
+
+
+ + + {(tab) => } + + +
+ + dialog.show(() => )} + aria-label={language.t("command.file.open")} + /> + +
+
+
+ + + +
+ + + + {language.t("session.review.loadingChanges")} +
+ } + > + addCommentToContext({ ...comment, origin: "review" })} + comments={comments.all()} + focusedComment={comments.focus()} + onFocusedCommentChange={comments.setFocus} + onViewFile={(path) => { + const value = file.tab(path) + tabs().open(value) + file.load(path) + }} + /> +
+ + +
+ +
+ No changes in this session yet +
+
+
+ +
+ + + + + + +
+ +
Select a file to open
+
+
+
+ + + + +
+ +
+
+
+
+ + + {(tab) => { + let scroll: HTMLDivElement | undefined + let scrollFrame: number | undefined + let pending: { x: number; y: number } | undefined + let codeScroll: HTMLElement[] = [] + + const path = createMemo(() => file.pathFromTab(tab)) + const state = createMemo(() => { + const p = path() + if (!p) return + return file.get(p) + }) + const contents = createMemo(() => state()?.content?.content ?? "") + const cacheKey = createMemo(() => checksum(contents())) + const isImage = createMemo(() => { + const c = state()?.content + return ( + c?.encoding === "base64" && + c?.mimeType?.startsWith("image/") && + c?.mimeType !== "image/svg+xml" + ) + }) + const isSvg = createMemo(() => { + const c = state()?.content + return c?.mimeType === "image/svg+xml" + }) + const svgContent = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return base64Decode(c.content) + return c.content + }) + const svgPreviewUrl = createMemo(() => { + if (!isSvg()) return + const c = state()?.content + if (!c) return + if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` + return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` + }) + const imageDataUrl = createMemo(() => { + if (!isImage()) return + const c = state()?.content + return `data:${c?.mimeType};base64,${c?.content}` + }) + const selectedLines = createMemo(() => { + const p = path() + if (!p) return null + if (file.ready()) return file.selectedLines(p) ?? null + return handoff.files[p] ?? null + }) + + let wrap: HTMLDivElement | undefined + + const fileComments = createMemo(() => { + const p = path() + if (!p) return [] + return comments.list(p) + }) + + const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) + + const [openedComment, setOpenedComment] = createSignal(null) + const [commenting, setCommenting] = createSignal(null) + const [draft, setDraft] = createSignal("") + const [positions, setPositions] = createSignal>({}) + const [draftTop, setDraftTop] = createSignal(undefined) + + const commentLabel = (range: SelectedLineRange) => { + const start = Math.min(range.start, range.end) + const end = Math.max(range.start, range.end) + if (start === end) return `line ${start}` + return `lines ${start}-${end}` + } + + const getRoot = () => { + const el = wrap + if (!el) return + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return + + const root = host.shadowRoot + if (!root) return + + return root + } + + const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { + const line = Math.max(range.start, range.end) + const node = root.querySelector(`[data-line="${line}"]`) + if (!(node instanceof HTMLElement)) return + return node + } + + const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { + const wrapperRect = wrapper.getBoundingClientRect() + const rect = marker.getBoundingClientRect() + return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) + } + + const updateComments = () => { + const el = wrap + const root = getRoot() + if (!el || !root) { + setPositions({}) + setDraftTop(undefined) + return + } + + const next: Record = {} + for (const comment of fileComments()) { + const marker = findMarker(root, comment.selection) + if (!marker) continue + next[comment.id] = markerTop(el, marker) + } + + setPositions(next) + + const range = commenting() + if (!range) { + setDraftTop(undefined) + return + } + + const marker = findMarker(root, range) + if (!marker) { + setDraftTop(undefined) + return + } + + setDraftTop(markerTop(el, marker)) + } + + const scheduleComments = () => { + requestAnimationFrame(updateComments) + } + + createEffect(() => { + fileComments() + scheduleComments() + }) + + createEffect(() => { + const range = commenting() + scheduleComments() + if (!range) return + setDraft("") + }) + + createEffect(() => { + const focus = comments.focus() + const p = path() + if (!focus || !p) return + if (focus.file !== p) return + if (activeTab() !== tab) return + + const target = fileComments().find((comment) => comment.id === focus.id) + if (!target) return + + setOpenedComment(target.id) + setCommenting(null) + file.setSelectedLines(p, target.selection) + requestAnimationFrame(() => comments.clearFocus()) + }) + + const renderCode = (source: string, wrapperClass: string) => ( +
{ + wrap = el + scheduleComments() + }} + class={`relative overflow-hidden ${wrapperClass}`} + > + { + requestAnimationFrame(restoreScroll) + requestAnimationFrame(scheduleComments) + }} + onLineSelected={(range: SelectedLineRange | null) => { + const p = path() + if (!p) return + file.setSelectedLines(p, range) + if (!range) setCommenting(null) + }} + onLineSelectionEnd={(range: SelectedLineRange | null) => { + if (!range) { + setCommenting(null) + return + } + + setOpenedComment(null) + setCommenting(range) + }} + overflow="scroll" + class="select-text" + /> + + {(comment) => ( + { + const p = path() + if (!p) return + file.setSelectedLines(p, comment.selection) + }} + onClick={() => { + const p = path() + if (!p) return + setCommenting(null) + setOpenedComment((current) => (current === comment.id ? null : comment.id)) + file.setSelectedLines(p, comment.selection) + }} + /> + )} + + + {(range) => ( + + setDraft(value)} + onCancel={() => setCommenting(null)} + onSubmit={(value) => { + const p = path() + if (!p) return + addCommentToContext({ + file: p, + selection: range(), + comment: value, + origin: "file", + }) + setCommenting(null) + }} + onPopoverFocusOut={(e: FocusEvent) => { + const current = e.currentTarget as HTMLDivElement + const target = e.relatedTarget + if (target instanceof Node && current.contains(target)) return + + setTimeout(() => { + if (!document.activeElement || !current.contains(document.activeElement)) { + setCommenting(null) + } + }, 0) + }} + /> + + )} + +
+ ) + + const getCodeScroll = () => { + const el = scroll + if (!el) return [] + + const host = el.querySelector("diffs-container") + if (!(host instanceof HTMLElement)) return [] + + const root = host.shadowRoot + if (!root) return [] + + return Array.from(root.querySelectorAll("[data-code]")).filter( + (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, + ) + } + + const queueScrollUpdate = (next: { x: number; y: number }) => { + pending = next + if (scrollFrame !== undefined) return + + scrollFrame = requestAnimationFrame(() => { + scrollFrame = undefined + + const next = pending + pending = undefined + if (!next) return + + view().setScroll(tab, next) + }) + } + + const handleCodeScroll = (event: Event) => { + const el = scroll + if (!el) return + + const target = event.currentTarget + if (!(target instanceof HTMLElement)) return + + queueScrollUpdate({ + x: target.scrollLeft, + y: el.scrollTop, + }) + } + + const syncCodeScroll = () => { + const next = getCodeScroll() + if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return + + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + codeScroll = next + + for (const item of codeScroll) { + item.addEventListener("scroll", handleCodeScroll) + } + } + + const restoreScroll = () => { + const el = scroll + if (!el) return + + const s = view()?.scroll(tab) + if (!s) return + + syncCodeScroll() + + if (codeScroll.length > 0) { + for (const item of codeScroll) { + if (item.scrollLeft !== s.x) item.scrollLeft = s.x + } + } + + if (el.scrollTop !== s.y) el.scrollTop = s.y + + if (codeScroll.length > 0) return + + if (el.scrollLeft !== s.x) el.scrollLeft = s.x + } + + const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { + if (codeScroll.length === 0) syncCodeScroll() + + queueScrollUpdate({ + x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, + y: event.currentTarget.scrollTop, + }) + } + + createEffect( + on( + () => state()?.loaded, + (loaded) => { + if (!loaded) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => file.ready(), + (ready) => { + if (!ready) return + requestAnimationFrame(restoreScroll) + }, + { defer: true }, + ), + ) + + createEffect( + on( + () => tabs().active() === tab, + (active) => { + if (!active) return + if (!state()?.loaded) return + requestAnimationFrame(restoreScroll) + }, + ), + ) + + onCleanup(() => { + for (const item of codeScroll) { + item.removeEventListener("scroll", handleCodeScroll) + } + + if (scrollFrame === undefined) return + cancelAnimationFrame(scrollFrame) + }) + + return ( + { + scroll = el + restoreScroll() + }} + onScroll={handleScroll} + > + + +
+ {path()} requestAnimationFrame(restoreScroll)} + /> +
+
+ +
+ {renderCode(svgContent() ?? "", "")} + +
+ {path()} +
+
+
+
+ {renderCode(contents(), "pb-40")} + +
{language.t("common.loading")}...
+
+ + {(err) =>
{err()}
} +
+
+
+ ) + }} +
+ + + + {(tab) => { + const path = createMemo(() => file.pathFromTab(tab())) + return ( +
+ {(p) => } +
+ ) + }} +
+
+ + } + >
@@ -1973,672 +2577,6 @@ export default function Page() {
- - - - - - -
- - - -
- - - -
-
{language.t("session.tab.review")}
- -
- {info()?.summary?.files ?? 0} -
-
-
-
-
-
- - - tabs().close("context")} - aria-label={language.t("common.closeTab")} - /> - - } - hideCloseButton - onMiddleClick={() => tabs().close("context")} - > -
- -
{language.t("session.tab.context")}
-
-
-
- - {(tab) => } - -
- - dialog.show(() => )} - aria-label={language.t("command.file.open")} - /> - -
-
-
- - - -
- - - - {language.t("session.review.loadingChanges")} -
- } - > - addCommentToContext({ ...comment, origin: "review" })} - comments={comments.all()} - focusedComment={comments.focus()} - onFocusedCommentChange={comments.setFocus} - onViewFile={(path) => { - const value = file.tab(path) - tabs().open(value) - file.load(path) - }} - /> -
- - -
- -
- No changes in this session yet -
-
-
- -
- - - - - - -
- -
Select a file to open
-
-
-
- - - - -
- -
-
-
-
- - {(tab) => { - let scroll: HTMLDivElement | undefined - let scrollFrame: number | undefined - let pending: { x: number; y: number } | undefined - let codeScroll: HTMLElement[] = [] - let focusToken = 0 - - const path = createMemo(() => file.pathFromTab(tab)) - const state = createMemo(() => { - const p = path() - if (!p) return - return file.get(p) - }) - const contents = createMemo(() => state()?.content?.content ?? "") - const cacheKey = createMemo(() => checksum(contents())) - const isImage = createMemo(() => { - const c = state()?.content - return ( - c?.encoding === "base64" && - c?.mimeType?.startsWith("image/") && - c?.mimeType !== "image/svg+xml" - ) - }) - const isSvg = createMemo(() => { - const c = state()?.content - return c?.mimeType === "image/svg+xml" - }) - const svgContent = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return base64Decode(c.content) - return c.content - }) - const svgPreviewUrl = createMemo(() => { - if (!isSvg()) return - const c = state()?.content - if (!c) return - if (c.encoding === "base64") return `data:image/svg+xml;base64,${c.content}` - return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(c.content)}` - }) - const imageDataUrl = createMemo(() => { - if (!isImage()) return - const c = state()?.content - return `data:${c?.mimeType};base64,${c?.content}` - }) - const selectedLines = createMemo(() => { - const p = path() - if (!p) return null - if (file.ready()) return file.selectedLines(p) ?? null - return handoff.files[p] ?? null - }) - - let wrap: HTMLDivElement | undefined - - const fileComments = createMemo(() => { - const p = path() - if (!p) return [] - return comments.list(p) - }) - - const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) - - const [openedComment, setOpenedComment] = createSignal(null) - const [commenting, setCommenting] = createSignal(null) - const [draft, setDraft] = createSignal("") - const [positions, setPositions] = createSignal>({}) - const [draftTop, setDraftTop] = createSignal(undefined) - - const empty = {} as Record - - const commentLabel = (range: SelectedLineRange) => { - const start = Math.min(range.start, range.end) - const end = Math.max(range.start, range.end) - if (start === end) return `line ${start}` - return `lines ${start}-${end}` - } - - const getRoot = () => { - const el = wrap - if (!el) return - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return - - const root = host.shadowRoot - if (!root) return - - return root - } - - const findMarker = (root: ShadowRoot, range: SelectedLineRange) => { - const line = Math.max(range.start, range.end) - const node = root.querySelector(`[data-line="${line}"]`) - if (!(node instanceof HTMLElement)) return - return node - } - - const markerTop = (wrapper: HTMLElement, marker: HTMLElement) => { - const wrapperRect = wrapper.getBoundingClientRect() - const rect = marker.getBoundingClientRect() - return rect.top - wrapperRect.top + Math.max(0, (rect.height - 20) / 2) - } - - const equal = (a: Record, b: Record) => { - const aKeys = Object.keys(a) - const bKeys = Object.keys(b) - if (aKeys.length !== bKeys.length) return false - for (const key of aKeys) { - if (a[key] !== b[key]) return false - } - return true - } - - const updateComments = () => { - const el = wrap - const root = getRoot() - if (!el || !root) { - setPositions((prev) => (Object.keys(prev).length === 0 ? prev : empty)) - setDraftTop((prev) => (prev === undefined ? prev : undefined)) - return - } - - const next: Record = {} - for (const comment of fileComments()) { - const marker = findMarker(root, comment.selection) - if (!marker) continue - next[comment.id] = markerTop(el, marker) - } - - setPositions((prev) => (equal(prev, next) ? prev : next)) - - const range = commenting() - if (!range) { - setDraftTop(undefined) - return - } - - const marker = findMarker(root, range) - if (!marker) { - setDraftTop(undefined) - return - } - - const nextTop = markerTop(el, marker) - setDraftTop((prev) => (prev === nextTop ? prev : nextTop)) - } - - let commentFrame: number | undefined - - const scheduleComments = () => { - if (commentFrame !== undefined) return - commentFrame = requestAnimationFrame(() => { - commentFrame = undefined - updateComments() - }) - } - - createEffect(() => { - fileComments() - scheduleComments() - }) - - createEffect(() => { - commenting() - scheduleComments() - }) - - createEffect(() => { - const range = commenting() - if (!range) return - setDraft("") - }) - - createEffect(() => { - const focus = comments.focus() - const p = path() - if (!focus || !p) return - if (focus.file !== p) return - if (activeTab() !== tab) return - - const target = fileComments().find((comment) => comment.id === focus.id) - if (!target) return - - focusToken++ - const token = focusToken - - setOpenedComment(target.id) - setCommenting(null) - file.setSelectedLines(p, target.selection) - - const scrollTo = (attempt: number) => { - if (token !== focusToken) return - - const root = scroll - if (!root) { - if (attempt >= 120) return - requestAnimationFrame(() => scrollTo(attempt + 1)) - return - } - - const anchor = root.querySelector(`[data-comment-id="${target.id}"]`) - const ready = - anchor instanceof HTMLElement && - anchor.style.pointerEvents !== "none" && - anchor.style.opacity !== "0" - - const shadow = getRoot() - const marker = shadow ? findMarker(shadow, target.selection) : undefined - const node = (ready ? anchor : (marker ?? wrap)) as HTMLElement | undefined - if (!node) { - if (attempt >= 120) return - requestAnimationFrame(() => scrollTo(attempt + 1)) - return - } - - const rootRect = root.getBoundingClientRect() - const targetRect = node.getBoundingClientRect() - const offset = targetRect.top - rootRect.top - const next = root.scrollTop + offset - rootRect.height / 2 + targetRect.height / 2 - root.scrollTop = Math.max(0, next) - - if (ready || marker) return - if (attempt >= 120) return - requestAnimationFrame(() => scrollTo(attempt + 1)) - } - - requestAnimationFrame(() => scrollTo(0)) - requestAnimationFrame(() => comments.clearFocus()) - }) - - const renderCode = (source: string, wrapperClass: string) => ( -
{ - wrap = el - scheduleComments() - }} - class={`relative overflow-hidden ${wrapperClass}`} - > - { - requestAnimationFrame(restoreScroll) - requestAnimationFrame(scheduleComments) - }} - onLineSelected={(range: SelectedLineRange | null) => { - const p = path() - if (!p) return - file.setSelectedLines(p, range) - if (!range) setCommenting(null) - }} - onLineSelectionEnd={(range: SelectedLineRange | null) => { - if (!range) { - setCommenting(null) - return - } - - setOpenedComment(null) - setCommenting(range) - }} - overflow="scroll" - class="select-text" - /> - - {(comment) => ( - { - const p = path() - if (!p) return - file.setSelectedLines(p, comment.selection) - }} - onClick={() => { - const p = path() - if (!p) return - setCommenting(null) - setOpenedComment((current) => (current === comment.id ? null : comment.id)) - file.setSelectedLines(p, comment.selection) - }} - comment={comment.comment} - selection={commentLabel(comment.selection)} - /> - )} - - - {(range) => ( - - setCommenting(null)} - onSubmit={(comment) => { - const p = path() - if (!p) return - addCommentToContext({ - file: p, - selection: range(), - comment, - origin: "file", - }) - setCommenting(null) - }} - onPopoverFocusOut={(e) => { - const target = e.relatedTarget as Node | null - if (target && e.currentTarget.contains(target)) return - // Delay to allow click handlers to fire first - setTimeout(() => { - if ( - !document.activeElement || - !e.currentTarget.contains(document.activeElement) - ) { - setCommenting(null) - } - }, 0) - }} - /> - - )} - -
- ) - - const getCodeScroll = () => { - const el = scroll - if (!el) return [] - - const host = el.querySelector("diffs-container") - if (!(host instanceof HTMLElement)) return [] - - const root = host.shadowRoot - if (!root) return [] - - return Array.from(root.querySelectorAll("[data-code]")).filter( - (node): node is HTMLElement => node instanceof HTMLElement && node.clientWidth > 0, - ) - } - - const queueScrollUpdate = (next: { x: number; y: number }) => { - pending = next - if (scrollFrame !== undefined) return - - scrollFrame = requestAnimationFrame(() => { - scrollFrame = undefined - - const next = pending - pending = undefined - if (!next) return - - view().setScroll(tab, next) - }) - } - - const handleCodeScroll = (event: Event) => { - const el = scroll - if (!el) return - - const target = event.currentTarget - if (!(target instanceof HTMLElement)) return - - queueScrollUpdate({ - x: target.scrollLeft, - y: el.scrollTop, - }) - } - - const syncCodeScroll = () => { - const next = getCodeScroll() - if (next.length === codeScroll.length && next.every((el, i) => el === codeScroll[i])) return - - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - codeScroll = next - - for (const item of codeScroll) { - item.addEventListener("scroll", handleCodeScroll) - } - } - - const restoreScroll = () => { - const el = scroll - if (!el) return - - const s = view()?.scroll(tab) - if (!s) return - - syncCodeScroll() - - if (codeScroll.length > 0) { - for (const item of codeScroll) { - if (item.scrollLeft !== s.x) item.scrollLeft = s.x - } - } - - if (el.scrollTop !== s.y) el.scrollTop = s.y - - if (codeScroll.length > 0) return - - if (el.scrollLeft !== s.x) el.scrollLeft = s.x - } - - const handleScroll = (event: Event & { currentTarget: HTMLDivElement }) => { - if (codeScroll.length === 0) syncCodeScroll() - - queueScrollUpdate({ - x: codeScroll[0]?.scrollLeft ?? event.currentTarget.scrollLeft, - y: event.currentTarget.scrollTop, - }) - } - - createEffect( - on( - () => state()?.loaded, - (loaded) => { - if (!loaded) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => file.ready(), - (ready) => { - if (!ready) return - requestAnimationFrame(restoreScroll) - }, - { defer: true }, - ), - ) - - createEffect( - on( - () => tabs().active() === tab, - (active) => { - if (!active) return - if (!state()?.loaded) return - requestAnimationFrame(restoreScroll) - }, - ), - ) - - onCleanup(() => { - if (commentFrame !== undefined) cancelAnimationFrame(commentFrame) - for (const item of codeScroll) { - item.removeEventListener("scroll", handleCodeScroll) - } - - if (scrollFrame === undefined) return - cancelAnimationFrame(scrollFrame) - }) - - return ( - { - scroll = el - restoreScroll() - }} - onScroll={handleScroll} - > - - -
- {path()} requestAnimationFrame(restoreScroll)} - /> -
-
- -
- {renderCode(svgContent() ?? "", "")} - -
- {path()} -
-
-
-
- {renderCode(contents(), "pb-40")} - -
{language.t("common.loading")}...
-
- - {(err) =>
{err()}
} -
-
-
- ) - }} -
-
- - - {(tab) => { - const path = createMemo(() => file.pathFromTab(tab())) - return ( -
- {(p) => } -
- ) - }} -
-
- -
@@ -2647,7 +2585,7 @@ export default function Page() { - Changes + {reviewCount()} {reviewCount() === 1 ? "Change" : "Changes"} All files @@ -2662,7 +2600,7 @@ export default function Page() { > d.file)} + allowed={diffFiles()} draggable={false} tooltip={false} onFileClick={(node) => focusReviewDiff(node.path)} @@ -2675,7 +2613,7 @@ export default function Page() { - openTab(file.tab(node.path))} /> + openTab(file.tab(node.path))} />
diff --git a/packages/ui/src/components/tabs.css b/packages/ui/src/components/tabs.css index 2f3c914e14..f02b7deb54 100644 --- a/packages/ui/src/components/tabs.css +++ b/packages/ui/src/components/tabs.css @@ -219,7 +219,6 @@ height: auto; padding: 6px; gap: 4px; - border-bottom: 1px solid var(--border-weak-base); background-color: var(--background-base); &::after { @@ -230,7 +229,7 @@ [data-slot="tabs-trigger-wrapper"] { height: 32px; border: none; - border-radius: 999px; + border-radius: var(--radius-sm); background-color: transparent; gap: 0; From 37f1a1a4ef36eacb60ad5493db8aeb1130c5fa91 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 10:08:29 -0600 Subject: [PATCH 049/294] chore: cleanup --- packages/app/src/components/file-tree.tsx | 2 +- packages/ui/src/components/tabs.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index c27ccbe6dd..283bceac72 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -96,7 +96,7 @@ export default function FileTree(props: { Date: Mon, 26 Jan 2026 10:04:59 -0600 Subject: [PATCH 050/294] chore(app): createStore over signals --- .../src/components/dialog-edit-project.tsx | 31 ++-- .../session/session-sortable-terminal-tab.tsx | 51 +++--- .../app/src/components/settings-keybinds.tsx | 45 +++--- .../app/src/components/status-popover.tsx | 23 ++- packages/app/src/context/command.tsx | 18 ++- packages/app/src/context/comments.tsx | 18 ++- packages/app/src/context/server.tsx | 39 ++--- packages/app/src/pages/layout.tsx | 147 ++++++++++-------- packages/app/src/pages/session.tsx | 99 ++++++++---- packages/app/src/utils/speech.ts | 41 +++-- 10 files changed, 294 insertions(+), 218 deletions(-) diff --git a/packages/app/src/components/dialog-edit-project.tsx b/packages/app/src/components/dialog-edit-project.tsx index 9e2bddc6be..2ab25ceeb4 100644 --- a/packages/app/src/components/dialog-edit-project.tsx +++ b/packages/app/src/components/dialog-edit-project.tsx @@ -3,7 +3,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" import { TextField } from "@opencode-ai/ui/text-field" import { Icon } from "@opencode-ai/ui/icon" -import { createMemo, createSignal, For, Show } from "solid-js" +import { createMemo, For, Show } from "solid-js" import { createStore } from "solid-js/store" import { useGlobalSDK } from "@/context/global-sdk" import { useGlobalSync } from "@/context/global-sync" @@ -29,35 +29,34 @@ export function DialogEditProject(props: { project: LocalProject }) { iconUrl: props.project.icon?.override || "", startup: props.project.commands?.start ?? "", saving: false, + dragOver: false, + iconHover: false, }) - const [dragOver, setDragOver] = createSignal(false) - const [iconHover, setIconHover] = createSignal(false) - function handleFileSelect(file: File) { if (!file.type.startsWith("image/")) return const reader = new FileReader() reader.onload = (e) => { setStore("iconUrl", e.target?.result as string) - setIconHover(false) + setStore("iconHover", false) } reader.readAsDataURL(file) } function handleDrop(e: DragEvent) { e.preventDefault() - setDragOver(false) + setStore("dragOver", false) const file = e.dataTransfer?.files[0] if (file) handleFileSelect(file) } function handleDragOver(e: DragEvent) { e.preventDefault() - setDragOver(true) + setStore("dragOver", true) } function handleDragLeave() { - setDragOver(false) + setStore("dragOver", false) } function handleInputChange(e: Event) { @@ -116,19 +115,23 @@ export function DialogEditProject(props: { project: LocalProject }) {
-
setIconHover(true)} onMouseLeave={() => setIconHover(false)}> +
setStore("iconHover", true)} + onMouseLeave={() => setStore("iconHover", false)} + >
{ - if (store.iconUrl && iconHover()) { + if (store.iconUrl && store.iconHover) { clearIcon() } else { document.getElementById("icon-upload")?.click() @@ -166,7 +169,7 @@ export function DialogEditProject(props: { project: LocalProject }) { "border-radius": "6px", "z-index": 10, "pointer-events": "none", - opacity: iconHover() && !store.iconUrl ? 1 : 0, + opacity: store.iconHover && !store.iconUrl ? 1 : 0, display: "flex", "align-items": "center", "justify-content": "center", @@ -185,7 +188,7 @@ export function DialogEditProject(props: { project: LocalProject }) { "border-radius": "6px", "z-index": 10, "pointer-events": "none", - opacity: iconHover() && store.iconUrl ? 1 : 0, + opacity: store.iconHover && store.iconUrl ? 1 : 0, display: "flex", "align-items": "center", "justify-content": "center", diff --git a/packages/app/src/components/session/session-sortable-terminal-tab.tsx b/packages/app/src/components/session/session-sortable-terminal-tab.tsx index d16379e80a..75e9b22f99 100644 --- a/packages/app/src/components/session/session-sortable-terminal-tab.tsx +++ b/packages/app/src/components/session/session-sortable-terminal-tab.tsx @@ -1,5 +1,6 @@ import type { JSX } from "solid-js" -import { createSignal, Show } from "solid-js" +import { Show } from "solid-js" +import { createStore } from "solid-js/store" import { createSortable } from "@thisbeyond/solid-dnd" import { IconButton } from "@opencode-ai/ui/icon-button" import { Tabs } from "@opencode-ai/ui/tabs" @@ -12,11 +13,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => const terminal = useTerminal() const language = useLanguage() const sortable = createSortable(props.terminal.id) - const [editing, setEditing] = createSignal(false) - const [title, setTitle] = createSignal(props.terminal.title) - const [menuOpen, setMenuOpen] = createSignal(false) - const [menuPosition, setMenuPosition] = createSignal({ x: 0, y: 0 }) - const [blurEnabled, setBlurEnabled] = createSignal(false) + const [store, setStore] = createStore({ + editing: false, + title: props.terminal.title, + menuOpen: false, + menuPosition: { x: 0, y: 0 }, + blurEnabled: false, + }) const isDefaultTitle = () => { const number = props.terminal.titleNumber @@ -47,7 +50,7 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => } const focus = () => { - if (editing()) return + if (store.editing) return if (document.activeElement instanceof HTMLElement) { document.activeElement.blur() @@ -71,26 +74,26 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => e.preventDefault() } - setBlurEnabled(false) - setTitle(props.terminal.title) - setEditing(true) + setStore("blurEnabled", false) + setStore("title", props.terminal.title) + setStore("editing", true) setTimeout(() => { const input = document.getElementById(`terminal-title-input-${props.terminal.id}`) as HTMLInputElement if (!input) return input.focus() input.select() - setTimeout(() => setBlurEnabled(true), 100) + setTimeout(() => setStore("blurEnabled", true), 100) }, 10) } const save = () => { - if (!blurEnabled()) return + if (!store.blurEnabled) return - const value = title().trim() + const value = store.title.trim() if (value && value !== props.terminal.title) { terminal.update({ id: props.terminal.id, title: value }) } - setEditing(false) + setStore("editing", false) } const keydown = (e: KeyboardEvent) => { @@ -101,14 +104,14 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => } if (e.key === "Escape") { e.preventDefault() - setEditing(false) + setStore("editing", false) } } const menu = (e: MouseEvent) => { e.preventDefault() - setMenuPosition({ x: e.clientX, y: e.clientY }) - setMenuOpen(true) + setStore("menuPosition", { x: e.clientX, y: e.clientY }) + setStore("menuOpen", true) } return ( @@ -143,17 +146,17 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => /> } > - + {label()} - +
setTitle(e.currentTarget.value)} + value={store.title} + onInput={(e) => setStore("title", e.currentTarget.value)} onBlur={save} onKeyDown={keydown} onMouseDown={(e) => e.stopPropagation()} @@ -161,13 +164,13 @@ export function SortableTerminalTab(props: { terminal: LocalPTY; onClose?: () => />
- + setStore("menuOpen", open)}> diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index 4dc3971495..7ff3425ab2 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -1,4 +1,5 @@ -import { Component, For, Show, createMemo, createSignal, onCleanup, onMount } from "solid-js" +import { Component, For, Show, createMemo, onCleanup, onMount } from "solid-js" +import { createStore } from "solid-js/store" import { Button } from "@opencode-ai/ui/button" import { Icon } from "@opencode-ai/ui/icon" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -111,24 +112,26 @@ export const SettingsKeybinds: Component = () => { const language = useLanguage() const settings = useSettings() - const [active, setActive] = createSignal(null) - const [filter, setFilter] = createSignal("") + const [store, setStore] = createStore({ + active: null as string | null, + filter: "", + }) const stop = () => { - if (!active()) return - setActive(null) + if (!store.active) return + setStore("active", null) command.keybinds(true) } const start = (id: string) => { - if (active() === id) { + if (store.active === id) { stop() return } - if (active()) stop() + if (store.active) stop() - setActive(id) + setStore("active", id) command.keybinds(false) } @@ -203,7 +206,7 @@ export const SettingsKeybinds: Component = () => { }) const filtered = createMemo(() => { - const query = filter().toLowerCase().trim() + const query = store.filter.toLowerCase().trim() if (!query) return grouped() const map = list() @@ -285,7 +288,7 @@ export const SettingsKeybinds: Component = () => { onMount(() => { const handle = (event: KeyboardEvent) => { - const id = active() + const id = store.active if (!id) return event.preventDefault() @@ -345,7 +348,7 @@ export const SettingsKeybinds: Component = () => { }) onCleanup(() => { - if (active()) command.keybinds(true) + if (store.active) command.keybinds(true) }) return ( @@ -370,8 +373,8 @@ export const SettingsKeybinds: Component = () => { setStore("filter", v)} placeholder={language.t("settings.shortcuts.search.placeholder")} spellcheck={false} autocorrect="off" @@ -379,8 +382,8 @@ export const SettingsKeybinds: Component = () => { autocapitalize="off" class="flex-1" /> - - setFilter("")} /> + + setStore("filter", "")} />
@@ -402,13 +405,13 @@ export const SettingsKeybinds: Component = () => { classList={{ "h-8 px-3 rounded-md text-12-regular": true, "bg-surface-base text-text-subtle hover:bg-surface-raised-base-hover active:bg-surface-raised-base-active": - active() !== id, - "border border-border-weak-base bg-surface-inset-base text-text-weak": active() === id, + store.active !== id, + "border border-border-weak-base bg-surface-inset-base text-text-weak": store.active === id, }} onClick={() => start(id)} > {language.t("settings.shortcuts.pressKeys")} @@ -423,11 +426,11 @@ export const SettingsKeybinds: Component = () => { )} - +
{language.t("settings.shortcuts.search.empty")} - - "{filter()}" + + "{store.filter}"
diff --git a/packages/app/src/components/status-popover.tsx b/packages/app/src/components/status-popover.tsx index cb45c36895..c2c4d268a2 100644 --- a/packages/app/src/components/status-popover.tsx +++ b/packages/app/src/components/status-popover.tsx @@ -39,9 +39,10 @@ export function StatusPopover() { const language = useLanguage() const navigate = useNavigate() - const [loading, setLoading] = createSignal(null) const [store, setStore] = createStore({ status: {} as Record, + loading: null as string | null, + defaultServerUrl: undefined as string | undefined, }) const servers = createMemo(() => { @@ -97,8 +98,8 @@ export function StatusPopover() { const mcpConnected = createMemo(() => mcpItems().filter((i) => i.status === "connected").length) const toggleMcp = async (name: string) => { - if (loading()) return - setLoading(name) + if (store.loading) return + setStore("loading", name) const status = sync.data.mcp[name] if (status?.status === "connected") { await sdk.client.mcp.disconnect({ name }) @@ -107,7 +108,7 @@ export function StatusPopover() { } const result = await sdk.client.mcp.status() if (result.data) sync.set("mcp", result.data) - setLoading(null) + setStore("loading", null) } const lspItems = createMemo(() => sync.data.lsp ?? []) @@ -123,19 +124,17 @@ export function StatusPopover() { const serverCount = createMemo(() => sortedServers().length) - const [defaultServerUrl, setDefaultServerUrl] = createSignal() - const refreshDefaultServerUrl = () => { const result = platform.getDefaultServerUrl?.() if (!result) { - setDefaultServerUrl(undefined) + setStore("defaultServerUrl", undefined) return } if (result instanceof Promise) { - result.then((url) => setDefaultServerUrl(url ? normalizeServerUrl(url) : undefined)) + result.then((url) => setStore("defaultServerUrl", url ? normalizeServerUrl(url) : undefined)) return } - setDefaultServerUrl(normalizeServerUrl(result)) + setStore("defaultServerUrl", normalizeServerUrl(result)) } createEffect(() => { @@ -220,7 +219,7 @@ export function StatusPopover() { {(url) => { const isActive = () => url === server.url - const isDefault = () => url === defaultServerUrl() + const isDefault = () => url === store.defaultServerUrl const status = () => store.status[url] const isBlocked = () => status()?.healthy === false const [truncated, setTruncated] = createSignal(false) @@ -329,7 +328,7 @@ export function StatusPopover() { type="button" class="flex items-center gap-2 w-full h-8 pl-3 pr-2 py-1 rounded-md hover:bg-surface-raised-base-hover transition-colors text-left" onClick={() => toggleMcp(item.name)} - disabled={loading() === item.name} + disabled={store.loading === item.name} >
event.stopPropagation()}> toggleMcp(item.name)} />
diff --git a/packages/app/src/context/command.tsx b/packages/app/src/context/command.tsx index dc5228b82d..7915695840 100644 --- a/packages/app/src/context/command.tsx +++ b/packages/app/src/context/command.tsx @@ -1,4 +1,4 @@ -import { createEffect, createMemo, createSignal, onCleanup, onMount, type Accessor } from "solid-js" +import { createEffect, createMemo, onCleanup, onMount, type Accessor } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -165,8 +165,10 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const dialog = useDialog() const settings = useSettings() const language = useLanguage() - const [registrations, setRegistrations] = createSignal[]>([]) - const [suspendCount, setSuspendCount] = createSignal(0) + const [store, setStore] = createStore({ + registrations: [] as Accessor[], + suspendCount: 0, + }) const [catalog, setCatalog, _, catalogReady] = persisted( Persist.global("command.catalog.v1"), @@ -184,7 +186,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex const seen = new Set() const all: CommandOption[] = [] - for (const reg of registrations()) { + for (const reg of store.registrations) { for (const opt of reg()) { if (seen.has(opt.id)) continue seen.add(opt.id) @@ -230,7 +232,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex ] }) - const suspended = () => suspendCount() > 0 + const suspended = () => store.suspendCount > 0 const palette = createMemo(() => { const config = settings.keybinds.get(PALETTE_ID) ?? DEFAULT_PALETTE_KEYBIND @@ -297,9 +299,9 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex return { register(cb: () => CommandOption[]) { const results = createMemo(cb) - setRegistrations((arr) => [results, ...arr]) + setStore("registrations", (arr) => [results, ...arr]) onCleanup(() => { - setRegistrations((arr) => arr.filter((x) => x !== results)) + setStore("registrations", (arr) => arr.filter((x) => x !== results)) }) }, trigger(id: string, source?: "palette" | "keybind" | "slash") { @@ -321,7 +323,7 @@ export const { use: useCommand, provider: CommandProvider } = createSimpleContex }, show: showPalette, keybinds(enabled: boolean) { - setSuspendCount((count) => count + (enabled ? -1 : 1)) + setStore("suspendCount", (count) => count + (enabled ? -1 : 1)) }, suspended, get catalog() { diff --git a/packages/app/src/context/comments.tsx b/packages/app/src/context/comments.tsx index 41123042d8..f555145874 100644 --- a/packages/app/src/context/comments.tsx +++ b/packages/app/src/context/comments.tsx @@ -1,4 +1,4 @@ -import { batch, createMemo, createRoot, createSignal, onCleanup } from "solid-js" +import { batch, createMemo, createRoot, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useParams } from "@solidjs/router" @@ -37,8 +37,16 @@ function createCommentSession(dir: string, id: string | undefined) { }), ) - const [focus, setFocus] = createSignal(null) - const [active, setActive] = createSignal(null) + const [state, setState] = createStore({ + focus: null as CommentFocus | null, + active: null as CommentFocus | null, + }) + + const setFocus = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setState("focus", value) + + const setActive = (value: CommentFocus | null | ((value: CommentFocus | null) => CommentFocus | null)) => + setState("active", value) const list = (file: string) => store.comments[file] ?? [] @@ -74,10 +82,10 @@ function createCommentSession(dir: string, id: string | undefined) { all, add, remove, - focus: createMemo(() => focus()), + focus: createMemo(() => state.focus), setFocus, clearFocus: () => setFocus(null), - active: createMemo(() => active()), + active: createMemo(() => state.active), setActive, clearActive: () => setActive(null), } diff --git a/packages/app/src/context/server.tsx b/packages/app/src/context/server.tsx index 1076570928..4a3f3c6d1a 100644 --- a/packages/app/src/context/server.tsx +++ b/packages/app/src/context/server.tsx @@ -1,6 +1,6 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" -import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, onCleanup } from "solid-js" import { createStore } from "solid-js/store" import { usePlatform } from "@/context/platform" import { Persist, persisted } from "@/utils/persist" @@ -40,12 +40,17 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }), ) - const [active, setActiveRaw] = createSignal("") + const [state, setState] = createStore({ + active: "", + healthy: undefined as boolean | undefined, + }) + + const healthy = () => state.healthy function setActive(input: string) { const url = normalizeServerUrl(input) if (!url) return - setActiveRaw(url) + setState("active", url) } function add(input: string) { @@ -54,7 +59,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( const fallback = normalizeServerUrl(props.defaultUrl) if (fallback && url === fallback) { - setActiveRaw(url) + setState("active", url) return } @@ -62,7 +67,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!store.list.includes(url)) { setStore("list", store.list.length, url) } - setActiveRaw(url) + setState("active", url) }) } @@ -71,25 +76,23 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( if (!url) return const list = store.list.filter((x) => x !== url) - const next = active() === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : active() + const next = state.active === url ? (list[0] ?? normalizeServerUrl(props.defaultUrl) ?? "") : state.active batch(() => { setStore("list", list) - setActiveRaw(next) + setState("active", next) }) } createEffect(() => { if (!ready()) return - if (active()) return + if (state.active) return const url = normalizeServerUrl(props.defaultUrl) if (!url) return - setActiveRaw(url) + setState("active", url) }) - const isReady = createMemo(() => ready() && !!active()) - - const [healthy, setHealthy] = createSignal(undefined) + const isReady = createMemo(() => ready() && !!state.active) const check = (url: string) => { const sdk = createOpencodeClient({ @@ -104,10 +107,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( } createEffect(() => { - const url = active() + const url = state.active if (!url) return - setHealthy(undefined) + setState("healthy", undefined) let alive = true let busy = false @@ -118,7 +121,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( void check(url) .then((next) => { if (!alive) return - setHealthy(next) + setState("healthy", next) }) .finally(() => { busy = false @@ -134,7 +137,7 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( }) }) - const origin = createMemo(() => projectsKey(active())) + const origin = createMemo(() => projectsKey(state.active)) const projectsList = createMemo(() => store.projects[origin()] ?? []) const isLocal = createMemo(() => origin() === "local") @@ -143,10 +146,10 @@ export const { use: useServer, provider: ServerProvider } = createSimpleContext( healthy, isLocal, get url() { - return active() + return state.active }, get name() { - return serverDisplayName(active()) + return serverDisplayName(state.active) }, get list() { return store.list diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 7ad97a989f..fd6f6e527d 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -91,7 +91,6 @@ export default function Layout(props: ParentProps) { let scrollContainerRef: HTMLDivElement | undefined const params = useParams() - const [autoselect, setAutoselect] = createSignal(!params.dir) const globalSDK = useGlobalSDK() const globalSync = useGlobalSync() const layout = useLayout() @@ -117,27 +116,31 @@ export default function Layout(props: ParentProps) { } const colorSchemeLabel = (scheme: ColorScheme) => language.t(colorSchemeKey[scheme]) + const [state, setState] = createStore({ + autoselect: !params.dir, + busyWorkspaces: new Set(), + hoverSession: undefined as string | undefined, + hoverProject: undefined as string | undefined, + scrollSessionKey: undefined as string | undefined, + nav: undefined as HTMLElement | undefined, + }) + const [editor, setEditor] = createStore({ active: "" as string, value: "", }) - const [busyWorkspaces, setBusyWorkspaces] = createSignal>(new Set()) const setBusy = (directory: string, value: boolean) => { const key = workspaceKey(directory) - setBusyWorkspaces((prev) => { + setState("busyWorkspaces", (prev) => { const next = new Set(prev) if (value) next.add(key) else next.delete(key) return next }) } - const isBusy = (directory: string) => busyWorkspaces().has(workspaceKey(directory)) + const isBusy = (directory: string) => state.busyWorkspaces.has(workspaceKey(directory)) const editorRef = { current: undefined as HTMLInputElement | undefined } - const [hoverSession, setHoverSession] = createSignal() - const [hoverProject, setHoverProject] = createSignal() - - const [nav, setNav] = createSignal(undefined) const navLeave = { current: undefined as number | undefined } onCleanup(() => { @@ -145,18 +148,18 @@ export default function Layout(props: ParentProps) { clearTimeout(navLeave.current) }) - const sidebarHovering = createMemo(() => !layout.sidebar.opened() && hoverProject() !== undefined) + const sidebarHovering = createMemo(() => !layout.sidebar.opened() && state.hoverProject !== undefined) const sidebarExpanded = createMemo(() => layout.sidebar.opened() || sidebarHovering()) const hoverProjectData = createMemo(() => { - const id = hoverProject() + const id = state.hoverProject if (!id) return return layout.projects.list().find((project) => project.worktree === id) }) createEffect(() => { if (!layout.sidebar.opened()) return - setHoverProject(undefined) + setState("hoverProject", undefined) }) createEffect( @@ -164,9 +167,9 @@ export default function Layout(props: ParentProps) { () => ({ dir: params.dir, id: params.id }), () => { if (layout.sidebar.opened()) return - if (!hoverProject()) return - setHoverSession(undefined) - setHoverProject(undefined) + if (!state.hoverProject) return + setState("hoverSession", undefined) + setState("hoverProject", undefined) }, { defer: true }, ), @@ -175,7 +178,7 @@ export default function Layout(props: ParentProps) { const autoselecting = createMemo(() => { if (params.dir) return false if (initialDir) return false - if (!autoselect()) return false + if (!state.autoselect) return false if (!pageReady()) return true if (!layoutReady()) return true const list = layout.projects.list() @@ -483,20 +486,18 @@ export default function Layout(props: ParentProps) { } } - const [scrollSessionKey, setScrollSessionKey] = createSignal(undefined) - function scrollToSession(sessionId: string, sessionKey: string) { if (!scrollContainerRef) return - if (scrollSessionKey() === sessionKey) return + if (state.scrollSessionKey === sessionKey) return const element = scrollContainerRef.querySelector(`[data-session-id="${sessionId}"]`) if (!element) return const containerRect = scrollContainerRef.getBoundingClientRect() const elementRect = element.getBoundingClientRect() if (elementRect.top >= containerRect.top && elementRect.bottom <= containerRect.bottom) { - setScrollSessionKey(sessionKey) + setState("scrollSessionKey", sessionKey) return } - setScrollSessionKey(sessionKey) + setState("scrollSessionKey", sessionKey) element.scrollIntoView({ block: "nearest", behavior: "smooth" }) } @@ -544,7 +545,7 @@ export default function Layout(props: ParentProps) { (value) => { if (!value.ready) return if (!value.layoutReady) return - if (!autoselect()) return + if (!state.autoselect) return if (initialDir) return if (value.dir) return if (value.list.length === 0) return @@ -552,7 +553,7 @@ export default function Layout(props: ParentProps) { const last = server.projects.last() const next = value.list.find((project) => project.worktree === last) ?? value.list[0] if (!next) return - setAutoselect(false) + setState("autoselect", false) openProject(next.worktree, false) navigateToProject(next.worktree) }, @@ -1066,8 +1067,8 @@ export default function Layout(props: ParentProps) { function navigateToProject(directory: string | undefined) { if (!directory) return if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } server.projects.touch(directory) const lastSession = store.lastSession[directory] @@ -1078,8 +1079,8 @@ export default function Layout(props: ParentProps) { function navigateToSession(session: Session | undefined) { if (!session) return if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } navigate(`/${base64Encode(session.directory)}/session/${session.id}`) layout.mobileSidebar.hide() @@ -1472,7 +1473,7 @@ export default function Layout(props: ParentProps) { function handleDragStart(event: unknown) { const id = getDraggableId(event) if (!id) return - setHoverProject(undefined) + setState("hoverProject", undefined) setStore("activeProject", id) } @@ -1632,8 +1633,10 @@ export default function Layout(props: ParentProps) { const hoverAllowed = createMemo(() => !props.mobile && sidebarExpanded()) const hoverEnabled = createMemo(() => (props.popover ?? true) && hoverAllowed()) const isActive = createMemo(() => props.session.id === params.id) - const [menuOpen, setMenuOpen] = createSignal(false) - const [pendingRename, setPendingRename] = createSignal(false) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) const messageLabel = (message: Message) => { const parts = sessionStore.part[message.id] ?? [] @@ -1644,13 +1647,13 @@ export default function Layout(props: ParentProps) { const item = (
prefetchSession(props.session, "high")} onFocus={() => prefetchSession(props.session, "high")} onClick={() => { - setHoverSession(undefined) + setState("hoverSession", undefined) if (layout.sidebar.opened()) return - queueMicrotask(() => setHoverProject(undefined)) + queueMicrotask(() => setState("hoverProject", undefined)) }} >
@@ -1713,9 +1716,9 @@ export default function Layout(props: ParentProps) { gutter={16} shift={-2} trigger={item} - mount={!props.mobile ? nav() : undefined} - open={hoverSession() === props.session.id} - onOpenChange={(open) => setHoverSession(open ? props.session.id : undefined)} + mount={!props.mobile ? state.nav : undefined} + open={state.hoverSession === props.session.id} + onOpenChange={(open) => setState("hoverSession", open ? props.session.id : undefined)} > - + setMenu("open", open)}> - + { - if (!pendingRename()) return + if (!menu.pendingRename) return event.preventDefault() - setPendingRename(false) + setMenu("pendingRename", false) openEditor(`session:${props.session.id}`, props.session.title) }} > { - setPendingRename(true) - setMenuOpen(false) + setMenu("pendingRename", true) + setMenu("open", false) }} > {language.t("common.rename")} @@ -1802,9 +1805,9 @@ export default function Layout(props: ParentProps) { end class={`flex items-center justify-between gap-3 min-w-0 text-left w-full focus:outline-none ${props.dense ? "py-0.5" : "py-1"}`} onClick={() => { - setHoverSession(undefined) + setState("hoverSession", undefined) if (layout.sidebar.opened()) return - queueMicrotask(() => setHoverProject(undefined)) + queueMicrotask(() => setState("hoverProject", undefined)) }} >
@@ -1884,8 +1887,10 @@ export default function Layout(props: ParentProps) { const SortableWorkspace = (props: { directory: string; project: LocalProject; mobile?: boolean }): JSX.Element => { const sortable = createSortable(props.directory) const [workspaceStore, setWorkspaceStore] = globalSync.child(props.directory, { bootstrap: false }) - const [menuOpen, setMenuOpen] = createSignal(false) - const [pendingRename, setPendingRename] = createSignal(false) + const [menu, setMenu] = createStore({ + open: false, + pendingRename: false, + }) const slug = createMemo(() => base64Encode(props.directory)) const sessions = createMemo(() => workspaceStore.session @@ -1995,13 +2000,17 @@ export default function Layout(props: ParentProps) {
- + setMenu("open", open)} + > - + { - if (!pendingRename()) return + if (!menu.pendingRename) return event.preventDefault() - setPendingRename(false) + setMenu("pendingRename", false) openEditor(`workspace:${props.directory}`, workspaceValue()) }} > { - setPendingRename(true) - setMenuOpen(false) + setMenu("pendingRename", true) + setMenu("open", false) }} > {language.t("common.rename")} @@ -2103,7 +2112,7 @@ export default function Layout(props: ParentProps) { const preview = createMemo(() => !props.mobile && layout.sidebar.opened()) const overlay = createMemo(() => !props.mobile && !layout.sidebar.opened()) - const active = createMemo(() => (preview() ? open() : overlay() && hoverProject() === props.project.worktree)) + const active = createMemo(() => (preview() ? open() : overlay() && state.hoverProject === props.project.worktree)) createEffect(() => { if (preview()) return @@ -2155,14 +2164,14 @@ export default function Layout(props: ParentProps) { onMouseEnter={() => { if (!overlay()) return globalSync.child(props.project.worktree) - setHoverProject(props.project.worktree) - setHoverSession(undefined) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) }} onFocus={() => { if (!overlay()) return globalSync.child(props.project.worktree) - setHoverProject(props.project.worktree) - setHoverSession(undefined) + setState("hoverProject", props.project.worktree) + setState("hoverSession", undefined) }} onClick={() => navigateToProject(props.project.worktree)} onBlur={() => setOpen(false)} @@ -2184,7 +2193,7 @@ export default function Layout(props: ParentProps) { trigger={trigger} onOpenChange={(value) => { setOpen(value) - if (value) setHoverSession(undefined) + if (value) setState("hoverSession", undefined) }} >
@@ -2323,8 +2332,8 @@ export default function Layout(props: ParentProps) { const createWorkspace = async (project: LocalProject) => { if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } const created = await globalSDK.client.worktree .create({ directory: project.worktree }) @@ -2427,7 +2436,7 @@ export default function Layout(props: ParentProps) { class="shrink-0 size-6 rounded-md opacity-0 group-hover/project:opacity-100 data-[expanded]:opacity-100 data-[expanded]:bg-surface-base-active" aria-label={language.t("common.moreOptions")} /> - + dialog.show(() => )}> {language.t("common.edit")} @@ -2476,8 +2485,8 @@ export default function Layout(props: ParentProps) { class="w-full" onClick={() => { if (!layout.sidebar.opened()) { - setHoverSession(undefined) - setHoverProject(undefined) + setState("hoverSession", undefined) + setState("hoverProject", undefined) } navigate(`/${base64Encode(p.worktree)}/session`) layout.mobileSidebar.hide() @@ -2668,7 +2677,7 @@ export default function Layout(props: ParentProps) { }} style={{ width: layout.sidebar.opened() ? `${Math.max(layout.sidebar.width(), 244)}px` : "64px" }} ref={(el) => { - setNav(el) + setState("nav", el) }} onMouseEnter={() => { if (navLeave.current === undefined) return @@ -2681,8 +2690,8 @@ export default function Layout(props: ParentProps) { if (navLeave.current !== undefined) clearTimeout(navLeave.current) navLeave.current = window.setTimeout(() => { navLeave.current = undefined - setHoverProject(undefined) - setHoverSession(undefined) + setState("hoverProject", undefined) + setState("hoverSession", undefined) }, 300) }} > diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 458decfc41..e9b29c03e3 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1,4 +1,4 @@ -import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on, createSignal } from "solid-js" +import { For, onCleanup, onMount, Show, Match, Switch, createMemo, createEffect, on } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { createResizeObserver } from "@solid-primitives/resize-observer" import { Dynamic } from "solid-js/web" @@ -198,12 +198,17 @@ export default function Page() { return next }) - const [responding, setResponding] = createSignal(false) + const [ui, setUi] = createStore({ + responding: false, + pendingMessage: undefined as string | undefined, + scrollGesture: 0, + autoCreated: false, + }) createEffect( on( () => request()?.id, - () => setResponding(false), + () => setUi("responding", false), { defer: true }, ), ) @@ -211,18 +216,17 @@ export default function Page() { const decide = (response: "once" | "always" | "reject") => { const perm = request() if (!perm) return - if (responding()) return + if (ui.responding) return - setResponding(true) + setUi("responding", true) sdk.client.permission .respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description: message }) }) - .finally(() => setResponding(false)) + .finally(() => setUi("responding", false)) } - const [pendingMessage, setPendingMessage] = createSignal(undefined) const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) @@ -439,7 +443,6 @@ export default function Page() { let promptDock: HTMLDivElement | undefined let scroller: HTMLDivElement | undefined - const [scrollGesture, setScrollGesture] = createSignal(0) const scrollGestureWindowMs = 250 const markScrollGesture = (target?: EventTarget | null) => { @@ -450,26 +453,24 @@ export default function Page() { const nested = el?.closest("[data-scrollable]") if (nested && nested !== root) return - setScrollGesture(Date.now()) + setUi("scrollGesture", Date.now()) } - const hasScrollGesture = () => Date.now() - scrollGesture() < scrollGestureWindowMs + const hasScrollGesture = () => Date.now() - ui.scrollGesture < scrollGestureWindowMs createEffect(() => { if (!params.id) return sync.session.sync(params.id) }) - const [autoCreated, setAutoCreated] = createSignal(false) - createEffect(() => { if (!view().terminal.opened()) { - setAutoCreated(false) + setUi("autoCreated", false) return } - if (!terminal.ready() || terminal.all().length !== 0 || autoCreated()) return + if (!terminal.ready() || terminal.all().length !== 0 || ui.autoCreated) return terminal.new() - setAutoCreated(true) + setUi("autoCreated", true) }) createEffect( @@ -1019,9 +1020,18 @@ export default function Page() { const showTabs = createMemo(() => view().reviewPanel.opened()) - const [fileTreeTab, setFileTreeTab] = createSignal<"changes" | "all">("changes") - const [reviewScroll, setReviewScroll] = createSignal(undefined) - const [pendingDiff, setPendingDiff] = createSignal(undefined) + const [tree, setTree] = createStore({ + fileTreeTab: "changes" as "changes" | "all", + reviewScroll: undefined as HTMLDivElement | undefined, + pendingDiff: undefined as string | undefined, + }) + + const fileTreeTab = () => tree.fileTreeTab + const setFileTreeTab = (value: "changes" | "all") => setTree("fileTreeTab", value) + const reviewScroll = () => tree.reviewScroll + const setReviewScroll = (value: HTMLDivElement | undefined) => setTree("reviewScroll", value) + const pendingDiff = () => tree.pendingDiff + const setPendingDiff = (value: string | undefined) => setTree("pendingDiff", value) createEffect(() => { if (!layout.fileTree.opened()) return @@ -1316,7 +1326,7 @@ export default function Page() { if (pendingSessionID !== sessionID) return sessionStorage.removeItem("opencode.pendingMessage") - setPendingMessage(messageID) + setUi("pendingMessage", messageID) }) const scrollToElement = (el: HTMLElement, behavior: ScrollBehavior) => { @@ -1484,7 +1494,7 @@ export default function Page() { store.turnStart const targetId = - pendingMessage() ?? + ui.pendingMessage ?? (() => { const hash = window.location.hash.slice(1) const match = hash.match(/^message-(.+)$/) @@ -1496,7 +1506,7 @@ export default function Page() { const msg = visibleUserMessages().find((m) => m.id === targetId) if (!msg) return - if (pendingMessage() === targetId) setPendingMessage(undefined) + if (ui.pendingMessage === targetId) setUi("pendingMessage", undefined) requestAnimationFrame(() => scrollToMessage(msg, "auto")) }) @@ -1877,18 +1887,18 @@ export default function Page() {
- -
@@ -2144,11 +2154,40 @@ export default function Page() { const commentedLines = createMemo(() => fileComments().map((comment) => comment.selection)) - const [openedComment, setOpenedComment] = createSignal(null) - const [commenting, setCommenting] = createSignal(null) - const [draft, setDraft] = createSignal("") - const [positions, setPositions] = createSignal>({}) - const [draftTop, setDraftTop] = createSignal(undefined) + const [note, setNote] = createStore({ + openedComment: null as string | null, + commenting: null as SelectedLineRange | null, + draft: "", + positions: {} as Record, + draftTop: undefined as number | undefined, + }) + + const openedComment = () => note.openedComment + const setOpenedComment = ( + value: + | typeof note.openedComment + | ((value: typeof note.openedComment) => typeof note.openedComment), + ) => setNote("openedComment", value) + + const commenting = () => note.commenting + const setCommenting = ( + value: typeof note.commenting | ((value: typeof note.commenting) => typeof note.commenting), + ) => setNote("commenting", value) + + const draft = () => note.draft + const setDraft = ( + value: typeof note.draft | ((value: typeof note.draft) => typeof note.draft), + ) => setNote("draft", value) + + const positions = () => note.positions + const setPositions = ( + value: typeof note.positions | ((value: typeof note.positions) => typeof note.positions), + ) => setNote("positions", value) + + const draftTop = () => note.draftTop + const setDraftTop = ( + value: typeof note.draftTop | ((value: typeof note.draftTop) => typeof note.draftTop), + ) => setNote("draftTop", value) const commentLabel = (range: SelectedLineRange) => { const start = Math.min(range.start, range.end) @@ -2695,7 +2734,7 @@ export default function Page() { terminal={pty} onClose={() => { view().terminal.close() - setAutoCreated(false) + setUi("autoCreated", false) }} /> )} diff --git a/packages/app/src/utils/speech.ts b/packages/app/src/utils/speech.ts index 921e0a159b..c8acf5241c 100644 --- a/packages/app/src/utils/speech.ts +++ b/packages/app/src/utils/speech.ts @@ -1,4 +1,5 @@ -import { createSignal, onCleanup } from "solid-js" +import { onCleanup } from "solid-js" +import { createStore } from "solid-js/store" // Minimal types to avoid relying on non-standard DOM typings type RecognitionResult = { @@ -59,9 +60,15 @@ export function createSpeechRecognition(opts?: { typeof window !== "undefined" && Boolean((window as any).webkitSpeechRecognition || (window as any).SpeechRecognition) - const [isRecording, setIsRecording] = createSignal(false) - const [committed, setCommitted] = createSignal("") - const [interim, setInterim] = createSignal("") + const [store, setStore] = createStore({ + isRecording: false, + committed: "", + interim: "", + }) + + const isRecording = () => store.isRecording + const committed = () => store.committed + const interim = () => store.interim let recognition: Recognition | undefined let shouldContinue = false @@ -82,7 +89,7 @@ export function createSpeechRecognition(opts?: { const nextCommitted = appendSegment(committedText, segment) if (nextCommitted === committedText) return committedText = nextCommitted - setCommitted(committedText) + setStore("committed", committedText) if (opts?.onFinal) opts.onFinal(segment.trim()) } @@ -98,7 +105,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = "" lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") } @@ -107,7 +114,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = hypothesis lastInterimSuffix = suffix shrinkCandidate = undefined - setInterim(suffix) + setStore("interim", suffix) if (opts?.onInterim) { opts.onInterim(suffix ? appendSegment(committedText, suffix) : "") } @@ -122,7 +129,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = "" lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") }, COMMIT_DELAY) } @@ -162,7 +169,7 @@ export function createSpeechRecognition(opts?: { pendingHypothesis = "" lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") return } @@ -211,7 +218,7 @@ export function createSpeechRecognition(opts?: { lastInterimSuffix = "" shrinkCandidate = undefined if (e.error === "no-speech" && shouldContinue) { - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") setTimeout(() => { try { @@ -221,7 +228,7 @@ export function createSpeechRecognition(opts?: { return } shouldContinue = false - setIsRecording(false) + setStore("isRecording", false) } recognition.onstart = () => { @@ -230,16 +237,16 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") - setIsRecording(true) + setStore("isRecording", true) } recognition.onend = () => { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setIsRecording(false) + setStore("isRecording", false) if (shouldContinue) { setTimeout(() => { try { @@ -258,7 +265,7 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") try { recognition.start() } catch {} @@ -271,7 +278,7 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") try { recognition.stop() @@ -284,7 +291,7 @@ export function createSpeechRecognition(opts?: { cancelPendingCommit() lastInterimSuffix = "" shrinkCandidate = undefined - setInterim("") + setStore("interim", "") if (opts?.onInterim) opts.onInterim("") try { recognition?.stop() From ec2ab639bb60ce42f84d74c04641dc4281e4a82c Mon Sep 17 00:00:00 2001 From: zerone0x Date: Tue, 27 Jan 2026 01:32:16 +0800 Subject: [PATCH 051/294] fix(enterprise): add message navigation to share page desktop view (#10071) Co-authored-by: Claude --- packages/enterprise/src/routes/share/[shareID].tsx | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/packages/enterprise/src/routes/share/[shareID].tsx b/packages/enterprise/src/routes/share/[shareID].tsx index 483db4d932..a2607891c8 100644 --- a/packages/enterprise/src/routes/share/[shareID].tsx +++ b/packages/enterprise/src/routes/share/[shareID].tsx @@ -20,6 +20,7 @@ import { createStore } from "solid-js/store" import z from "zod" import NotFound from "../[...404]" import { Tabs } from "@opencode-ai/ui/tabs" +import { MessageNav } from "@opencode-ai/ui/message-nav" import { preloadMultiFileDiff, PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { Diff as SSRDiff } from "@opencode-ai/ui/diff-ssr" import { clientOnly } from "@solidjs/start" @@ -362,6 +363,15 @@ export default function () { {title()}
+ 1}> + + Date: Mon, 26 Jan 2026 11:57:59 -0600 Subject: [PATCH 052/294] chore: fix changelog page --- packages/console/app/src/routes/changelog.json.ts | 15 +++++++++++++++ .../console/app/src/routes/changelog/index.tsx | 10 +++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts index 9e3b75e5cd..48dd985d7f 100644 --- a/packages/console/app/src/routes/changelog.json.ts +++ b/packages/console/app/src/routes/changelog.json.ts @@ -20,6 +20,12 @@ type HighlightGroup = { items: HighlightItem[] } +const cors = { + "Access-Control-Allow-Origin": "*", + "Access-Control-Allow-Methods": "GET, OPTIONS", + "Access-Control-Allow-Headers": "Content-Type, Authorization", +} + const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400" const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400" @@ -106,6 +112,7 @@ export async function GET() { headers: { "Content-Type": "application/json", "Cache-Control": error, + ...cors, }, }) @@ -134,7 +141,15 @@ export async function GET() { headers: { "Content-Type": "application/json", "Cache-Control": ok, + ...cors, }, }, ) } + +export async function OPTIONS() { + return new Response(null, { + status: 200, + headers: cors, + }) +} diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index 2e2818da50..c7dca97a76 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -31,11 +31,15 @@ type ChangelogRelease = { sections: { title: string; items: string[] }[] } -async function getReleases() { +function endpoint() { const event = getRequestEvent() - const url = event ? new URL("/changelog.json", event.request.url).toString() : "/changelog.json" + if (event) return new URL("/changelog.json", event.request.url).toString() + if (!import.meta.env.SSR) return "/changelog.json" + return `${config.baseUrl}/changelog.json` +} - const response = await fetch(url).catch(() => undefined) +async function getReleases() { + const response = await fetch(endpoint()).catch(() => undefined) if (!response?.ok) return [] const json = await response.json().catch(() => undefined) From 4c9d879624fe4233950fd2365cc190f60e074154 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:09:02 -0600 Subject: [PATCH 053/294] Revert "fix(app): restore external link opening in system browser (#10697)" This reverts commit 984518b1c0cb74db0b8eb9f77bb15fb97224a4e2. --- packages/desktop/src/index.tsx | 23 +++++++++-------------- 1 file changed, 9 insertions(+), 14 deletions(-) diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index b0ac833073..fe9e3f92e2 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -328,23 +328,18 @@ render(() => { const [serverPassword, setServerPassword] = createSignal(null) const platform = createPlatform(() => serverPassword()) - onMount(() => { - // Handle external links - open in system browser instead of webview - const handleClick = (e: MouseEvent) => { - const target = e.target as HTMLElement - const link = target.closest("a") as HTMLAnchorElement | null - - if (link?.href && !link.href.startsWith("javascript:") && !link.href.startsWith("#")) { - e.preventDefault() - e.stopPropagation() - e.stopImmediatePropagation() - void shellOpen(link.href).catch(() => undefined) - } + function handleClick(e: MouseEvent) { + const link = (e.target as HTMLElement).closest("a.external-link") as HTMLAnchorElement | null + if (link?.href) { + e.preventDefault() + platform.openLink(link.href) } + } - document.addEventListener("click", handleClick, true) + onMount(() => { + document.addEventListener("click", handleClick) onCleanup(() => { - document.removeEventListener("click", handleClick, true) + document.removeEventListener("click", handleClick) }) }) From 7795cae0b55c09544dcdcf3051cefa2d473efe86 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 12:51:48 -0500 Subject: [PATCH 054/294] ignore: tweak ai deps --- .opencode/command/ai-deps.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.opencode/command/ai-deps.md b/.opencode/command/ai-deps.md index 4d23c76a4d..83783d5b9b 100644 --- a/.opencode/command/ai-deps.md +++ b/.opencode/command/ai-deps.md @@ -7,7 +7,7 @@ Please read @package.json and @packages/opencode/package.json. Your job is to look into AI SDK dependencies, figure out if they have versions that can be upgraded (minor or patch versions ONLY no major ignore major changes). I want a report of every dependency and the version that can be upgraded to. -What would be even better is if you can give me links to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. +What would be even better is if you can give me brief summary of the changes for each dep and a link to the changelog for each dependency, or at least some reference info so I can see what bugs were fixed or new features were added. Consider using subagents for each dep to save your context window. From ac53a372b0b97fca93f887036f5957eddb88b606 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 13:17:59 -0500 Subject: [PATCH 055/294] feat: use anthropic compat messages api for anthropic models through copilot --- packages/opencode/src/plugin/copilot.ts | 22 ++++++++++++++++------ packages/opencode/src/provider/provider.ts | 9 +++++---- packages/opencode/src/session/llm.ts | 22 ++++++++++++++-------- 3 files changed, 35 insertions(+), 18 deletions(-) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 369acf800f..659090dd3a 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -26,6 +26,9 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { const info = await getAuth() if (!info || info.type !== "oauth") return {} + const enterpriseUrl = info.enterpriseUrl + const baseURL = enterpriseUrl ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` : undefined + if (provider && provider.models) { for (const model of Object.values(provider.models)) { model.cost = { @@ -36,16 +39,23 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { write: 0, }, } + + // TODO: move some of this hacky-ness to models.dev presets once we have better grasp of things here... + const base = baseURL ?? model.api.url + const claude = model.id.includes("claude") + const url = iife(() => { + if (!claude) return base + if (base.endsWith("/v1")) return base + if (base.endsWith("/")) return `${base}v1` + return `${base}/v1` + }) + + model.api.url = url + model.api.npm = claude ? "@ai-sdk/anthropic" : "@ai-sdk/github-copilot" } } - const enterpriseUrl = info.enterpriseUrl - const baseURL = enterpriseUrl - ? `https://copilot-api.${normalizeDomain(enterpriseUrl)}` - : "https://api.githubcopilot.com" - return { - baseURL, apiKey: "", async fetch(request: RequestInfo | URL, init?: RequestInit) { const info = await getAuth() diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index f898d3be43..ee7ee75c9f 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -132,6 +132,7 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { + if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, options: {}, @@ -141,6 +142,7 @@ export namespace Provider { return { autoload: false, async getModel(sdk: any, modelID: string, _options?: Record) { + if (sdk.responses === undefined && sdk.chat === undefined) return sdk.languageModel(modelID) return shouldUseCopilotResponsesApi(modelID) ? sdk.responses(modelID) : sdk.chat(modelID) }, options: {}, @@ -601,10 +603,7 @@ export namespace Provider { api: { id: model.id, url: provider.api!, - npm: iife(() => { - if (provider.id.startsWith("github-copilot")) return "@ai-sdk/github-copilot" - return model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible" - }), + npm: model.provider?.npm ?? provider.npm ?? "@ai-sdk/openai-compatible", }, status: model.status ?? "active", headers: model.headers ?? {}, @@ -924,6 +923,8 @@ export namespace Provider { ) delete provider.models[modelID] + model.variants = mapValues(ProviderTransform.variants(model), (v) => v) + // Filter out disabled variants from config const configVariants = configProvider?.models?.[modelID]?.variants if (configVariants && model.variants) { diff --git a/packages/opencode/src/session/llm.ts b/packages/opencode/src/session/llm.ts index 1e409b03fe..d651308032 100644 --- a/packages/opencode/src/session/llm.ts +++ b/packages/opencode/src/session/llm.ts @@ -150,14 +150,20 @@ export namespace LLM { }, ) - const maxOutputTokens = isCodex - ? undefined - : ProviderTransform.maxOutputTokens( - input.model.api.npm, - params.options, - input.model.limit.output, - OUTPUT_TOKEN_MAX, - ) + const maxOutputTokens = isCodex ? undefined : undefined + log.info("max_output_tokens", { + tokens: ProviderTransform.maxOutputTokens( + input.model.api.npm, + params.options, + input.model.limit.output, + OUTPUT_TOKEN_MAX, + ), + modelOptions: params.options, + outputLimit: input.model.limit.output, + }) + // tokens = 32000 + // outputLimit = 64000 + // modelOptions={"reasoningEffort":"minimal"} const tools = await resolveTools(input) From b0f865eae5a79742560ddea472fa8ba25871aa3d Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:21:24 -0600 Subject: [PATCH 056/294] chore: debug changelog --- .../console/app/src/routes/changelog.json.ts | 33 ++++- .../app/src/routes/changelog/index.tsx | 115 ++++++++++++++++-- 2 files changed, 136 insertions(+), 12 deletions(-) diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts index 48dd985d7f..f82c8421d9 100644 --- a/packages/console/app/src/routes/changelog.json.ts +++ b/packages/console/app/src/routes/changelog.json.ts @@ -104,7 +104,12 @@ export async function GET() { cacheTtl: 60 * 5, cacheEverything: true, }, - } as any).catch(() => undefined) + } as any).catch((err) => { + console.error("[changelog.json] fetch failed", { + error: err instanceof Error ? err.message : String(err), + }) + return undefined + }) const fail = () => new Response(JSON.stringify({ releases: [] }), { @@ -116,10 +121,30 @@ export async function GET() { }, }) - if (!response?.ok) return fail() + if (!response) return fail() + if (!response.ok) { + const body = await response.text().catch(() => undefined) + console.error("[changelog.json] github non-ok", { + status: response.status, + remaining: response.headers.get("x-ratelimit-remaining"), + reset: response.headers.get("x-ratelimit-reset"), + body: body?.slice(0, 300), + }) + return fail() + } - const data = await response.json().catch(() => undefined) - if (!Array.isArray(data)) return fail() + const data = await response.json().catch((err) => { + console.error("[changelog.json] json parse failed", { + error: err instanceof Error ? err.message : String(err), + }) + return undefined + }) + if (!Array.isArray(data)) { + console.error("[changelog.json] invalid json", { + type: typeof data, + }) + return fail() + } const releases = data as Release[] diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index c7dca97a76..ec5cd4e9d8 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -1,11 +1,11 @@ import "./index.css" import { Title, Meta, Link } from "@solidjs/meta" -import { createAsync } from "@solidjs/router" +import { createAsync, useSearchParams } from "@solidjs/router" import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { config } from "~/config" -import { For, Show, createSignal } from "solid-js" +import { For, Show, createSignal, onMount } from "solid-js" import { getRequestEvent } from "solid-js/web" type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } @@ -31,6 +31,21 @@ type ChangelogRelease = { sections: { title: string; items: string[] }[] } +type LoadMeta = { + endpoint: string + ssr: boolean + hasEvent: boolean + ok: boolean + status?: number + contentType?: string + error?: string +} + +type Load = { + releases: ChangelogRelease[] + meta: LoadMeta +} + function endpoint() { const event = getRequestEvent() if (event) return new URL("/changelog.json", event.request.url).toString() @@ -38,12 +53,64 @@ function endpoint() { return `${config.baseUrl}/changelog.json` } -async function getReleases() { - const response = await fetch(endpoint()).catch(() => undefined) - if (!response?.ok) return [] +async function getReleases(debug = false): Promise { + const url = endpoint() + const meta = { + endpoint: url, + ssr: import.meta.env.SSR, + hasEvent: Boolean(getRequestEvent()), + ok: false, + } satisfies LoadMeta - const json = await response.json().catch(() => undefined) - return Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : [] + const response = await fetch(url).catch((err) => { + console.error("[changelog] fetch failed", { + ...meta, + error: err instanceof Error ? err.message : String(err), + }) + return undefined + }) + + if (!response) return { releases: [], meta: { ...meta, error: "fetch_failed" } } + if (!response.ok) { + const contentType = response.headers.get("content-type") ?? undefined + const body = debug ? await response.text().catch(() => undefined) : undefined + console.error("[changelog] fetch non-ok", { + ...meta, + status: response.status, + contentType, + body: body?.slice(0, 300), + }) + return { releases: [], meta: { ...meta, status: response.status, contentType, error: "bad_status" } } + } + + const contentType = response.headers.get("content-type") ?? undefined + const copy = debug ? response.clone() : undefined + const json = await response.json().catch(async (err) => { + const body = copy ? await copy.text().catch(() => undefined) : undefined + console.error("[changelog] json parse failed", { + ...meta, + status: response.status, + contentType, + error: err instanceof Error ? err.message : String(err), + body: body?.slice(0, 300), + }) + return undefined + }) + + const releases = Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : [] + if (!releases.length) { + console.error("[changelog] empty releases", { + ...meta, + status: response.status, + contentType, + keys: json && typeof json === "object" ? Object.keys(json) : undefined, + }) + } + + return { + releases, + meta: { ...meta, ok: true, status: response.status, contentType }, + } } function formatDate(dateString: string) { @@ -134,7 +201,22 @@ function CollapsibleSections(props: { sections: { title: string; items: string[] } export default function Changelog() { - const releases = createAsync(() => getReleases()) + const [params] = useSearchParams() + const debug = () => params.debug === "1" + const data = createAsync(() => getReleases(debug())) + const [client, setClient] = createSignal(undefined) + const releases = () => client()?.releases ?? data()?.releases ?? [] + + onMount(() => { + queueMicrotask(async () => { + const server = data()?.releases + if (!server) return + if (server.length) return + + const next = await getReleases(debug()) + setClient(next) + }) + }) return (
@@ -152,6 +234,23 @@ export default function Changelog() {
+ +

+ No changelog entries found. View JSON +

+ + +
+                {JSON.stringify(
+                  {
+                    server: data()?.meta,
+                    client: client()?.meta,
+                  },
+                  null,
+                  2,
+                )}
+              
+
{(release) => { return ( From 837037cd04d94841cfa470eb8639f2397bd3ba87 Mon Sep 17 00:00:00 2001 From: Tim Date: Tue, 27 Jan 2026 07:50:04 +1300 Subject: [PATCH 057/294] fix: ensure openai 404 errors are retried (#10590) --- packages/opencode/src/session/message-v2.ts | 9 ++++++++- packages/opencode/test/session/retry.test.ts | 15 +++++++++++++++ 2 files changed, 23 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/message-v2.ts b/packages/opencode/src/session/message-v2.ts index 7c8689037e..650fda6e94 100644 --- a/packages/opencode/src/session/message-v2.ts +++ b/packages/opencode/src/session/message-v2.ts @@ -656,6 +656,13 @@ export namespace MessageV2 { return result } + const isOpenAiErrorRetryable = (e: APICallError) => { + const status = e.statusCode + if (!status) return e.isRetryable + // openai sometimes returns 404 for models that are actually available + return status === 404 || e.isRetryable + } + export function fromError(e: unknown, ctx: { providerID: string }) { switch (true) { case e instanceof DOMException && e.name === "AbortError": @@ -724,7 +731,7 @@ export namespace MessageV2 { { message, statusCode: e.statusCode, - isRetryable: e.isRetryable, + isRetryable: ctx.providerID.startsWith("openai") ? isOpenAiErrorRetryable(e) : e.isRetryable, responseHeaders: e.responseHeaders, responseBody: e.responseBody, metadata, diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index b130e927e4..10137ed988 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import { APICallError } from "ai" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -128,4 +129,18 @@ describe("session.message-v2.fromError", () => { expect(retryable).toBeDefined() expect(retryable).toBe("Connection reset by server") }) + + test("marks OpenAI 404 status codes as retryable", () => { + const error = new APICallError({ + message: "boom", + url: "https://api.openai.com/v1/chat/completions", + requestBodyValues: {}, + statusCode: 404, + responseHeaders: { "content-type": "application/json" }, + responseBody: "{\"error\":\"boom\"}", + isRetryable: false, + }) + const result = MessageV2.fromError(error, { providerID: "openai" }) as MessageV2.APIError + expect(result.data.isRetryable).toBe(true) + }) }) From 18bfc740c839e9a6b931c7c5d0ed1a8bec98c904 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 26 Jan 2026 18:50:58 +0000 Subject: [PATCH 058/294] chore: generate --- packages/opencode/test/session/retry.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 10137ed988..22ffb0cb11 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -137,7 +137,7 @@ describe("session.message-v2.fromError", () => { requestBodyValues: {}, statusCode: 404, responseHeaders: { "content-type": "application/json" }, - responseBody: "{\"error\":\"boom\"}", + responseBody: '{"error":"boom"}', isRetryable: false, }) const result = MessageV2.fromError(error, { providerID: "openai" }) as MessageV2.APIError From 8b17ac656cb428b1eee5f425deb195dc61f844ba Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:44:17 -0600 Subject: [PATCH 059/294] test(app): e2e test for sidebar nav --- .../app/e2e/sidebar-session-links.spec.ts | 61 +++++++++++++++++++ 1 file changed, 61 insertions(+) create mode 100644 packages/app/e2e/sidebar-session-links.spec.ts diff --git a/packages/app/e2e/sidebar-session-links.spec.ts b/packages/app/e2e/sidebar-session-links.spec.ts new file mode 100644 index 0000000000..fab64736e2 --- /dev/null +++ b/packages/app/e2e/sidebar-session-links.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "./fixtures" +import { modKey, promptSelector } from "./utils" + +type Locator = { + first: () => Locator + getAttribute: (name: string) => Promise + scrollIntoViewIfNeeded: () => Promise + click: () => Promise +} + +type Page = { + locator: (selector: string) => Locator + keyboard: { + press: (key: string) => Promise + } +} + +type Fixtures = { + page: Page + slug: string + sdk: { + session: { + create: (input: { title: string }) => Promise<{ data?: { id?: string } }> + delete: (input: { sessionID: string }) => Promise + } + } + gotoSession: (sessionID?: string) => Promise +} + +test("sidebar session links navigate to the selected session", async ({ page, slug, sdk, gotoSession }: Fixtures) => { + const stamp = Date.now() + + const one = await sdk.session.create({ title: `e2e sidebar nav 1 ${stamp}` }).then((r) => r.data) + const two = await sdk.session.create({ title: `e2e sidebar nav 2 ${stamp}` }).then((r) => r.data) + + if (!one?.id) throw new Error("Session create did not return an id") + if (!two?.id) throw new Error("Session create did not return an id") + + try { + await gotoSession(one.id) + + const main = page.locator("main") + const collapsed = ((await main.getAttribute("class")) ?? "").includes("xl:border-l") + if (collapsed) { + await page.keyboard.press(`${modKey}+B`) + await expect(main).not.toHaveClass(/xl:border-l/) + } + + const target = page.locator(`[data-session-id="${two.id}"] a`).first() + await expect(target).toBeVisible() + await target.scrollIntoViewIfNeeded() + await target.click() + + await expect(page).toHaveURL(new RegExp(`/${slug}/session/${two.id}(?:\\?|#|$)`)) + await expect(page.locator(promptSelector)).toBeVisible() + await expect(page.locator(`[data-session-id="${two.id}"] a`).first()).toHaveClass(/\bactive\b/) + } finally { + await sdk.session.delete({ sessionID: one.id }).catch(() => undefined) + await sdk.session.delete({ sessionID: two.id }).catch(() => undefined) + } +}) From de3b654dcd195448f1d19e55562b2d84a2db7c91 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:51:32 -0600 Subject: [PATCH 060/294] chore: refactor changelog --- packages/console/app/src/lib/changelog.ts | 146 +++++++++++++++ .../console/app/src/routes/changelog.json.ts | 166 +----------------- .../app/src/routes/changelog/index.tsx | 142 +-------------- 3 files changed, 160 insertions(+), 294 deletions(-) create mode 100644 packages/console/app/src/lib/changelog.ts diff --git a/packages/console/app/src/lib/changelog.ts b/packages/console/app/src/lib/changelog.ts new file mode 100644 index 0000000000..93a0d423c6 --- /dev/null +++ b/packages/console/app/src/lib/changelog.ts @@ -0,0 +1,146 @@ +import { query } from "@solidjs/router" + +type Release = { + tag_name: string + name: string + body: string + published_at: string + html_url: string +} + +export type HighlightMedia = + | { type: "video"; src: string } + | { type: "image"; src: string; width: string; height: string } + +export type HighlightItem = { + title: string + description: string + shortDescription?: string + media: HighlightMedia +} + +export type HighlightGroup = { + source: string + items: HighlightItem[] +} + +export type ChangelogRelease = { + tag: string + name: string + date: string + url: string + highlights: HighlightGroup[] + sections: { title: string; items: string[] }[] +} + +export type ChangelogData = { + ok: boolean + releases: ChangelogRelease[] +} + +export async function loadChangelog(): Promise { + const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", { + headers: { + Accept: "application/vnd.github.v3+json", + "User-Agent": "OpenCode-Console", + }, + cf: { + // best-effort edge caching (ignored outside Cloudflare) + cacheTtl: 60 * 5, + cacheEverything: true, + }, + } as RequestInit).catch(() => undefined) + + if (!response?.ok) return { ok: false, releases: [] } + + const data = await response.json().catch(() => undefined) + if (!Array.isArray(data)) return { ok: false, releases: [] } + + const releases = (data as Release[]).map((release) => { + const parsed = parseMarkdown(release.body || "") + return { + tag: release.tag_name, + name: release.name, + date: release.published_at, + url: release.html_url, + highlights: parsed.highlights, + sections: parsed.sections, + } + }) + + return { ok: true, releases } +} + +export const changelog = query(async () => { + "use server" + const result = await loadChangelog() + return result.releases +}, "changelog") + +function parseHighlights(body: string): HighlightGroup[] { + const groups = new Map() + const regex = /([\s\S]*?)<\/highlight>/g + let match + + while ((match = regex.exec(body)) !== null) { + const source = match[1] + const content = match[2] + + const titleMatch = content.match(/

([^<]+)<\/h2>/) + const pMatch = content.match(/([^<]+)<\/p>/) + const imgMatch = content.match(/ { + if (videoMatch) return { type: "video", src: videoMatch[1] } satisfies HighlightMedia + if (imgMatch) { + return { + type: "image", + src: imgMatch[3], + width: imgMatch[1], + height: imgMatch[2], + } satisfies HighlightMedia + } + })() + + if (!titleMatch || !media) continue + + const item: HighlightItem = { + title: titleMatch[1], + description: pMatch?.[2] || "", + shortDescription: pMatch?.[1], + media, + } + + if (!groups.has(source)) groups.set(source, []) + groups.get(source)!.push(item) + } + + return Array.from(groups.entries()).map(([source, items]) => ({ source, items })) +} + +function parseMarkdown(body: string) { + const lines = body.split("\n") + const sections: { title: string; items: string[] }[] = [] + let current: { title: string; items: string[] } | null = null + let skip = false + + for (const line of lines) { + if (line.startsWith("## ")) { + if (current) sections.push(current) + current = { title: line.slice(3).trim(), items: [] } + skip = false + continue + } + + if (line.startsWith("**Thank you")) { + skip = true + continue + } + + if (line.startsWith("- ") && !skip) current?.items.push(line.slice(2).trim()) + } + + if (current) sections.push(current) + return { sections, highlights: parseHighlights(body) } +} diff --git a/packages/console/app/src/routes/changelog.json.ts b/packages/console/app/src/routes/changelog.json.ts index f82c8421d9..f06c1be9b4 100644 --- a/packages/console/app/src/routes/changelog.json.ts +++ b/packages/console/app/src/routes/changelog.json.ts @@ -1,24 +1,4 @@ -type Release = { - tag_name: string - name: string - body: string - published_at: string - html_url: string -} - -type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } - -type HighlightItem = { - title: string - description: string - shortDescription?: string - media: HighlightMedia -} - -type HighlightGroup = { - source: string - items: HighlightItem[] -} +import { loadChangelog } from "~/lib/changelog" const cors = { "Access-Control-Allow-Origin": "*", @@ -29,147 +9,17 @@ const cors = { const ok = "public, max-age=1, s-maxage=300, stale-while-revalidate=86400, stale-if-error=86400" const error = "public, max-age=1, s-maxage=60, stale-while-revalidate=600, stale-if-error=86400" -function parseHighlights(body: string): HighlightGroup[] { - const groups = new Map() - const regex = /([\s\S]*?)<\/highlight>/g - let match - - while ((match = regex.exec(body)) !== null) { - const source = match[1] - const content = match[2] - - const titleMatch = content.match(/

([^<]+)<\/h2>/) - const pMatch = content.match(/([^<]+)<\/p>/) - const imgMatch = content.match(/ ({ source, items })) -} - -function parseMarkdown(body: string) { - const lines = body.split("\n") - const sections: { title: string; items: string[] }[] = [] - let current: { title: string; items: string[] } | null = null - let skip = false - - for (const line of lines) { - if (line.startsWith("## ")) { - if (current) sections.push(current) - const title = line.slice(3).trim() - current = { title, items: [] } - skip = false - } else if (line.startsWith("**Thank you")) { - skip = true - } else if (line.startsWith("- ") && !skip) { - current?.items.push(line.slice(2).trim()) - } - } - if (current) sections.push(current) - - const highlights = parseHighlights(body) - - return { sections, highlights } -} - export async function GET() { - const response = await fetch("https://api.github.com/repos/anomalyco/opencode/releases?per_page=20", { + const result = await loadChangelog().catch(() => ({ ok: false, releases: [] })) + + return new Response(JSON.stringify({ releases: result.releases }), { + status: result.ok ? 200 : 503, headers: { - Accept: "application/vnd.github.v3+json", - "User-Agent": "OpenCode-Console", + "Content-Type": "application/json", + "Cache-Control": result.ok ? ok : error, + ...cors, }, - cf: { - // best-effort edge caching (ignored outside Cloudflare) - cacheTtl: 60 * 5, - cacheEverything: true, - }, - } as any).catch((err) => { - console.error("[changelog.json] fetch failed", { - error: err instanceof Error ? err.message : String(err), - }) - return undefined }) - - const fail = () => - new Response(JSON.stringify({ releases: [] }), { - status: 503, - headers: { - "Content-Type": "application/json", - "Cache-Control": error, - ...cors, - }, - }) - - if (!response) return fail() - if (!response.ok) { - const body = await response.text().catch(() => undefined) - console.error("[changelog.json] github non-ok", { - status: response.status, - remaining: response.headers.get("x-ratelimit-remaining"), - reset: response.headers.get("x-ratelimit-reset"), - body: body?.slice(0, 300), - }) - return fail() - } - - const data = await response.json().catch((err) => { - console.error("[changelog.json] json parse failed", { - error: err instanceof Error ? err.message : String(err), - }) - return undefined - }) - if (!Array.isArray(data)) { - console.error("[changelog.json] invalid json", { - type: typeof data, - }) - return fail() - } - - const releases = data as Release[] - - return new Response( - JSON.stringify({ - releases: releases.map((release) => { - const parsed = parseMarkdown(release.body || "") - return { - tag: release.tag_name, - name: release.name, - date: release.published_at, - url: release.html_url, - highlights: parsed.highlights, - sections: parsed.sections, - } - }), - }), - { - headers: { - "Content-Type": "application/json", - "Cache-Control": ok, - ...cors, - }, - }, - ) } export async function OPTIONS() { diff --git a/packages/console/app/src/routes/changelog/index.tsx b/packages/console/app/src/routes/changelog/index.tsx index ec5cd4e9d8..dff0a427f7 100644 --- a/packages/console/app/src/routes/changelog/index.tsx +++ b/packages/console/app/src/routes/changelog/index.tsx @@ -1,117 +1,13 @@ import "./index.css" import { Title, Meta, Link } from "@solidjs/meta" -import { createAsync, useSearchParams } from "@solidjs/router" +import { createAsync } from "@solidjs/router" import { Header } from "~/component/header" import { Footer } from "~/component/footer" import { Legal } from "~/component/legal" import { config } from "~/config" -import { For, Show, createSignal, onMount } from "solid-js" -import { getRequestEvent } from "solid-js/web" - -type HighlightMedia = { type: "video"; src: string } | { type: "image"; src: string; width: string; height: string } - -type HighlightItem = { - title: string - description: string - shortDescription?: string - media: HighlightMedia -} - -type HighlightGroup = { - source: string - items: HighlightItem[] -} - -type ChangelogRelease = { - tag: string - name: string - date: string - url: string - highlights: HighlightGroup[] - sections: { title: string; items: string[] }[] -} - -type LoadMeta = { - endpoint: string - ssr: boolean - hasEvent: boolean - ok: boolean - status?: number - contentType?: string - error?: string -} - -type Load = { - releases: ChangelogRelease[] - meta: LoadMeta -} - -function endpoint() { - const event = getRequestEvent() - if (event) return new URL("/changelog.json", event.request.url).toString() - if (!import.meta.env.SSR) return "/changelog.json" - return `${config.baseUrl}/changelog.json` -} - -async function getReleases(debug = false): Promise { - const url = endpoint() - const meta = { - endpoint: url, - ssr: import.meta.env.SSR, - hasEvent: Boolean(getRequestEvent()), - ok: false, - } satisfies LoadMeta - - const response = await fetch(url).catch((err) => { - console.error("[changelog] fetch failed", { - ...meta, - error: err instanceof Error ? err.message : String(err), - }) - return undefined - }) - - if (!response) return { releases: [], meta: { ...meta, error: "fetch_failed" } } - if (!response.ok) { - const contentType = response.headers.get("content-type") ?? undefined - const body = debug ? await response.text().catch(() => undefined) : undefined - console.error("[changelog] fetch non-ok", { - ...meta, - status: response.status, - contentType, - body: body?.slice(0, 300), - }) - return { releases: [], meta: { ...meta, status: response.status, contentType, error: "bad_status" } } - } - - const contentType = response.headers.get("content-type") ?? undefined - const copy = debug ? response.clone() : undefined - const json = await response.json().catch(async (err) => { - const body = copy ? await copy.text().catch(() => undefined) : undefined - console.error("[changelog] json parse failed", { - ...meta, - status: response.status, - contentType, - error: err instanceof Error ? err.message : String(err), - body: body?.slice(0, 300), - }) - return undefined - }) - - const releases = Array.isArray(json?.releases) ? (json.releases as ChangelogRelease[]) : [] - if (!releases.length) { - console.error("[changelog] empty releases", { - ...meta, - status: response.status, - contentType, - keys: json && typeof json === "object" ? Object.keys(json) : undefined, - }) - } - - return { - releases, - meta: { ...meta, ok: true, status: response.status, contentType }, - } -} +import { changelog } from "~/lib/changelog" +import type { HighlightGroup } from "~/lib/changelog" +import { For, Show, createSignal } from "solid-js" function formatDate(dateString: string) { const date = new Date(dateString) @@ -201,22 +97,8 @@ function CollapsibleSections(props: { sections: { title: string; items: string[] } export default function Changelog() { - const [params] = useSearchParams() - const debug = () => params.debug === "1" - const data = createAsync(() => getReleases(debug())) - const [client, setClient] = createSignal(undefined) - const releases = () => client()?.releases ?? data()?.releases ?? [] - - onMount(() => { - queueMicrotask(async () => { - const server = data()?.releases - if (!server) return - if (server.length) return - - const next = await getReleases(debug()) - setClient(next) - }) - }) + const data = createAsync(() => changelog()) + const releases = () => data() ?? [] return (
@@ -239,18 +121,6 @@ export default function Changelog() { No changelog entries found. View JSON

- -
-                {JSON.stringify(
-                  {
-                    server: data()?.meta,
-                    client: client()?.meta,
-                  },
-                  null,
-                  2,
-                )}
-              
-
{(release) => { return ( From 319ad2a3916862adaedcc52a8b88d8e131516b1f Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:58:46 -0600 Subject: [PATCH 061/294] fix(app): session load cap --- packages/app/src/context/global-sync.tsx | 121 ++++++++++++++++------- packages/app/src/pages/layout.tsx | 48 ++++++++- 2 files changed, 129 insertions(+), 40 deletions(-) diff --git a/packages/app/src/context/global-sync.tsx b/packages/app/src/context/global-sync.tsx index 37d4d68912..15a9205841 100644 --- a/packages/app/src/context/global-sync.tsx +++ b/packages/app/src/context/global-sync.tsx @@ -225,6 +225,65 @@ function createGlobalSync() { const sessionLoads = new Map>() const sessionMeta = new Map() + const sessionRecentWindow = 4 * 60 * 60 * 1000 + const sessionRecentLimit = 50 + + function sessionUpdatedAt(session: Session) { + return session.time.updated ?? session.time.created + } + + function compareSessionRecent(a: Session, b: Session) { + const aUpdated = sessionUpdatedAt(a) + const bUpdated = sessionUpdatedAt(b) + if (aUpdated !== bUpdated) return bUpdated - aUpdated + return a.id.localeCompare(b.id) + } + + function takeRecentSessions(sessions: Session[], limit: number, cutoff: number) { + if (limit <= 0) return [] as Session[] + const selected: Session[] = [] + const seen = new Set() + for (const session of sessions) { + if (!session?.id) continue + if (seen.has(session.id)) continue + seen.add(session.id) + + if (sessionUpdatedAt(session) <= cutoff) continue + + const index = selected.findIndex((x) => compareSessionRecent(session, x) < 0) + if (index === -1) selected.push(session) + if (index !== -1) selected.splice(index, 0, session) + if (selected.length > limit) selected.pop() + } + return selected + } + + function trimSessions(input: Session[], options: { limit: number; permission: Record }) { + const limit = Math.max(0, options.limit) + const cutoff = Date.now() - sessionRecentWindow + const all = input + .filter((s) => !!s?.id) + .filter((s) => !s.time?.archived) + .sort((a, b) => a.id.localeCompare(b.id)) + + const roots = all.filter((s) => !s.parentID) + const children = all.filter((s) => !!s.parentID) + + const base = roots.slice(0, limit) + const recent = takeRecentSessions(roots.slice(limit), sessionRecentLimit, cutoff) + const keepRoots = [...base, ...recent] + + const keepRootIds = new Set(keepRoots.map((s) => s.id)) + const keepChildren = children.filter((s) => { + if (s.parentID && keepRootIds.has(s.parentID)) return true + const perms = options.permission[s.id] ?? [] + if (perms.length > 0) return true + return sessionUpdatedAt(s) > cutoff + }) + + return [...keepRoots, ...keepChildren].sort((a, b) => a.id.localeCompare(b.id)) + } + function ensureChild(directory: string) { if (!directory) console.error("No directory provided") if (!children[directory]) { @@ -323,7 +382,13 @@ function createGlobalSync() { const [store, setStore] = child(directory, { bootstrap: false }) const meta = sessionMeta.get(directory) - if (meta && meta.limit >= store.limit) return + if (meta && meta.limit >= store.limit) { + const next = trimSessions(store.session, { limit: store.limit, permission: store.permission }) + if (next.length !== store.session.length) { + setStore("session", reconcile(next, { key: "id" })) + } + return + } const promise = globalSDK.client.session .list({ directory, roots: true }) @@ -337,21 +402,9 @@ function createGlobalSync() { // a request is in-flight still get the expanded result. const limit = store.limit - const sandboxWorkspace = globalStore.project.some((p) => (p.sandboxes ?? []).includes(directory)) - if (sandboxWorkspace) { - setStore("sessionTotal", nonArchived.length) - setStore("session", reconcile(nonArchived, { key: "id" })) - sessionMeta.set(directory, { limit }) - return - } + const children = store.session.filter((s) => !!s.parentID) + const sessions = trimSessions([...nonArchived, ...children], { limit, permission: store.permission }) - const fourHoursAgo = Date.now() - 4 * 60 * 60 * 1000 - // Include up to the limit, plus any updated in the last 4 hours - const sessions = nonArchived.filter((s, i) => { - if (i < limit) return true - const updated = new Date(s.time?.updated ?? s.time?.created).getTime() - return updated > fourHoursAgo - }) // Store total session count (used for "load more" pagination) setStore("sessionTotal", nonArchived.length) setStore("session", reconcile(sessions, { key: "id" })) @@ -536,25 +589,25 @@ function createGlobalSync() { break } case "session.created": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) + const info = event.properties.info + const result = Binary.search(store.session, info.id, (s) => s.id) if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) + setStore("session", result.index, reconcile(info)) break } - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) - if (!event.properties.info.parentID) { - setStore("sessionTotal", store.sessionTotal + 1) + const next = store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission }) + setStore("session", reconcile(trimmed, { key: "id" })) + if (!info.parentID) { + setStore("sessionTotal", (value) => value + 1) } break } case "session.updated": { - const result = Binary.search(store.session, event.properties.info.id, (s) => s.id) - if (event.properties.info.time.archived) { + const info = event.properties.info + const result = Binary.search(store.session, info.id, (s) => s.id) + if (info.time.archived) { if (result.found) { setStore( "session", @@ -563,20 +616,18 @@ function createGlobalSync() { }), ) } - if (event.properties.info.parentID) break + if (info.parentID) break setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } if (result.found) { - setStore("session", result.index, reconcile(event.properties.info)) + setStore("session", result.index, reconcile(info)) break } - setStore( - "session", - produce((draft) => { - draft.splice(result.index, 0, event.properties.info) - }), - ) + const next = store.session.slice() + next.splice(result.index, 0, info) + const trimmed = trimSessions(next, { limit: store.limit, permission: store.permission }) + setStore("session", reconcile(trimmed, { key: "id" })) break } case "session.deleted": { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index fd6f6e527d..b13cb1ac34 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -1592,6 +1592,7 @@ export default function Layout(props: ParentProps) { mobile?: boolean dense?: boolean popover?: boolean + children?: Map }): JSX.Element => { const notification = useNotification() const notifications = createMemo(() => notification.session.unseen(props.session.id)) @@ -1600,6 +1601,16 @@ export default function Layout(props: ParentProps) { 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] ?? [] @@ -1898,6 +1909,19 @@ export default function Layout(props: ParentProps) { .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 local = createMemo(() => props.directory === props.project.worktree) const active = createMemo(() => { const current = params.dir ? base64Decode(params.dir) : "" @@ -1911,10 +1935,9 @@ export default function Layout(props: ParentProps) { const open = createMemo(() => store.workspaceExpanded[props.directory] ?? local()) const boot = createMemo(() => open() || active()) const loading = createMemo(() => open() && workspaceStore.status !== "complete" && sessions().length === 0) - const hasMore = createMemo(() => local() && workspaceStore.sessionTotal > workspaceStore.session.length) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const busy = createMemo(() => isBusy(props.directory)) const loadMore = async () => { - if (!local()) return setWorkspaceStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.directory) } @@ -2073,7 +2096,9 @@ export default function Layout(props: ParentProps) { - {(session) => } + {(session) => ( + + )}
@@ -2288,8 +2313,21 @@ export default function Layout(props: ParentProps) { .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 loading = createMemo(() => workspaceStore.status !== "complete" && sessions().length === 0) - const hasMore = createMemo(() => workspaceStore.sessionTotal > workspaceStore.session.length) + const hasMore = createMemo(() => workspaceStore.sessionTotal > sessions().length) const loadMore = async () => { setWorkspaceStore("limit", (limit) => limit + 5) await globalSync.project.loadSessions(props.project.worktree) @@ -2308,7 +2346,7 @@ export default function Layout(props: ParentProps) { - {(session) => } + {(session) => }
From 97aec21cb3e74cabcae77fd89a2e8be67376b3a5 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 12:18:10 -0600 Subject: [PATCH 062/294] chore(app): missing i18n strings --- .../app/src/components/dialog-settings.tsx | 2 +- packages/app/src/components/prompt-input.tsx | 10 +- .../src/components/session/session-header.tsx | 4 +- packages/app/src/context/file.tsx | 2 +- packages/app/src/i18n/en.ts | 20 ++ packages/app/src/pages/session.tsx | 191 ++++++++++-------- packages/ui/src/components/line-comment.tsx | 21 +- packages/ui/src/components/session-review.tsx | 4 +- packages/ui/src/i18n/en.ts | 9 + 9 files changed, 161 insertions(+), 102 deletions(-) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index 5efee5a3c6..dbae37c9d8 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -50,7 +50,7 @@ export const DialogSettings: Component = () => {
- OpenCode Desktop + {language.t("app.name.desktop")} v{platform.version}

diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 5ec0eb1ea0..84f16d67ee 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -1565,7 +1565,7 @@ export const PromptInput: Component = (props) => { const timeoutMs = 5 * 60 * 1000 const timeout = new Promise>>((resolve) => { setTimeout(() => { - resolve({ status: "failed", message: "Workspace is still preparing" }) + resolve({ status: "failed", message: language.t("workspace.error.stillPreparing") }) }, timeoutMs) }) @@ -1863,9 +1863,9 @@ export const PromptInput: Component = (props) => { store.mode === "shell" ? language.t("prompt.placeholder.shell") : commentCount() > 1 - ? "Summarize comments…" + ? language.t("prompt.placeholder.summarizeComments") : commentCount() === 1 - ? "Summarize comment…" + ? language.t("prompt.placeholder.summarizeComment") : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) }) } contenteditable="true" @@ -1887,9 +1887,9 @@ export const PromptInput: Component = (props) => { {store.mode === "shell" ? language.t("prompt.placeholder.shell") : commentCount() > 1 - ? "Summarize comments…" + ? language.t("prompt.placeholder.summarizeComments") : commentCount() === 1 - ? "Summarize comment…" + ? language.t("prompt.placeholder.summarizeComment") : language.t("prompt.placeholder.normal", { example: language.t(EXAMPLES[store.placeholder]) })}
diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 8480e60607..9db0cdcecb 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -281,7 +281,7 @@ export function SessionHeader() {
@@ -1777,7 +1777,9 @@ export default function Page() { sync.session.history.loadMore(id) }} > - {historyLoading() ? "Loading earlier messages..." : "Load earlier messages"} + {historyLoading() + ? language.t("session.messages.loadingEarlier") + : language.t("session.messages.loadEarlier")}
@@ -1911,7 +1913,7 @@ export default function Page() { when={prompt.ready()} fallback={
- {handoff.prompt || "Loading prompt..."} + {handoff.prompt || language.t("prompt.loading")}
} > @@ -2057,7 +2059,7 @@ export default function Page() {
- No changes in this session yet + {language.t("session.review.empty")}
@@ -2071,7 +2073,9 @@ export default function Page() {
-
Select a file to open
+
+ {language.t("session.files.selectToOpen")} +
@@ -2609,7 +2613,9 @@ export default function Page() {
-
No changes in this session yet
+
+ {language.t("session.review.empty")} +
@@ -2624,10 +2630,11 @@ export default function Page() { - {reviewCount()} {reviewCount() === 1 ? "Change" : "Changes"} + {reviewCount()}{" "} + {language.t(reviewCount() === 1 ? "session.review.change.one" : "session.review.change.other")} - All files + {language.t("session.files.all")} @@ -2635,7 +2642,12 @@ export default function Page() { Loading...
} + fallback={ +
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+ } > -
No changes
+
+ {language.t("session.review.noChanges")} +
@@ -2702,9 +2716,14 @@ export default function Page() { )}
-
Loading...
+
+ {language.t("common.loading")} + {language.t("common.loading.ellipsis")} +
+
+
+ {language.t("terminal.loading")}
-
Loading terminal...
} > diff --git a/packages/ui/src/components/line-comment.tsx b/packages/ui/src/components/line-comment.tsx index f8869748cb..81e4759b01 100644 --- a/packages/ui/src/components/line-comment.tsx +++ b/packages/ui/src/components/line-comment.tsx @@ -1,6 +1,7 @@ import { onMount, Show, splitProps, type JSX } from "solid-js" import { Button } from "./button" import { Icon } from "./icon" +import { useI18n } from "../context/i18n" export type LineCommentVariant = "default" | "editor" @@ -60,13 +61,18 @@ export type LineCommentProps = Omit { + const i18n = useI18n() const [split, rest] = splitProps(props, ["comment", "selection"]) return (
{split.comment}
-
Comment on {split.selection}
+
+ {i18n.t("ui.lineComment.label.prefix")} + {split.selection} + {i18n.t("ui.lineComment.label.suffix")} +
) @@ -86,6 +92,7 @@ export type LineCommentEditorProps = Omit { + const i18n = useI18n() const [split, rest] = splitProps(props, [ "value", "selection", @@ -125,7 +132,7 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { }} data-slot="line-comment-textarea" rows={split.rows ?? 3} - placeholder={split.placeholder ?? "Add comment"} + placeholder={split.placeholder ?? i18n.t("ui.lineComment.placeholder")} value={split.value} onInput={(e) => split.onInput(e.currentTarget.value)} onKeyDown={(e) => { @@ -143,12 +150,16 @@ export const LineCommentEditor = (props: LineCommentEditorProps) => { }} />
-
Commenting on {split.selection}
+
+ {i18n.t("ui.lineComment.editorLabel.prefix")} + {split.selection} + {i18n.t("ui.lineComment.editorLabel.suffix")} +
diff --git a/packages/ui/src/components/session-review.tsx b/packages/ui/src/components/session-review.tsx index 9a337c4538..8dfbbb1ca6 100644 --- a/packages/ui/src/components/session-review.tsx +++ b/packages/ui/src/components/session-review.tsx @@ -530,12 +530,12 @@ export const SessionReview = (props: SessionReviewProps) => { - Added + {i18n.t("ui.sessionReview.change.added")} - Removed + {i18n.t("ui.sessionReview.change.removed")} diff --git a/packages/ui/src/i18n/en.ts b/packages/ui/src/i18n/en.ts index 502d047f7f..da77b04aa5 100644 --- a/packages/ui/src/i18n/en.ts +++ b/packages/ui/src/i18n/en.ts @@ -4,6 +4,15 @@ export const dict = { "ui.sessionReview.diffStyle.split": "Split", "ui.sessionReview.expandAll": "Expand all", "ui.sessionReview.collapseAll": "Collapse all", + "ui.sessionReview.change.added": "Added", + "ui.sessionReview.change.removed": "Removed", + + "ui.lineComment.label.prefix": "Comment on ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Commenting on ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Add comment", + "ui.lineComment.submit": "Comment", "ui.sessionTurn.steps.show": "Show steps", "ui.sessionTurn.steps.hide": "Hide steps", From 04337f62024ab054a62467332c94b62ab292f3c8 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:03:29 -0600 Subject: [PATCH 063/294] chore: cleanup --- packages/app/src/i18n/ar.ts | 32 ++++++++++ packages/app/src/i18n/br.ts | 37 ++++++++++++ packages/app/src/i18n/da.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/de.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/es.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/fr.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/ja.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/ko.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/no.ts | 93 +++++++++++++++++++++++++++++ packages/app/src/i18n/pl.ts | 32 ++++++++++ packages/app/src/i18n/ru.ts | 32 ++++++++++ packages/app/src/i18n/zh.ts | 111 +++++++++++++++++++++++++++++++++++ packages/app/src/i18n/zht.ts | 110 ++++++++++++++++++++++++++++++++++ packages/ui/src/i18n/ar.ts | 8 +++ packages/ui/src/i18n/br.ts | 8 +++ packages/ui/src/i18n/da.ts | 8 +++ packages/ui/src/i18n/de.ts | 8 +++ packages/ui/src/i18n/es.ts | 8 +++ packages/ui/src/i18n/fr.ts | 8 +++ packages/ui/src/i18n/ja.ts | 8 +++ packages/ui/src/i18n/ko.ts | 8 +++ packages/ui/src/i18n/no.ts | 8 +++ packages/ui/src/i18n/pl.ts | 8 +++ packages/ui/src/i18n/ru.ts | 8 +++ packages/ui/src/i18n/zh.ts | 8 +++ packages/ui/src/i18n/zht.ts | 8 +++ 26 files changed, 1217 insertions(+) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 9fcd8ba6db..b442b05a4e 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "سمة", "command.category.language": "لغة", "command.category.file": "ملف", + "command.category.context": "سياق", "command.category.terminal": "محطة طرفية", "command.category.model": "نموذج", "command.category.mcp": "MCP", @@ -42,7 +43,10 @@ export const dict = { "command.session.new": "جلسة جديدة", "command.file.open": "فتح ملف", "command.file.open.description": "البحث في الملفات والأوامر", + "command.context.addSelection": "إضافة التحديد إلى السياق", + "command.context.addSelection.description": "إضافة الأسطر المحددة من الملف الحالي", "command.terminal.toggle": "تبديل المحطة الطرفية", + "command.fileTree.toggle": "تبديل شجرة الملفات", "command.review.toggle": "تبديل المراجعة", "command.terminal.new": "محطة طرفية جديدة", "command.terminal.new.description": "إنشاء علامة تبويب جديدة للمحطة الطرفية", @@ -137,6 +141,8 @@ export const dict = { "provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", + "provider.disconnect.toast.disconnected.title": "تم فصل {{provider}}", + "provider.disconnect.toast.disconnected.description": "لم تعد نماذج {{provider}} متاحة.", "model.tag.free": "مجاني", "model.tag.latest": "الأحدث", "model.provider.anthropic": "Anthropic", @@ -159,6 +165,8 @@ export const dict = { "common.loading": "جارٍ التحميل", "common.loading.ellipsis": "...", "common.cancel": "إلغاء", + "common.connect": "اتصال", + "common.disconnect": "قطع الاتصال", "common.submit": "إرسال", "common.save": "حفظ", "common.saving": "جارٍ الحفظ...", @@ -167,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "أدخل أمر shell...", "prompt.placeholder.normal": 'اسأل أي شيء... "{{example}}"', + "prompt.placeholder.summarizeComments": "لخّص التعليقات…", + "prompt.placeholder.summarizeComment": "لخّص التعليق…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc للخروج", @@ -270,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "لون", "dialog.project.edit.color.select": "اختر لون {{color}}", + "dialog.project.edit.worktree.startup": "سكريبت بدء تشغيل مساحة العمل", + "dialog.project.edit.worktree.startup.description": "يتم تشغيله بعد إنشاء مساحة عمل جديدة (شجرة عمل).", + "dialog.project.edit.worktree.startup.placeholder": "مثال: bun install", "context.breakdown.title": "تفصيل السياق", "context.breakdown.note": 'تفصيل تقريبي لرموز الإدخال. يشمل "أخرى" تعريفات الأدوات والنفقات العامة.', "context.breakdown.system": "النظام", @@ -335,6 +348,9 @@ export const dict = { "toast.file.loadFailed.title": "فشل تحميل الملف", + "toast.file.listFailed.title": "فشل سرد الملفات", + "toast.context.noLineSelection.title": "لا يوجد تحديد للأسطر", + "toast.context.noLineSelection.description": "حدد نطاق أسطر في تبويب ملف أولاً.", "toast.session.share.copyFailed.title": "فشل نسخ عنوان URL إلى الحافظة", "toast.session.share.success.title": "تمت مشاركة الجلسة", "toast.session.share.success.description": "تم نسخ عنوان URL للمشاركة إلى الحافظة!", @@ -408,8 +424,13 @@ export const dict = { "session.tab.context": "سياق", "session.panel.reviewAndFiles": "المراجعة والملفات", "session.review.filesChanged": "تم تغيير {{count}} ملفات", + "session.review.change.one": "تغيير", + "session.review.change.other": "تغييرات", "session.review.loadingChanges": "جارٍ تحميل التغييرات...", "session.review.empty": "لا توجد تغييرات في هذه الجلسة بعد", + "session.review.noChanges": "لا توجد تغييرات", + "session.files.selectToOpen": "اختر ملفًا لفتحه", + "session.files.all": "كل الملفات", "session.messages.renderEarlier": "عرض الرسائل السابقة", "session.messages.loadingEarlier": "جارٍ تحميل الرسائل السابقة...", "session.messages.loadEarlier": "تحميل الرسائل السابقة", @@ -483,7 +504,9 @@ export const dict = { "sidebar.project.recentSessions": "الجلسات الحديثة", "sidebar.project.viewAllSessions": "عرض جميع الجلسات", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "سطح المكتب", + "settings.section.server": "الخادم", "settings.tab.general": "عام", "settings.tab.shortcuts": "اختصارات", @@ -505,6 +528,7 @@ export const dict = { "font.option.hack": "Hack", "font.option.inconsolata": "Inconsolata", "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", "font.option.jetbrainsMono": "JetBrains Mono", "font.option.mesloLgs": "Meslo LGS", "font.option.robotoMono": "Roboto Mono", @@ -590,6 +614,13 @@ export const dict = { "settings.providers.title": "الموفرون", "settings.providers.description": "ستكون إعدادات الموفر قابلة للتكوين هنا.", + "settings.providers.section.connected": "الموفرون المتصلون", + "settings.providers.connected.empty": "لا يوجد موفرون متصلون", + "settings.providers.section.popular": "الموفرون الشائعون", + "settings.providers.tag.environment": "البيئة", + "settings.providers.tag.config": "التكوين", + "settings.providers.tag.custom": "مخصص", + "settings.providers.tag.other": "أخرى", "settings.models.title": "النماذج", "settings.models.description": "ستكون إعدادات النموذج قابلة للتكوين هنا.", "settings.agents.title": "الوكلاء", @@ -657,6 +688,7 @@ export const dict = { "workspace.reset.failed.title": "فشل إعادة تعيين مساحة العمل", "workspace.reset.success.title": "تمت إعادة تعيين مساحة العمل", "workspace.reset.success.description": "مساحة العمل تطابق الآن الفرع الافتراضي.", + "workspace.error.stillPreparing": "مساحة العمل لا تزال قيد الإعداد", "workspace.status.checking": "التحقق من التغييرات غير المدمجة...", "workspace.status.error": "تعذر التحقق من حالة git.", "workspace.status.clean": "لم يتم اكتشاف تغييرات غير مدمجة.", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index a0c904dfbc..f6d59bd006 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "Tema", "command.category.language": "Idioma", "command.category.file": "Arquivo", + "command.category.context": "Contexto", "command.category.terminal": "Terminal", "command.category.model": "Modelo", "command.category.mcp": "MCP", @@ -42,7 +43,10 @@ export const dict = { "command.session.new": "Nova sessão", "command.file.open": "Abrir arquivo", "command.file.open.description": "Buscar arquivos e comandos", + "command.context.addSelection": "Adicionar seleção ao contexto", + "command.context.addSelection.description": "Adicionar as linhas selecionadas do arquivo atual", "command.terminal.toggle": "Alternar terminal", + "command.fileTree.toggle": "Alternar árvore de arquivos", "command.review.toggle": "Alternar revisão", "command.terminal.new": "Novo terminal", "command.terminal.new.description": "Criar uma nova aba de terminal", @@ -137,6 +141,8 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.", + "provider.disconnect.toast.disconnected.title": "{{provider}} desconectado", + "provider.disconnect.toast.disconnected.description": "Os modelos de {{provider}} não estão mais disponíveis.", "model.tag.free": "Grátis", "model.tag.latest": "Mais recente", "model.provider.anthropic": "Anthropic", @@ -159,6 +165,8 @@ export const dict = { "common.loading": "Carregando", "common.loading.ellipsis": "...", "common.cancel": "Cancelar", + "common.connect": "Conectar", + "common.disconnect": "Desconectar", "common.submit": "Enviar", "common.save": "Salvar", "common.saving": "Salvando...", @@ -167,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "Digite comando do shell...", "prompt.placeholder.normal": 'Pergunte qualquer coisa... "{{example}}"', + "prompt.placeholder.summarizeComments": "Resumir comentários…", + "prompt.placeholder.summarizeComment": "Resumir comentário…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc para sair", @@ -223,6 +233,8 @@ export const dict = { "dialog.mcp.description": "{{enabled}} de {{total}} habilitados", "dialog.mcp.empty": "Nenhum MCP configurado", + "dialog.lsp.empty": "LSPs detectados automaticamente pelos tipos de arquivo", + "dialog.plugins.empty": "Plugins configurados em opencode.json", "mcp.status.connected": "conectado", "mcp.status.failed": "falhou", "mcp.status.needs_auth": "precisa de autenticação", @@ -251,6 +263,12 @@ export const dict = { "dialog.server.default.clear": "Limpar", "dialog.server.action.remove": "Remover servidor", + "dialog.server.menu.edit": "Editar", + "dialog.server.menu.default": "Definir como padrão", + "dialog.server.menu.defaultRemove": "Remover padrão", + "dialog.server.menu.delete": "Excluir", + "dialog.server.current": "Servidor atual", + "dialog.server.status.default": "Padrão", "dialog.project.edit.title": "Editar projeto", "dialog.project.edit.name": "Nome", "dialog.project.edit.icon": "Ícone", @@ -329,6 +347,9 @@ export const dict = { "toast.file.loadFailed.title": "Falha ao carregar arquivo", + "toast.file.listFailed.title": "Falha ao listar arquivos", + "toast.context.noLineSelection.title": "Nenhuma seleção de linhas", + "toast.context.noLineSelection.description": "Selecione primeiro um intervalo de linhas em uma aba de arquivo.", "toast.session.share.copyFailed.title": "Falha ao copiar URL para a área de transferência", "toast.session.share.success.title": "Sessão compartilhada", "toast.session.share.success.description": "URL compartilhada copiada para a área de transferência!", @@ -404,8 +425,13 @@ export const dict = { "session.tab.context": "Contexto", "session.panel.reviewAndFiles": "Revisão e arquivos", "session.review.filesChanged": "{{count}} Arquivos Alterados", + "session.review.change.one": "Alteração", + "session.review.change.other": "Alterações", "session.review.loadingChanges": "Carregando alterações...", "session.review.empty": "Nenhuma alteração nesta sessão ainda", + "session.review.noChanges": "Sem alterações", + "session.files.selectToOpen": "Selecione um arquivo para abrir", + "session.files.all": "Todos os arquivos", "session.messages.renderEarlier": "Renderizar mensagens anteriores", "session.messages.loadingEarlier": "Carregando mensagens anteriores...", "session.messages.loadEarlier": "Carregar mensagens anteriores", @@ -482,7 +508,9 @@ export const dict = { "sidebar.project.recentSessions": "Sessões recentes", "sidebar.project.viewAllSessions": "Ver todas as sessões", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", + "settings.section.server": "Servidor", "settings.tab.general": "Geral", "settings.tab.shortcuts": "Atalhos", @@ -504,6 +532,7 @@ export const dict = { "font.option.hack": "Hack", "font.option.inconsolata": "Inconsolata", "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", "font.option.jetbrainsMono": "JetBrains Mono", "font.option.mesloLgs": "Meslo LGS", "font.option.robotoMono": "Roboto Mono", @@ -591,6 +620,13 @@ export const dict = { "settings.providers.title": "Provedores", "settings.providers.description": "Configurações de provedores estarão disponíveis aqui.", + "settings.providers.section.connected": "Provedores conectados", + "settings.providers.connected.empty": "Nenhum provedor conectado", + "settings.providers.section.popular": "Provedores populares", + "settings.providers.tag.environment": "Ambiente", + "settings.providers.tag.config": "Configuração", + "settings.providers.tag.custom": "Personalizado", + "settings.providers.tag.other": "Outro", "settings.models.title": "Modelos", "settings.models.description": "Configurações de modelos estarão disponíveis aqui.", "settings.agents.title": "Agentes", @@ -658,6 +694,7 @@ export const dict = { "workspace.reset.failed.title": "Falha ao redefinir espaço de trabalho", "workspace.reset.success.title": "Espaço de trabalho redefinido", "workspace.reset.success.description": "Espaço de trabalho agora corresponde ao branch padrão.", + "workspace.error.stillPreparing": "O espaço de trabalho ainda está sendo preparado", "workspace.status.checking": "Verificando alterações não mescladas...", "workspace.status.error": "Não foi possível verificar o status do git.", "workspace.status.clean": "Nenhuma alteração não mesclada detectada.", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 4dc4a2cfb2..8f4d010c35 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "Tema", "command.category.language": "Sprog", "command.category.file": "Fil", + "command.category.context": "Kontekst", "command.category.terminal": "Terminal", "command.category.model": "Model", "command.category.mcp": "MCP", @@ -15,6 +16,7 @@ export const dict = { "command.category.permissions": "Tilladelser", "command.category.workspace": "Arbejdsområde", + "command.category.settings": "Indstillinger", "theme.scheme.system": "System", "theme.scheme.light": "Lys", "theme.scheme.dark": "Mørk", @@ -23,6 +25,7 @@ export const dict = { "command.project.open": "Åbn projekt", "command.provider.connect": "Tilslut udbyder", "command.server.switch": "Skift server", + "command.settings.open": "Åbn indstillinger", "command.session.previous": "Forrige session", "command.session.next": "Næste session", "command.session.archive": "Arkivér session", @@ -40,7 +43,10 @@ export const dict = { "command.session.new": "Ny session", "command.file.open": "Åbn fil", "command.file.open.description": "Søg i filer og kommandoer", + "command.context.addSelection": "Tilføj markering til kontekst", + "command.context.addSelection.description": "Tilføj markerede linjer fra den aktuelle fil", "command.terminal.toggle": "Skift terminal", + "command.fileTree.toggle": "Skift filtræ", "command.review.toggle": "Skift gennemgang", "command.terminal.new": "Ny terminal", "command.terminal.new.description": "Opret en ny terminalfane", @@ -117,6 +123,7 @@ export const dict = { "provider.connect.opencodeZen.line2": "Med en enkelt API-nøgle får du adgang til modeller som Claude, GPT, Gemini, GLM og flere.", "provider.connect.opencodeZen.visit.prefix": "Besøg ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " for at hente din API-nøgle.", "provider.connect.oauth.code.visit.prefix": "Besøg ", "provider.connect.oauth.code.visit.link": "dette link", @@ -134,13 +141,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} forbundet", "provider.connect.toast.connected.description": "{{provider}} modeller er nu tilgængelige.", + "provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet", + "provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke længere tilgængelige.", "model.tag.free": "Gratis", "model.tag.latest": "Nyeste", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "tekst", + "model.input.image": "billede", + "model.input.audio": "lyd", + "model.input.video": "video", + "model.input.pdf": "pdf", + "model.tooltip.allows": "Tillader: {{inputs}}", + "model.tooltip.reasoning.allowed": "Tillader tænkning", + "model.tooltip.reasoning.none": "Ingen tænkning", + "model.tooltip.context": "Kontekstgrænse {{limit}}", "common.search.placeholder": "Søg", "common.goBack": "Gå tilbage", "common.loading": "Indlæser", + "common.loading.ellipsis": "...", "common.cancel": "Annuller", + "common.connect": "Forbind", + "common.disconnect": "Frakobl", "common.submit": "Indsend", "common.save": "Gem", "common.saving": "Gemmer...", @@ -149,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "Indtast shell-kommando...", "prompt.placeholder.normal": 'Spørg om hvad som helst... "{{example}}"', + "prompt.placeholder.summarizeComments": "Opsummér kommentarer…", + "prompt.placeholder.summarizeComment": "Opsummér kommentar…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc for at afslutte", @@ -252,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "Farve", "dialog.project.edit.color.select": "Vælg farven {{color}}", + "dialog.project.edit.worktree.startup": "Opstartsscript for arbejdsområde", + "dialog.project.edit.worktree.startup.description": "Køres efter oprettelse af et nyt arbejdsområde (worktree).", + "dialog.project.edit.worktree.startup.placeholder": "f.eks. bun install", "context.breakdown.title": "Kontekstfordeling", "context.breakdown.note": 'Omtrentlig fordeling af input-tokens. "Andre" inkluderer værktøjsdefinitioner og overhead.', @@ -318,6 +349,9 @@ export const dict = { "toast.file.loadFailed.title": "Kunne ikke indlæse fil", + "toast.file.listFailed.title": "Kunne ikke liste filer", + "toast.context.noLineSelection.title": "Ingen linjevalg", + "toast.context.noLineSelection.description": "Vælg først et linjeinterval i en filfane.", "toast.session.share.copyFailed.title": "Kunne ikke kopiere URL til udklipsholder", "toast.session.share.success.title": "Session delt", "toast.session.share.success.description": "Delings-URL kopieret til udklipsholder!", @@ -392,13 +426,19 @@ export const dict = { "session.tab.context": "Kontekst", "session.panel.reviewAndFiles": "Gennemgang og filer", "session.review.filesChanged": "{{count}} Filer ændret", + "session.review.change.one": "Ændring", + "session.review.change.other": "Ændringer", "session.review.loadingChanges": "Indlæser ændringer...", "session.review.empty": "Ingen ændringer i denne session endnu", + "session.review.noChanges": "Ingen ændringer", + "session.files.selectToOpen": "Vælg en fil at åbne", + "session.files.all": "Alle filer", "session.messages.renderEarlier": "Vis tidligere beskeder", "session.messages.loadingEarlier": "Indlæser tidligere beskeder...", "session.messages.loadEarlier": "Indlæs tidligere beskeder", "session.messages.loading": "Indlæser beskeder...", + "session.messages.jumpToLatest": "Gå til seneste", "session.context.addToContext": "Tilføj {{selection}} til kontekst", "session.new.worktree.main": "Hovedgren", @@ -440,6 +480,8 @@ export const dict = { "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Luk terminal", + "terminal.connectionLost.title": "Forbindelse mistet", + "terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.", "common.closeTab": "Luk fane", "common.dismiss": "Afvis", "common.requestFailed": "Forespørgsel mislykkedes", @@ -453,6 +495,8 @@ export const dict = { "common.edit": "Rediger", "common.loadMore": "Indlæs flere", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "Skift menu", "sidebar.nav.projectsAndSessions": "Projekter og sessioner", "sidebar.settings": "Indstillinger", "sidebar.help": "Hjælp", @@ -464,7 +508,9 @@ export const dict = { "sidebar.project.recentSessions": "Seneste sessioner", "sidebar.project.viewAllSessions": "Vis alle sessioner", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", + "settings.section.server": "Server", "settings.tab.general": "Generelt", "settings.tab.shortcuts": "Genveje", @@ -481,6 +527,63 @@ export const dict = { "settings.general.row.font.title": "Skrifttype", "settings.general.row.font.description": "Tilpas mono-skrifttypen brugt i kodeblokke", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "Alarm 01", + "sound.option.alert02": "Alarm 02", + "sound.option.alert03": "Alarm 03", + "sound.option.alert04": "Alarm 04", + "sound.option.alert05": "Alarm 05", + "sound.option.alert06": "Alarm 06", + "sound.option.alert07": "Alarm 07", + "sound.option.alert08": "Alarm 08", + "sound.option.alert09": "Alarm 09", + "sound.option.alert10": "Alarm 10", + "sound.option.bipbop01": "Bip-bop 01", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "Nej 01", + "sound.option.nope02": "Nej 02", + "sound.option.nope03": "Nej 03", + "sound.option.nope04": "Nej 04", + "sound.option.nope05": "Nej 05", + "sound.option.nope06": "Nej 06", + "sound.option.nope07": "Nej 07", + "sound.option.nope08": "Nej 08", + "sound.option.nope09": "Nej 09", + "sound.option.nope10": "Nej 10", + "sound.option.nope11": "Nej 11", + "sound.option.nope12": "Nej 12", + "sound.option.yup01": "Ja 01", + "sound.option.yup02": "Ja 02", + "sound.option.yup03": "Ja 03", + "sound.option.yup04": "Ja 04", + "sound.option.yup05": "Ja 05", + "sound.option.yup06": "Ja 06", "settings.general.notifications.agent.title": "Agent", "settings.general.notifications.agent.description": "Vis systemmeddelelse når agenten er færdig eller kræver opmærksomhed", @@ -516,6 +619,13 @@ export const dict = { "settings.providers.title": "Udbydere", "settings.providers.description": "Udbyderindstillinger vil kunne konfigureres her.", + "settings.providers.section.connected": "Forbundne udbydere", + "settings.providers.connected.empty": "Ingen forbundne udbydere", + "settings.providers.section.popular": "Populære udbydere", + "settings.providers.tag.environment": "Miljø", + "settings.providers.tag.config": "Konfiguration", + "settings.providers.tag.custom": "Brugerdefineret", + "settings.providers.tag.other": "Andet", "settings.models.title": "Modeller", "settings.models.description": "Modelindstillinger vil kunne konfigureres her.", "settings.agents.title": "Agenter", @@ -583,6 +693,7 @@ export const dict = { "workspace.reset.failed.title": "Kunne ikke nulstille arbejdsområde", "workspace.reset.success.title": "Arbejdsområde nulstillet", "workspace.reset.success.description": "Arbejdsområdet matcher nu hovedgrenen.", + "workspace.error.stillPreparing": "Arbejdsområdet er stadig ved at blive klargjort", "workspace.status.checking": "Tjekker for uflettede ændringer...", "workspace.status.error": "Kunne ikke bekræfte git-status.", "workspace.status.clean": "Ingen uflettede ændringer fundet.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 69bf1fb494..a54f19d180 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -12,6 +12,7 @@ export const dict = { "command.category.theme": "Thema", "command.category.language": "Sprache", "command.category.file": "Datei", + "command.category.context": "Kontext", "command.category.terminal": "Terminal", "command.category.model": "Modell", "command.category.mcp": "MCP", @@ -19,6 +20,7 @@ export const dict = { "command.category.permissions": "Berechtigungen", "command.category.workspace": "Arbeitsbereich", + "command.category.settings": "Einstellungen", "theme.scheme.system": "System", "theme.scheme.light": "Hell", "theme.scheme.dark": "Dunkel", @@ -27,6 +29,7 @@ export const dict = { "command.project.open": "Projekt öffnen", "command.provider.connect": "Anbieter verbinden", "command.server.switch": "Server wechseln", + "command.settings.open": "Einstellungen öffnen", "command.session.previous": "Vorherige Sitzung", "command.session.next": "Nächste Sitzung", "command.session.archive": "Sitzung archivieren", @@ -44,7 +47,10 @@ export const dict = { "command.session.new": "Neue Sitzung", "command.file.open": "Datei öffnen", "command.file.open.description": "Dateien und Befehle durchsuchen", + "command.context.addSelection": "Auswahl zum Kontext hinzufügen", + "command.context.addSelection.description": "Ausgewählte Zeilen aus der aktuellen Datei hinzufügen", "command.terminal.toggle": "Terminal umschalten", + "command.fileTree.toggle": "Dateibaum umschalten", "command.review.toggle": "Überprüfung umschalten", "command.terminal.new": "Neues Terminal", "command.terminal.new.description": "Neuen Terminal-Tab erstellen", @@ -121,6 +127,7 @@ export const dict = { "provider.connect.opencodeZen.line2": "Mit einem einzigen API-Schlüssel erhalten Sie Zugriff auf Modelle wie Claude, GPT, Gemini, GLM und mehr.", "provider.connect.opencodeZen.visit.prefix": "Besuchen Sie ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": ", um Ihren API-Schlüssel zu erhalten.", "provider.connect.oauth.code.visit.prefix": "Besuchen Sie ", "provider.connect.oauth.code.visit.link": "diesen Link", @@ -138,13 +145,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} verbunden", "provider.connect.toast.connected.description": "{{provider}} Modelle sind jetzt verfügbar.", + "provider.disconnect.toast.disconnected.title": "{{provider}} getrennt", + "provider.disconnect.toast.disconnected.description": "Die {{provider}}-Modelle sind nicht mehr verfügbar.", "model.tag.free": "Kostenlos", "model.tag.latest": "Neueste", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "Text", + "model.input.image": "Bild", + "model.input.audio": "Audio", + "model.input.video": "Video", + "model.input.pdf": "pdf", + "model.tooltip.allows": "Erlaubt: {{inputs}}", + "model.tooltip.reasoning.allowed": "Erlaubt Reasoning", + "model.tooltip.reasoning.none": "Kein Reasoning", + "model.tooltip.context": "Kontextlimit {{limit}}", "common.search.placeholder": "Suchen", "common.goBack": "Zurück", "common.loading": "Laden", + "common.loading.ellipsis": "...", "common.cancel": "Abbrechen", + "common.connect": "Verbinden", + "common.disconnect": "Trennen", "common.submit": "Absenden", "common.save": "Speichern", "common.saving": "Speichert...", @@ -153,6 +179,8 @@ export const dict = { "prompt.placeholder.shell": "Shell-Befehl eingeben...", "prompt.placeholder.normal": 'Fragen Sie alles... "{{example}}"', + "prompt.placeholder.summarizeComments": "Kommentare zusammenfassen…", + "prompt.placeholder.summarizeComment": "Kommentar zusammenfassen…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc zum Verlassen", @@ -257,6 +285,9 @@ export const dict = { "dialog.project.edit.color": "Farbe", "dialog.project.edit.color.select": "{{color}}-Farbe auswählen", + "dialog.project.edit.worktree.startup": "Startup-Skript für Arbeitsbereich", + "dialog.project.edit.worktree.startup.description": "Wird nach dem Erstellen eines neuen Arbeitsbereichs (Worktree) ausgeführt.", + "dialog.project.edit.worktree.startup.placeholder": "z. B. bun install", "context.breakdown.title": "Kontext-Aufschlüsselung", "context.breakdown.note": 'Ungefähre Aufschlüsselung der Eingabe-Token. "Andere" beinhaltet Werkzeugdefinitionen und Overhead.', @@ -323,6 +354,9 @@ export const dict = { "toast.file.loadFailed.title": "Datei konnte nicht geladen werden", + "toast.file.listFailed.title": "Dateien konnten nicht aufgelistet werden", + "toast.context.noLineSelection.title": "Keine Zeilenauswahl", + "toast.context.noLineSelection.description": "Wählen Sie zuerst einen Zeilenbereich in einem Datei-Tab aus.", "toast.session.share.copyFailed.title": "URL konnte nicht in die Zwischenablage kopiert werden", "toast.session.share.success.title": "Sitzung geteilt", "toast.session.share.success.description": "Teilen-URL in die Zwischenablage kopiert!", @@ -399,13 +433,19 @@ export const dict = { "session.tab.context": "Kontext", "session.panel.reviewAndFiles": "Überprüfung und Dateien", "session.review.filesChanged": "{{count}} Dateien geändert", + "session.review.change.one": "Änderung", + "session.review.change.other": "Änderungen", "session.review.loadingChanges": "Lade Änderungen...", "session.review.empty": "Noch keine Änderungen in dieser Sitzung", + "session.review.noChanges": "Keine Änderungen", + "session.files.selectToOpen": "Datei zum Öffnen auswählen", + "session.files.all": "Alle Dateien", "session.messages.renderEarlier": "Frühere Nachrichten rendern", "session.messages.loadingEarlier": "Lade frühere Nachrichten...", "session.messages.loadEarlier": "Frühere Nachrichten laden", "session.messages.loading": "Lade Nachrichten...", + "session.messages.jumpToLatest": "Zum neuesten springen", "session.context.addToContext": "{{selection}} zum Kontext hinzufügen", "session.new.worktree.main": "Haupt-Branch", @@ -447,6 +487,8 @@ export const dict = { "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Terminal schließen", + "terminal.connectionLost.title": "Verbindung verloren", + "terminal.connectionLost.description": "Die Terminalverbindung wurde unterbrochen. Das kann passieren, wenn der Server neu startet.", "common.closeTab": "Tab schließen", "common.dismiss": "Verwerfen", "common.requestFailed": "Anfrage fehlgeschlagen", @@ -460,6 +502,8 @@ export const dict = { "common.edit": "Bearbeiten", "common.loadMore": "Mehr laden", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "Menü umschalten", "sidebar.nav.projectsAndSessions": "Projekte und Sitzungen", "sidebar.settings": "Einstellungen", "sidebar.help": "Hilfe", @@ -472,7 +516,9 @@ export const dict = { "sidebar.project.recentSessions": "Letzte Sitzungen", "sidebar.project.viewAllSessions": "Alle Sitzungen anzeigen", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", + "settings.section.server": "Server", "settings.tab.general": "Allgemein", "settings.tab.shortcuts": "Tastenkombinationen", @@ -489,6 +535,63 @@ export const dict = { "settings.general.row.font.title": "Schriftart", "settings.general.row.font.description": "Die in Codeblöcken verwendete Monospace-Schriftart anpassen", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "Alarm 01", + "sound.option.alert02": "Alarm 02", + "sound.option.alert03": "Alarm 03", + "sound.option.alert04": "Alarm 04", + "sound.option.alert05": "Alarm 05", + "sound.option.alert06": "Alarm 06", + "sound.option.alert07": "Alarm 07", + "sound.option.alert08": "Alarm 08", + "sound.option.alert09": "Alarm 09", + "sound.option.alert10": "Alarm 10", + "sound.option.bipbop01": "Bip-bop 01", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "Nein 01", + "sound.option.nope02": "Nein 02", + "sound.option.nope03": "Nein 03", + "sound.option.nope04": "Nein 04", + "sound.option.nope05": "Nein 05", + "sound.option.nope06": "Nein 06", + "sound.option.nope07": "Nein 07", + "sound.option.nope08": "Nein 08", + "sound.option.nope09": "Nein 09", + "sound.option.nope10": "Nein 10", + "sound.option.nope11": "Nein 11", + "sound.option.nope12": "Nein 12", + "sound.option.yup01": "Ja 01", + "sound.option.yup02": "Ja 02", + "sound.option.yup03": "Ja 03", + "sound.option.yup04": "Ja 04", + "sound.option.yup05": "Ja 05", + "sound.option.yup06": "Ja 06", "settings.general.notifications.agent.title": "Agent", "settings.general.notifications.agent.description": "Systembenachrichtigung anzeigen, wenn der Agent fertig ist oder Aufmerksamkeit benötigt", @@ -525,6 +628,13 @@ export const dict = { "settings.providers.title": "Anbieter", "settings.providers.description": "Anbietereinstellungen können hier konfiguriert werden.", + "settings.providers.section.connected": "Verbundene Anbieter", + "settings.providers.connected.empty": "Keine verbundenen Anbieter", + "settings.providers.section.popular": "Beliebte Anbieter", + "settings.providers.tag.environment": "Umgebung", + "settings.providers.tag.config": "Konfiguration", + "settings.providers.tag.custom": "Benutzerdefiniert", + "settings.providers.tag.other": "Andere", "settings.models.title": "Modelle", "settings.models.description": "Modelleinstellungen können hier konfiguriert werden.", "settings.agents.title": "Agenten", @@ -592,6 +702,7 @@ export const dict = { "workspace.reset.failed.title": "Arbeitsbereich konnte nicht zurückgesetzt werden", "workspace.reset.success.title": "Arbeitsbereich zurückgesetzt", "workspace.reset.success.description": "Der Arbeitsbereich entspricht jetzt dem Standard-Branch.", + "workspace.error.stillPreparing": "Arbeitsbereich wird noch vorbereitet", "workspace.status.checking": "Suche nach nicht zusammengeführten Änderungen...", "workspace.status.error": "Git-Status konnte nicht überprüft werden.", "workspace.status.clean": "Keine nicht zusammengeführten Änderungen erkannt.", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index c715bc048b..06f92e07f0 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "Tema", "command.category.language": "Idioma", "command.category.file": "Archivo", + "command.category.context": "Contexto", "command.category.terminal": "Terminal", "command.category.model": "Modelo", "command.category.mcp": "MCP", @@ -15,6 +16,7 @@ export const dict = { "command.category.permissions": "Permisos", "command.category.workspace": "Espacio de trabajo", + "command.category.settings": "Ajustes", "theme.scheme.system": "Sistema", "theme.scheme.light": "Claro", "theme.scheme.dark": "Oscuro", @@ -23,6 +25,7 @@ export const dict = { "command.project.open": "Abrir proyecto", "command.provider.connect": "Conectar proveedor", "command.server.switch": "Cambiar servidor", + "command.settings.open": "Abrir ajustes", "command.session.previous": "Sesión anterior", "command.session.next": "Siguiente sesión", "command.session.archive": "Archivar sesión", @@ -40,7 +43,10 @@ export const dict = { "command.session.new": "Nueva sesión", "command.file.open": "Abrir archivo", "command.file.open.description": "Buscar archivos y comandos", + "command.context.addSelection": "Añadir selección al contexto", + "command.context.addSelection.description": "Añadir las líneas seleccionadas del archivo actual", "command.terminal.toggle": "Alternar terminal", + "command.fileTree.toggle": "Alternar árbol de archivos", "command.review.toggle": "Alternar revisión", "command.terminal.new": "Nueva terminal", "command.terminal.new.description": "Crear una nueva pestaña de terminal", @@ -117,6 +123,7 @@ export const dict = { "provider.connect.opencodeZen.line2": "Con una sola clave API obtendrás acceso a modelos como Claude, GPT, Gemini, GLM y más.", "provider.connect.opencodeZen.visit.prefix": "Visita ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " para obtener tu clave API.", "provider.connect.oauth.code.visit.prefix": "Visita ", "provider.connect.oauth.code.visit.link": "este enlace", @@ -134,13 +141,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.description": "Los modelos de {{provider}} ahora están disponibles para usar.", + "provider.disconnect.toast.disconnected.title": "{{provider}} desconectado", + "provider.disconnect.toast.disconnected.description": "Los modelos de {{provider}} ya no están disponibles.", "model.tag.free": "Gratis", "model.tag.latest": "Último", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "texto", + "model.input.image": "imagen", + "model.input.audio": "audio", + "model.input.video": "video", + "model.input.pdf": "pdf", + "model.tooltip.allows": "Permite: {{inputs}}", + "model.tooltip.reasoning.allowed": "Permite razonamiento", + "model.tooltip.reasoning.none": "Sin razonamiento", + "model.tooltip.context": "Límite de contexto {{limit}}", "common.search.placeholder": "Buscar", "common.goBack": "Volver", "common.loading": "Cargando", + "common.loading.ellipsis": "...", "common.cancel": "Cancelar", + "common.connect": "Conectar", + "common.disconnect": "Desconectar", "common.submit": "Enviar", "common.save": "Guardar", "common.saving": "Guardando...", @@ -149,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "Introduce comando de shell...", "prompt.placeholder.normal": 'Pregunta cualquier cosa... "{{example}}"', + "prompt.placeholder.summarizeComments": "Resumir comentarios…", + "prompt.placeholder.summarizeComment": "Resumir comentario…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc para salir", @@ -252,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "Color", "dialog.project.edit.color.select": "Seleccionar color {{color}}", + "dialog.project.edit.worktree.startup": "Script de inicio del espacio de trabajo", + "dialog.project.edit.worktree.startup.description": "Se ejecuta después de crear un nuevo espacio de trabajo (árbol de trabajo).", + "dialog.project.edit.worktree.startup.placeholder": "p. ej. bun install", "context.breakdown.title": "Desglose de Contexto", "context.breakdown.note": 'Desglose aproximado de tokens de entrada. "Otro" incluye definiciones de herramientas y sobrecarga.', @@ -318,6 +349,9 @@ export const dict = { "toast.file.loadFailed.title": "Fallo al cargar archivo", + "toast.file.listFailed.title": "Fallo al listar archivos", + "toast.context.noLineSelection.title": "Sin selección de líneas", + "toast.context.noLineSelection.description": "Primero selecciona un rango de líneas en una pestaña de archivo.", "toast.session.share.copyFailed.title": "Fallo al copiar URL al portapapeles", "toast.session.share.success.title": "Sesión compartida", "toast.session.share.success.description": "¡URL compartida copiada al portapapeles!", @@ -393,13 +427,19 @@ export const dict = { "session.tab.context": "Contexto", "session.panel.reviewAndFiles": "Revisión y archivos", "session.review.filesChanged": "{{count}} Archivos Cambiados", + "session.review.change.one": "Cambio", + "session.review.change.other": "Cambios", "session.review.loadingChanges": "Cargando cambios...", "session.review.empty": "No hay cambios en esta sesión aún", + "session.review.noChanges": "Sin cambios", + "session.files.selectToOpen": "Selecciona un archivo para abrir", + "session.files.all": "Todos los archivos", "session.messages.renderEarlier": "Renderizar mensajes anteriores", "session.messages.loadingEarlier": "Cargando mensajes anteriores...", "session.messages.loadEarlier": "Cargar mensajes anteriores", "session.messages.loading": "Cargando mensajes...", + "session.messages.jumpToLatest": "Ir al último", "session.context.addToContext": "Añadir {{selection}} al contexto", "session.new.worktree.main": "Rama principal", @@ -441,6 +481,8 @@ export const dict = { "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Cerrar terminal", + "terminal.connectionLost.title": "Conexión perdida", + "terminal.connectionLost.description": "La conexión del terminal se interrumpió. Esto puede ocurrir cuando el servidor se reinicia.", "common.closeTab": "Cerrar pestaña", "common.dismiss": "Descartar", "common.requestFailed": "Solicitud fallida", @@ -454,6 +496,8 @@ export const dict = { "common.edit": "Editar", "common.loadMore": "Cargar más", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "Alternar menú", "sidebar.nav.projectsAndSessions": "Proyectos y sesiones", "sidebar.settings": "Ajustes", "sidebar.help": "Ayuda", @@ -465,7 +509,9 @@ export const dict = { "sidebar.project.recentSessions": "Sesiones recientes", "sidebar.project.viewAllSessions": "Ver todas las sesiones", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Escritorio", + "settings.section.server": "Servidor", "settings.tab.general": "General", "settings.tab.shortcuts": "Atajos", @@ -482,6 +528,63 @@ export const dict = { "settings.general.row.font.title": "Fuente", "settings.general.row.font.description": "Personaliza la fuente mono usada en bloques de código", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "Alerta 01", + "sound.option.alert02": "Alerta 02", + "sound.option.alert03": "Alerta 03", + "sound.option.alert04": "Alerta 04", + "sound.option.alert05": "Alerta 05", + "sound.option.alert06": "Alerta 06", + "sound.option.alert07": "Alerta 07", + "sound.option.alert08": "Alerta 08", + "sound.option.alert09": "Alerta 09", + "sound.option.alert10": "Alerta 10", + "sound.option.bipbop01": "Bip-bop 01", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "No 01", + "sound.option.nope02": "No 02", + "sound.option.nope03": "No 03", + "sound.option.nope04": "No 04", + "sound.option.nope05": "No 05", + "sound.option.nope06": "No 06", + "sound.option.nope07": "No 07", + "sound.option.nope08": "No 08", + "sound.option.nope09": "No 09", + "sound.option.nope10": "No 10", + "sound.option.nope11": "No 11", + "sound.option.nope12": "No 12", + "sound.option.yup01": "Sí 01", + "sound.option.yup02": "Sí 02", + "sound.option.yup03": "Sí 03", + "sound.option.yup04": "Sí 04", + "sound.option.yup05": "Sí 05", + "sound.option.yup06": "Sí 06", "settings.general.notifications.agent.title": "Agente", "settings.general.notifications.agent.description": "Mostrar notificación del sistema cuando el agente termine o necesite atención", @@ -519,6 +622,13 @@ export const dict = { "settings.providers.title": "Proveedores", "settings.providers.description": "La configuración de proveedores estará disponible aquí.", + "settings.providers.section.connected": "Proveedores conectados", + "settings.providers.connected.empty": "No hay proveedores conectados", + "settings.providers.section.popular": "Proveedores populares", + "settings.providers.tag.environment": "Entorno", + "settings.providers.tag.config": "Configuración", + "settings.providers.tag.custom": "Personalizado", + "settings.providers.tag.other": "Otro", "settings.models.title": "Modelos", "settings.models.description": "La configuración de modelos estará disponible aquí.", "settings.agents.title": "Agentes", @@ -586,6 +696,7 @@ export const dict = { "workspace.reset.failed.title": "Fallo al restablecer espacio de trabajo", "workspace.reset.success.title": "Espacio de trabajo restablecido", "workspace.reset.success.description": "El espacio de trabajo ahora coincide con la rama predeterminada.", + "workspace.error.stillPreparing": "El espacio de trabajo aún se está preparando", "workspace.status.checking": "Comprobando cambios no fusionados...", "workspace.status.error": "No se pudo verificar el estado de git.", "workspace.status.clean": "No se detectaron cambios no fusionados.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 8bd8dba25b..3a3017f080 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "Thème", "command.category.language": "Langue", "command.category.file": "Fichier", + "command.category.context": "Contexte", "command.category.terminal": "Terminal", "command.category.model": "Modèle", "command.category.mcp": "MCP", @@ -15,6 +16,7 @@ export const dict = { "command.category.permissions": "Permissions", "command.category.workspace": "Espace de travail", + "command.category.settings": "Paramètres", "theme.scheme.system": "Système", "theme.scheme.light": "Clair", "theme.scheme.dark": "Sombre", @@ -23,6 +25,7 @@ export const dict = { "command.project.open": "Ouvrir un projet", "command.provider.connect": "Connecter un fournisseur", "command.server.switch": "Changer de serveur", + "command.settings.open": "Ouvrir les paramètres", "command.session.previous": "Session précédente", "command.session.next": "Session suivante", "command.session.archive": "Archiver la session", @@ -40,7 +43,10 @@ export const dict = { "command.session.new": "Nouvelle session", "command.file.open": "Ouvrir un fichier", "command.file.open.description": "Rechercher des fichiers et des commandes", + "command.context.addSelection": "Ajouter la sélection au contexte", + "command.context.addSelection.description": "Ajouter les lignes sélectionnées du fichier actuel", "command.terminal.toggle": "Basculer le terminal", + "command.fileTree.toggle": "Basculer l'arborescence des fichiers", "command.review.toggle": "Basculer la revue", "command.terminal.new": "Nouveau terminal", "command.terminal.new.description": "Créer un nouvel onglet de terminal", @@ -117,6 +123,7 @@ export const dict = { "provider.connect.opencodeZen.line2": "Avec une seule clé API, vous aurez accès à des modèles tels que Claude, GPT, Gemini, GLM et plus encore.", "provider.connect.opencodeZen.visit.prefix": "Visitez ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " pour récupérer votre clé API.", "provider.connect.oauth.code.visit.prefix": "Visitez ", "provider.connect.oauth.code.visit.link": "ce lien", @@ -134,13 +141,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} connecté", "provider.connect.toast.connected.description": "Les modèles {{provider}} sont maintenant disponibles.", + "provider.disconnect.toast.disconnected.title": "{{provider}} déconnecté", + "provider.disconnect.toast.disconnected.description": "Les modèles {{provider}} ne sont plus disponibles.", "model.tag.free": "Gratuit", "model.tag.latest": "Dernier", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "texte", + "model.input.image": "image", + "model.input.audio": "audio", + "model.input.video": "vidéo", + "model.input.pdf": "pdf", + "model.tooltip.allows": "Autorise : {{inputs}}", + "model.tooltip.reasoning.allowed": "Autorise le raisonnement", + "model.tooltip.reasoning.none": "Sans raisonnement", + "model.tooltip.context": "Limite de contexte {{limit}}", "common.search.placeholder": "Rechercher", "common.goBack": "Retour", "common.loading": "Chargement", + "common.loading.ellipsis": "...", "common.cancel": "Annuler", + "common.connect": "Connecter", + "common.disconnect": "Déconnecter", "common.submit": "Soumettre", "common.save": "Enregistrer", "common.saving": "Enregistrement...", @@ -149,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "Entrez une commande shell...", "prompt.placeholder.normal": 'Demandez n\'importe quoi... "{{example}}"', + "prompt.placeholder.summarizeComments": "Résumer les commentaires…", + "prompt.placeholder.summarizeComment": "Résumer le commentaire…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "esc pour quitter", @@ -252,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "Couleur", "dialog.project.edit.color.select": "Sélectionner la couleur {{color}}", + "dialog.project.edit.worktree.startup": "Script de démarrage de l'espace de travail", + "dialog.project.edit.worktree.startup.description": "S'exécute après la création d'un nouvel espace de travail (arbre de travail).", + "dialog.project.edit.worktree.startup.placeholder": "p. ex. bun install", "context.breakdown.title": "Répartition du contexte", "context.breakdown.note": "Répartition approximative des jetons d'entrée. \"Autre\" inclut les définitions d'outils et les frais généraux.", @@ -320,6 +351,9 @@ export const dict = { "toast.file.loadFailed.title": "Échec du chargement du fichier", + "toast.file.listFailed.title": "Échec de la liste des fichiers", + "toast.context.noLineSelection.title": "Aucune sélection de lignes", + "toast.context.noLineSelection.description": "Sélectionnez d'abord une plage de lignes dans un onglet de fichier.", "toast.session.share.copyFailed.title": "Échec de la copie de l'URL dans le presse-papiers", "toast.session.share.success.title": "Session partagée", "toast.session.share.success.description": "URL de partage copiée dans le presse-papiers !", @@ -398,13 +432,19 @@ export const dict = { "session.tab.context": "Contexte", "session.panel.reviewAndFiles": "Revue et fichiers", "session.review.filesChanged": "{{count}} fichiers modifiés", + "session.review.change.one": "Modification", + "session.review.change.other": "Modifications", "session.review.loadingChanges": "Chargement des modifications...", "session.review.empty": "Aucune modification dans cette session pour l'instant", + "session.review.noChanges": "Aucune modification", + "session.files.selectToOpen": "Sélectionnez un fichier à ouvrir", + "session.files.all": "Tous les fichiers", "session.messages.renderEarlier": "Afficher les messages précédents", "session.messages.loadingEarlier": "Chargement des messages précédents...", "session.messages.loadEarlier": "Charger les messages précédents", "session.messages.loading": "Chargement des messages...", + "session.messages.jumpToLatest": "Aller au dernier", "session.context.addToContext": "Ajouter {{selection}} au contexte", "session.new.worktree.main": "Branche principale", @@ -446,6 +486,8 @@ export const dict = { "terminal.title.numbered": "Terminal {{number}}", "terminal.close": "Fermer le terminal", + "terminal.connectionLost.title": "Connexion perdue", + "terminal.connectionLost.description": "La connexion au terminal a été interrompue. Cela peut arriver lorsque le serveur redémarre.", "common.closeTab": "Fermer l'onglet", "common.dismiss": "Ignorer", "common.requestFailed": "La demande a échoué", @@ -459,6 +501,8 @@ export const dict = { "common.edit": "Modifier", "common.loadMore": "Charger plus", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "Basculer le menu", "sidebar.nav.projectsAndSessions": "Projets et sessions", "sidebar.settings": "Paramètres", "sidebar.help": "Aide", @@ -472,7 +516,9 @@ export const dict = { "sidebar.project.recentSessions": "Sessions récentes", "sidebar.project.viewAllSessions": "Voir toutes les sessions", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Bureau", + "settings.section.server": "Serveur", "settings.tab.general": "Général", "settings.tab.shortcuts": "Raccourcis", @@ -489,6 +535,63 @@ export const dict = { "settings.general.row.font.title": "Police", "settings.general.row.font.description": "Personnaliser la police mono utilisée dans les blocs de code", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "Alerte 01", + "sound.option.alert02": "Alerte 02", + "sound.option.alert03": "Alerte 03", + "sound.option.alert04": "Alerte 04", + "sound.option.alert05": "Alerte 05", + "sound.option.alert06": "Alerte 06", + "sound.option.alert07": "Alerte 07", + "sound.option.alert08": "Alerte 08", + "sound.option.alert09": "Alerte 09", + "sound.option.alert10": "Alerte 10", + "sound.option.bipbop01": "Bip-bop 01", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "Non 01", + "sound.option.nope02": "Non 02", + "sound.option.nope03": "Non 03", + "sound.option.nope04": "Non 04", + "sound.option.nope05": "Non 05", + "sound.option.nope06": "Non 06", + "sound.option.nope07": "Non 07", + "sound.option.nope08": "Non 08", + "sound.option.nope09": "Non 09", + "sound.option.nope10": "Non 10", + "sound.option.nope11": "Non 11", + "sound.option.nope12": "Non 12", + "sound.option.yup01": "Oui 01", + "sound.option.yup02": "Oui 02", + "sound.option.yup03": "Oui 03", + "sound.option.yup04": "Oui 04", + "sound.option.yup05": "Oui 05", + "sound.option.yup06": "Oui 06", "settings.general.notifications.agent.title": "Agent", "settings.general.notifications.agent.description": "Afficher une notification système lorsque l'agent a terminé ou nécessite une attention", @@ -525,6 +628,13 @@ export const dict = { "settings.providers.title": "Fournisseurs", "settings.providers.description": "Les paramètres des fournisseurs seront configurables ici.", + "settings.providers.section.connected": "Fournisseurs connectés", + "settings.providers.connected.empty": "Aucun fournisseur connecté", + "settings.providers.section.popular": "Fournisseurs populaires", + "settings.providers.tag.environment": "Environnement", + "settings.providers.tag.config": "Configuration", + "settings.providers.tag.custom": "Personnalisé", + "settings.providers.tag.other": "Autre", "settings.models.title": "Modèles", "settings.models.description": "Les paramètres des modèles seront configurables ici.", "settings.agents.title": "Agents", @@ -593,6 +703,7 @@ export const dict = { "workspace.reset.failed.title": "Échec de la réinitialisation de l'espace de travail", "workspace.reset.success.title": "Espace de travail réinitialisé", "workspace.reset.success.description": "L'espace de travail correspond maintenant à la branche par défaut.", + "workspace.error.stillPreparing": "L'espace de travail est encore en cours de préparation", "workspace.status.checking": "Vérification des modifications non fusionnées...", "workspace.status.error": "Impossible de vérifier le statut git.", "workspace.status.clean": "Aucune modification non fusionnée détectée.", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 5b98f5aa92..7c629c3a01 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "テーマ", "command.category.language": "言語", "command.category.file": "ファイル", + "command.category.context": "コンテキスト", "command.category.terminal": "ターミナル", "command.category.model": "モデル", "command.category.mcp": "MCP", @@ -15,6 +16,7 @@ export const dict = { "command.category.permissions": "権限", "command.category.workspace": "ワークスペース", + "command.category.settings": "設定", "theme.scheme.system": "システム", "theme.scheme.light": "ライト", "theme.scheme.dark": "ダーク", @@ -23,6 +25,7 @@ export const dict = { "command.project.open": "プロジェクトを開く", "command.provider.connect": "プロバイダーに接続", "command.server.switch": "サーバーの切り替え", + "command.settings.open": "設定を開く", "command.session.previous": "前のセッション", "command.session.next": "次のセッション", "command.session.archive": "セッションをアーカイブ", @@ -40,7 +43,10 @@ export const dict = { "command.session.new": "新しいセッション", "command.file.open": "ファイルを開く", "command.file.open.description": "ファイルとコマンドを検索", + "command.context.addSelection": "選択範囲をコンテキストに追加", + "command.context.addSelection.description": "現在のファイルから選択した行を追加", "command.terminal.toggle": "ターミナルの切り替え", + "command.fileTree.toggle": "ファイルツリーを切り替え", "command.review.toggle": "レビューの切り替え", "command.terminal.new": "新しいターミナル", "command.terminal.new.description": "新しいターミナルタブを作成", @@ -116,6 +122,7 @@ export const dict = { "OpenCode Zenは、コーディングエージェント向けに最適化された信頼性の高いモデルへのアクセスを提供します。", "provider.connect.opencodeZen.line2": "1つのAPIキーで、Claude、GPT、Gemini、GLMなどのモデルにアクセスできます。", "provider.connect.opencodeZen.visit.prefix": " ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " にアクセスしてAPIキーを取得してください。", "provider.connect.oauth.code.visit.prefix": " ", "provider.connect.oauth.code.visit.link": "このリンク", @@ -133,13 +140,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}}が接続されました", "provider.connect.toast.connected.description": "{{provider}}モデルが使用可能になりました。", + "provider.disconnect.toast.disconnected.title": "{{provider}}が切断されました", + "provider.disconnect.toast.disconnected.description": "{{provider}}のモデルは利用できなくなりました。", "model.tag.free": "無料", "model.tag.latest": "最新", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "テキスト", + "model.input.image": "画像", + "model.input.audio": "音声", + "model.input.video": "動画", + "model.input.pdf": "pdf", + "model.tooltip.allows": "対応: {{inputs}}", + "model.tooltip.reasoning.allowed": "推論を許可", + "model.tooltip.reasoning.none": "推論なし", + "model.tooltip.context": "コンテキスト上限 {{limit}}", "common.search.placeholder": "検索", "common.goBack": "戻る", "common.loading": "読み込み中", + "common.loading.ellipsis": "...", "common.cancel": "キャンセル", + "common.connect": "接続", + "common.disconnect": "切断", "common.submit": "送信", "common.save": "保存", "common.saving": "保存中...", @@ -148,6 +174,8 @@ export const dict = { "prompt.placeholder.shell": "シェルコマンドを入力...", "prompt.placeholder.normal": '何でも聞いてください... "{{example}}"', + "prompt.placeholder.summarizeComments": "コメントを要約…", + "prompt.placeholder.summarizeComment": "コメントを要約…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "escで終了", @@ -251,6 +279,9 @@ export const dict = { "dialog.project.edit.color": "色", "dialog.project.edit.color.select": "{{color}}の色を選択", + "dialog.project.edit.worktree.startup": "ワークスペース起動スクリプト", + "dialog.project.edit.worktree.startup.description": "新しいワークスペース (ワークツリー) を作成した後に実行されます。", + "dialog.project.edit.worktree.startup.placeholder": "例: bun install", "context.breakdown.title": "コンテキストの内訳", "context.breakdown.note": '入力トークンのおおよその内訳です。"その他"にはツールの定義やオーバーヘッドが含まれます。', "context.breakdown.system": "システム", @@ -316,6 +347,9 @@ export const dict = { "toast.file.loadFailed.title": "ファイルの読み込みに失敗しました", + "toast.file.listFailed.title": "ファイル一覧の取得に失敗しました", + "toast.context.noLineSelection.title": "行が選択されていません", + "toast.context.noLineSelection.description": "まずファイルタブで行範囲を選択してください。", "toast.session.share.copyFailed.title": "URLのコピーに失敗しました", "toast.session.share.success.title": "セッションを共有しました", "toast.session.share.success.description": "共有URLをクリップボードにコピーしました!", @@ -390,13 +424,19 @@ export const dict = { "session.tab.context": "コンテキスト", "session.panel.reviewAndFiles": "レビューとファイル", "session.review.filesChanged": "{{count}} ファイル変更", + "session.review.change.one": "変更", + "session.review.change.other": "変更", "session.review.loadingChanges": "変更を読み込み中...", "session.review.empty": "このセッションでの変更はまだありません", + "session.review.noChanges": "変更なし", + "session.files.selectToOpen": "開くファイルを選択", + "session.files.all": "すべてのファイル", "session.messages.renderEarlier": "以前のメッセージを表示", "session.messages.loadingEarlier": "以前のメッセージを読み込み中...", "session.messages.loadEarlier": "以前のメッセージを読み込む", "session.messages.loading": "メッセージを読み込み中...", + "session.messages.jumpToLatest": "最新へジャンプ", "session.context.addToContext": "{{selection}}をコンテキストに追加", "session.new.worktree.main": "メインブランチ", @@ -438,6 +478,8 @@ export const dict = { "terminal.title.numbered": "ターミナル {{number}}", "terminal.close": "ターミナルを閉じる", + "terminal.connectionLost.title": "接続が失われました", + "terminal.connectionLost.description": "ターミナルの接続が中断されました。これはサーバーが再起動したときに発生することがあります。", "common.closeTab": "タブを閉じる", "common.dismiss": "閉じる", "common.requestFailed": "リクエスト失敗", @@ -451,6 +493,8 @@ export const dict = { "common.edit": "編集", "common.loadMore": "さらに読み込む", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "メニューを切り替え", "sidebar.nav.projectsAndSessions": "プロジェクトとセッション", "sidebar.settings": "設定", "sidebar.help": "ヘルプ", @@ -462,7 +506,9 @@ export const dict = { "sidebar.project.recentSessions": "最近のセッション", "sidebar.project.viewAllSessions": "すべてのセッションを表示", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "デスクトップ", + "settings.section.server": "サーバー", "settings.tab.general": "一般", "settings.tab.shortcuts": "ショートカット", @@ -479,6 +525,63 @@ export const dict = { "settings.general.row.font.title": "フォント", "settings.general.row.font.description": "コードブロックで使用する等幅フォントをカスタマイズします", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "アラート 01", + "sound.option.alert02": "アラート 02", + "sound.option.alert03": "アラート 03", + "sound.option.alert04": "アラート 04", + "sound.option.alert05": "アラート 05", + "sound.option.alert06": "アラート 06", + "sound.option.alert07": "アラート 07", + "sound.option.alert08": "アラート 08", + "sound.option.alert09": "アラート 09", + "sound.option.alert10": "アラート 10", + "sound.option.bipbop01": "ビップボップ 01", + "sound.option.bipbop02": "ビップボップ 02", + "sound.option.bipbop03": "ビップボップ 03", + "sound.option.bipbop04": "ビップボップ 04", + "sound.option.bipbop05": "ビップボップ 05", + "sound.option.bipbop06": "ビップボップ 06", + "sound.option.bipbop07": "ビップボップ 07", + "sound.option.bipbop08": "ビップボップ 08", + "sound.option.bipbop09": "ビップボップ 09", + "sound.option.bipbop10": "ビップボップ 10", + "sound.option.staplebops01": "ステープルボップス 01", + "sound.option.staplebops02": "ステープルボップス 02", + "sound.option.staplebops03": "ステープルボップス 03", + "sound.option.staplebops04": "ステープルボップス 04", + "sound.option.staplebops05": "ステープルボップス 05", + "sound.option.staplebops06": "ステープルボップス 06", + "sound.option.staplebops07": "ステープルボップス 07", + "sound.option.nope01": "いいえ 01", + "sound.option.nope02": "いいえ 02", + "sound.option.nope03": "いいえ 03", + "sound.option.nope04": "いいえ 04", + "sound.option.nope05": "いいえ 05", + "sound.option.nope06": "いいえ 06", + "sound.option.nope07": "いいえ 07", + "sound.option.nope08": "いいえ 08", + "sound.option.nope09": "いいえ 09", + "sound.option.nope10": "いいえ 10", + "sound.option.nope11": "いいえ 11", + "sound.option.nope12": "いいえ 12", + "sound.option.yup01": "はい 01", + "sound.option.yup02": "はい 02", + "sound.option.yup03": "はい 03", + "sound.option.yup04": "はい 04", + "sound.option.yup05": "はい 05", + "sound.option.yup06": "はい 06", "settings.general.notifications.agent.title": "エージェント", "settings.general.notifications.agent.description": "エージェントが完了したか、注意が必要な場合にシステム通知を表示します", @@ -514,6 +617,13 @@ export const dict = { "settings.providers.title": "プロバイダー", "settings.providers.description": "プロバイダー設定はここで構成できます。", + "settings.providers.section.connected": "接続済みプロバイダー", + "settings.providers.connected.empty": "接続済みプロバイダーはありません", + "settings.providers.section.popular": "人気のプロバイダー", + "settings.providers.tag.environment": "環境", + "settings.providers.tag.config": "設定", + "settings.providers.tag.custom": "カスタム", + "settings.providers.tag.other": "その他", "settings.models.title": "モデル", "settings.models.description": "モデル設定はここで構成できます。", "settings.agents.title": "エージェント", @@ -580,6 +690,7 @@ export const dict = { "workspace.reset.failed.title": "ワークスペースのリセットに失敗しました", "workspace.reset.success.title": "ワークスペースをリセットしました", "workspace.reset.success.description": "ワークスペースはデフォルトブランチと一致しています。", + "workspace.error.stillPreparing": "ワークスペースはまだ準備中です", "workspace.status.checking": "未マージの変更を確認中...", "workspace.status.error": "gitステータスを確認できません。", "workspace.status.clean": "未マージの変更は検出されませんでした。", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index a016cd34a4..0a04d62511 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -12,6 +12,7 @@ export const dict = { "command.category.theme": "테마", "command.category.language": "언어", "command.category.file": "파일", + "command.category.context": "컨텍스트", "command.category.terminal": "터미널", "command.category.model": "모델", "command.category.mcp": "MCP", @@ -19,6 +20,7 @@ export const dict = { "command.category.permissions": "권한", "command.category.workspace": "작업 공간", + "command.category.settings": "설정", "theme.scheme.system": "시스템", "theme.scheme.light": "라이트", "theme.scheme.dark": "다크", @@ -27,6 +29,7 @@ export const dict = { "command.project.open": "프로젝트 열기", "command.provider.connect": "공급자 연결", "command.server.switch": "서버 전환", + "command.settings.open": "설정 열기", "command.session.previous": "이전 세션", "command.session.next": "다음 세션", "command.session.archive": "세션 보관", @@ -44,7 +47,10 @@ export const dict = { "command.session.new": "새 세션", "command.file.open": "파일 열기", "command.file.open.description": "파일 및 명령어 검색", + "command.context.addSelection": "선택 영역을 컨텍스트에 추가", + "command.context.addSelection.description": "현재 파일에서 선택한 줄을 추가", "command.terminal.toggle": "터미널 토글", + "command.fileTree.toggle": "파일 트리 토글", "command.review.toggle": "검토 토글", "command.terminal.new": "새 터미널", "command.terminal.new.description": "새 터미널 탭 생성", @@ -120,6 +126,7 @@ export const dict = { "OpenCode Zen은 코딩 에이전트를 위해 최적화된 신뢰할 수 있는 엄선된 모델에 대한 액세스를 제공합니다.", "provider.connect.opencodeZen.line2": "단일 API 키로 Claude, GPT, Gemini, GLM 등 다양한 모델에 액세스할 수 있습니다.", "provider.connect.opencodeZen.visit.prefix": "", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": "를 방문하여 API 키를 받으세요.", "provider.connect.oauth.code.visit.prefix": "", "provider.connect.oauth.code.visit.link": "이 링크", @@ -137,13 +144,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} 연결됨", "provider.connect.toast.connected.description": "이제 {{provider}} 모델을 사용할 수 있습니다.", + "provider.disconnect.toast.disconnected.title": "{{provider}} 연결 해제됨", + "provider.disconnect.toast.disconnected.description": "{{provider}} 모델을 더 이상 사용할 수 없습니다.", "model.tag.free": "무료", "model.tag.latest": "최신", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "텍스트", + "model.input.image": "이미지", + "model.input.audio": "오디오", + "model.input.video": "비디오", + "model.input.pdf": "pdf", + "model.tooltip.allows": "지원: {{inputs}}", + "model.tooltip.reasoning.allowed": "추론 허용", + "model.tooltip.reasoning.none": "추론 없음", + "model.tooltip.context": "컨텍스트 제한 {{limit}}", "common.search.placeholder": "검색", "common.goBack": "뒤로 가기", "common.loading": "로딩 중", + "common.loading.ellipsis": "...", "common.cancel": "취소", + "common.connect": "연결", + "common.disconnect": "연결 해제", "common.submit": "제출", "common.save": "저장", "common.saving": "저장 중...", @@ -152,6 +178,8 @@ export const dict = { "prompt.placeholder.shell": "셸 명령어 입력...", "prompt.placeholder.normal": '무엇이든 물어보세요... "{{example}}"', + "prompt.placeholder.summarizeComments": "댓글 요약…", + "prompt.placeholder.summarizeComment": "댓글 요약…", "prompt.mode.shell": "셸", "prompt.mode.shell.exit": "종료하려면 esc", @@ -255,6 +283,9 @@ export const dict = { "dialog.project.edit.color": "색상", "dialog.project.edit.color.select": "{{color}} 색상 선택", + "dialog.project.edit.worktree.startup": "작업 공간 시작 스크립트", + "dialog.project.edit.worktree.startup.description": "새 작업 공간(작업 트리)을 만든 뒤 실행됩니다.", + "dialog.project.edit.worktree.startup.placeholder": "예: bun install", "context.breakdown.title": "컨텍스트 분석", "context.breakdown.note": '입력 토큰의 대략적인 분석입니다. "기타"에는 도구 정의 및 오버헤드가 포함됩니다.', "context.breakdown.system": "시스템", @@ -320,6 +351,9 @@ export const dict = { "toast.file.loadFailed.title": "파일 로드 실패", + "toast.file.listFailed.title": "파일 목록을 불러오지 못했습니다", + "toast.context.noLineSelection.title": "줄 선택 없음", + "toast.context.noLineSelection.description": "먼저 파일 탭에서 줄 범위를 선택하세요.", "toast.session.share.copyFailed.title": "URL 클립보드 복사 실패", "toast.session.share.success.title": "세션 공유됨", "toast.session.share.success.description": "공유 URL이 클립보드에 복사되었습니다!", @@ -393,13 +427,19 @@ export const dict = { "session.tab.context": "컨텍스트", "session.panel.reviewAndFiles": "검토 및 파일", "session.review.filesChanged": "{{count}}개 파일 변경됨", + "session.review.change.one": "변경", + "session.review.change.other": "변경", "session.review.loadingChanges": "변경 사항 로드 중...", "session.review.empty": "이 세션에 변경 사항이 아직 없습니다", + "session.review.noChanges": "변경 없음", + "session.files.selectToOpen": "열 파일을 선택하세요", + "session.files.all": "모든 파일", "session.messages.renderEarlier": "이전 메시지 렌더링", "session.messages.loadingEarlier": "이전 메시지 로드 중...", "session.messages.loadEarlier": "이전 메시지 로드", "session.messages.loading": "메시지 로드 중...", + "session.messages.jumpToLatest": "최신으로 이동", "session.context.addToContext": "컨텍스트에 {{selection}} 추가", "session.new.worktree.main": "메인 브랜치", @@ -440,6 +480,8 @@ export const dict = { "terminal.title.numbered": "터미널 {{number}}", "terminal.close": "터미널 닫기", + "terminal.connectionLost.title": "연결 끊김", + "terminal.connectionLost.description": "터미널 연결이 중단되었습니다. 서버가 재시작하면 이런 일이 발생할 수 있습니다.", "common.closeTab": "탭 닫기", "common.dismiss": "닫기", "common.requestFailed": "요청 실패", @@ -453,6 +495,8 @@ export const dict = { "common.edit": "편집", "common.loadMore": "더 불러오기", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "메뉴 토글", "sidebar.nav.projectsAndSessions": "프로젝트 및 세션", "sidebar.settings": "설정", "sidebar.help": "도움말", @@ -464,7 +508,9 @@ export const dict = { "sidebar.project.recentSessions": "최근 세션", "sidebar.project.viewAllSessions": "모든 세션 보기", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "데스크톱", + "settings.section.server": "서버", "settings.tab.general": "일반", "settings.tab.shortcuts": "단축키", @@ -481,6 +527,63 @@ export const dict = { "settings.general.row.font.title": "글꼴", "settings.general.row.font.description": "코드 블록에 사용되는 고정폭 글꼴 사용자 지정", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "알림 01", + "sound.option.alert02": "알림 02", + "sound.option.alert03": "알림 03", + "sound.option.alert04": "알림 04", + "sound.option.alert05": "알림 05", + "sound.option.alert06": "알림 06", + "sound.option.alert07": "알림 07", + "sound.option.alert08": "알림 08", + "sound.option.alert09": "알림 09", + "sound.option.alert10": "알림 10", + "sound.option.bipbop01": "빕-밥 01", + "sound.option.bipbop02": "빕-밥 02", + "sound.option.bipbop03": "빕-밥 03", + "sound.option.bipbop04": "빕-밥 04", + "sound.option.bipbop05": "빕-밥 05", + "sound.option.bipbop06": "빕-밥 06", + "sound.option.bipbop07": "빕-밥 07", + "sound.option.bipbop08": "빕-밥 08", + "sound.option.bipbop09": "빕-밥 09", + "sound.option.bipbop10": "빕-밥 10", + "sound.option.staplebops01": "스테이플밥스 01", + "sound.option.staplebops02": "스테이플밥스 02", + "sound.option.staplebops03": "스테이플밥스 03", + "sound.option.staplebops04": "스테이플밥스 04", + "sound.option.staplebops05": "스테이플밥스 05", + "sound.option.staplebops06": "스테이플밥스 06", + "sound.option.staplebops07": "스테이플밥스 07", + "sound.option.nope01": "아니오 01", + "sound.option.nope02": "아니오 02", + "sound.option.nope03": "아니오 03", + "sound.option.nope04": "아니오 04", + "sound.option.nope05": "아니오 05", + "sound.option.nope06": "아니오 06", + "sound.option.nope07": "아니오 07", + "sound.option.nope08": "아니오 08", + "sound.option.nope09": "아니오 09", + "sound.option.nope10": "아니오 10", + "sound.option.nope11": "아니오 11", + "sound.option.nope12": "아니오 12", + "sound.option.yup01": "네 01", + "sound.option.yup02": "네 02", + "sound.option.yup03": "네 03", + "sound.option.yup04": "네 04", + "sound.option.yup05": "네 05", + "sound.option.yup06": "네 06", "settings.general.notifications.agent.title": "에이전트", "settings.general.notifications.agent.description": "에이전트가 완료되거나 주의가 필요할 때 시스템 알림 표시", "settings.general.notifications.permissions.title": "권한", @@ -515,6 +618,13 @@ export const dict = { "settings.providers.title": "공급자", "settings.providers.description": "공급자 설정은 여기서 구성할 수 있습니다.", + "settings.providers.section.connected": "연결된 공급자", + "settings.providers.connected.empty": "연결된 공급자 없음", + "settings.providers.section.popular": "인기 공급자", + "settings.providers.tag.environment": "환경", + "settings.providers.tag.config": "구성", + "settings.providers.tag.custom": "사용자 지정", + "settings.providers.tag.other": "기타", "settings.models.title": "모델", "settings.models.description": "모델 설정은 여기서 구성할 수 있습니다.", "settings.agents.title": "에이전트", @@ -581,6 +691,7 @@ export const dict = { "workspace.reset.failed.title": "작업 공간 재설정 실패", "workspace.reset.success.title": "작업 공간 재설정됨", "workspace.reset.success.description": "작업 공간이 이제 기본 브랜치와 일치합니다.", + "workspace.error.stillPreparing": "작업 공간이 아직 준비 중입니다", "workspace.status.checking": "병합되지 않은 변경 사항 확인 중...", "workspace.status.error": "Git 상태를 확인할 수 없습니다.", "workspace.status.clean": "병합되지 않은 변경 사항이 감지되지 않았습니다.", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index 153ee04122..c2992d57a6 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -11,6 +11,7 @@ export const dict = { "command.category.theme": "Tema", "command.category.language": "Språk", "command.category.file": "Fil", + "command.category.context": "Kontekst", "command.category.terminal": "Terminal", "command.category.model": "Modell", "command.category.mcp": "MCP", @@ -45,7 +46,10 @@ export const dict = { "command.session.new": "Ny sesjon", "command.file.open": "Åpne fil", "command.file.open.description": "Søk i filer og kommandoer", + "command.context.addSelection": "Legg til markering i kontekst", + "command.context.addSelection.description": "Legg til valgte linjer fra gjeldende fil", "command.terminal.toggle": "Veksle terminal", + "command.fileTree.toggle": "Veksle filtre", "command.review.toggle": "Veksle gjennomgang", "command.terminal.new": "Ny terminal", "command.terminal.new.description": "Opprett en ny terminalfane", @@ -140,6 +144,8 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} tilkoblet", "provider.connect.toast.connected.description": "{{provider}}-modeller er nå tilgjengelige.", + "provider.disconnect.toast.disconnected.title": "{{provider}} frakoblet", + "provider.disconnect.toast.disconnected.description": "Modeller fra {{provider}} er ikke lenger tilgjengelige.", "model.tag.free": "Gratis", "model.tag.latest": "Nyeste", "model.provider.anthropic": "Anthropic", @@ -162,6 +168,8 @@ export const dict = { "common.loading": "Laster", "common.loading.ellipsis": "...", "common.cancel": "Avbryt", + "common.connect": "Koble til", + "common.disconnect": "Koble fra", "common.submit": "Send inn", "common.save": "Lagre", "common.saving": "Lagrer...", @@ -170,6 +178,8 @@ export const dict = { "prompt.placeholder.shell": "Skriv inn shell-kommando...", "prompt.placeholder.normal": 'Spør om hva som helst... "{{example}}"', + "prompt.placeholder.summarizeComments": "Oppsummer kommentarer…", + "prompt.placeholder.summarizeComment": "Oppsummer kommentar…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "ESC for å avslutte", @@ -273,6 +283,9 @@ export const dict = { "dialog.project.edit.color": "Farge", "dialog.project.edit.color.select": "Velg fargen {{color}}", + "dialog.project.edit.worktree.startup": "Oppstartsskript for arbeidsområde", + "dialog.project.edit.worktree.startup.description": "Kjører etter at et nytt arbeidsområde (worktree) er opprettet.", + "dialog.project.edit.worktree.startup.placeholder": "f.eks. bun install", "context.breakdown.title": "Kontekstfordeling", "context.breakdown.note": 'Omtrentlig fordeling av input-tokens. "Annet" inkluderer verktøydefinisjoner og overhead.', "context.breakdown.system": "System", @@ -338,6 +351,9 @@ export const dict = { "toast.file.loadFailed.title": "Kunne ikke laste fil", + "toast.file.listFailed.title": "Kunne ikke liste filer", + "toast.context.noLineSelection.title": "Ingen linjevalg", + "toast.context.noLineSelection.description": "Velg først et linjeområde i en filfane.", "toast.session.share.copyFailed.title": "Kunne ikke kopiere URL til utklippstavlen", "toast.session.share.success.title": "Sesjon delt", "toast.session.share.success.description": "Delings-URL kopiert til utklippstavlen!", @@ -412,8 +428,13 @@ export const dict = { "session.tab.context": "Kontekst", "session.panel.reviewAndFiles": "Gjennomgang og filer", "session.review.filesChanged": "{{count}} filer endret", + "session.review.change.one": "Endring", + "session.review.change.other": "Endringer", "session.review.loadingChanges": "Laster endringer...", "session.review.empty": "Ingen endringer i denne sesjonen ennå", + "session.review.noChanges": "Ingen endringer", + "session.files.selectToOpen": "Velg en fil å åpne", + "session.files.all": "Alle filer", "session.messages.renderEarlier": "Vis tidligere meldinger", "session.messages.loadingEarlier": "Laster inn tidligere meldinger...", "session.messages.loadEarlier": "Last inn tidligere meldinger", @@ -471,6 +492,7 @@ export const dict = { "common.learnMore": "Lær mer", "common.rename": "Gi nytt navn", "common.reset": "Tilbakestill", + "common.archive": "Arkiver", "common.delete": "Slett", "common.close": "Lukk", "common.edit": "Rediger", @@ -489,7 +511,9 @@ export const dict = { "sidebar.project.recentSessions": "Nylige sesjoner", "sidebar.project.viewAllSessions": "Vis alle sesjoner", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Skrivebord", + "settings.section.server": "Server", "settings.tab.general": "Generelt", "settings.tab.shortcuts": "Snarveier", @@ -506,6 +530,63 @@ export const dict = { "settings.general.row.font.title": "Skrift", "settings.general.row.font.description": "Tilpass mono-skriften som brukes i kodeblokker", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "Varsel 01", + "sound.option.alert02": "Varsel 02", + "sound.option.alert03": "Varsel 03", + "sound.option.alert04": "Varsel 04", + "sound.option.alert05": "Varsel 05", + "sound.option.alert06": "Varsel 06", + "sound.option.alert07": "Varsel 07", + "sound.option.alert08": "Varsel 08", + "sound.option.alert09": "Varsel 09", + "sound.option.alert10": "Varsel 10", + "sound.option.bipbop01": "Bip-bop 01", + "sound.option.bipbop02": "Bip-bop 02", + "sound.option.bipbop03": "Bip-bop 03", + "sound.option.bipbop04": "Bip-bop 04", + "sound.option.bipbop05": "Bip-bop 05", + "sound.option.bipbop06": "Bip-bop 06", + "sound.option.bipbop07": "Bip-bop 07", + "sound.option.bipbop08": "Bip-bop 08", + "sound.option.bipbop09": "Bip-bop 09", + "sound.option.bipbop10": "Bip-bop 10", + "sound.option.staplebops01": "Staplebops 01", + "sound.option.staplebops02": "Staplebops 02", + "sound.option.staplebops03": "Staplebops 03", + "sound.option.staplebops04": "Staplebops 04", + "sound.option.staplebops05": "Staplebops 05", + "sound.option.staplebops06": "Staplebops 06", + "sound.option.staplebops07": "Staplebops 07", + "sound.option.nope01": "Nei 01", + "sound.option.nope02": "Nei 02", + "sound.option.nope03": "Nei 03", + "sound.option.nope04": "Nei 04", + "sound.option.nope05": "Nei 05", + "sound.option.nope06": "Nei 06", + "sound.option.nope07": "Nei 07", + "sound.option.nope08": "Nei 08", + "sound.option.nope09": "Nei 09", + "sound.option.nope10": "Nei 10", + "sound.option.nope11": "Nei 11", + "sound.option.nope12": "Nei 12", + "sound.option.yup01": "Ja 01", + "sound.option.yup02": "Ja 02", + "sound.option.yup03": "Ja 03", + "sound.option.yup04": "Ja 04", + "sound.option.yup05": "Ja 05", + "sound.option.yup06": "Ja 06", "settings.general.notifications.agent.title": "Agent", "settings.general.notifications.agent.description": "Vis systemvarsel når agenten er ferdig eller trenger oppmerksomhet", @@ -541,6 +622,13 @@ export const dict = { "settings.providers.title": "Leverandører", "settings.providers.description": "Leverandørinnstillinger vil kunne konfigureres her.", + "settings.providers.section.connected": "Tilkoblede leverandører", + "settings.providers.connected.empty": "Ingen tilkoblede leverandører", + "settings.providers.section.popular": "Populære leverandører", + "settings.providers.tag.environment": "Miljø", + "settings.providers.tag.config": "Konfigurasjon", + "settings.providers.tag.custom": "Tilpasset", + "settings.providers.tag.other": "Annet", "settings.models.title": "Modeller", "settings.models.description": "Modellinnstillinger vil kunne konfigureres her.", "settings.agents.title": "Agenter", @@ -593,6 +681,10 @@ export const dict = { "settings.permissions.tool.doom_loop.title": "Doom Loop", "settings.permissions.tool.doom_loop.description": "Oppdager gjentatte verktøykall med identisk input", + "session.delete.failed.title": "Kunne ikke slette sesjon", + "session.delete.title": "Slett sesjon", + "session.delete.confirm": "Slette sesjonen \"{{name}}\"?", + "session.delete.button": "Slett sesjon", "workspace.new": "Nytt arbeidsområde", "workspace.type.local": "lokal", "workspace.type.sandbox": "sandkasse", @@ -603,6 +695,7 @@ export const dict = { "workspace.reset.failed.title": "Kunne ikke tilbakestille arbeidsområde", "workspace.reset.success.title": "Arbeidsområde tilbakestilt", "workspace.reset.success.description": "Arbeidsområdet samsvarer nå med standardgrenen.", + "workspace.error.stillPreparing": "Arbeidsområdet klargjøres fortsatt", "workspace.status.checking": "Sjekker for ikke-sammenslåtte endringer...", "workspace.status.error": "Kunne ikke bekrefte git-status.", "workspace.status.clean": "Ingen ikke-sammenslåtte endringer oppdaget.", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index db10262847..75b461487b 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "Motyw", "command.category.language": "Język", "command.category.file": "Plik", + "command.category.context": "Kontekst", "command.category.terminal": "Terminal", "command.category.model": "Model", "command.category.mcp": "MCP", @@ -42,7 +43,10 @@ export const dict = { "command.session.new": "Nowa sesja", "command.file.open": "Otwórz plik", "command.file.open.description": "Szukaj plików i poleceń", + "command.context.addSelection": "Dodaj zaznaczenie do kontekstu", + "command.context.addSelection.description": "Dodaj zaznaczone linie z bieżącego pliku", "command.terminal.toggle": "Przełącz terminal", + "command.fileTree.toggle": "Przełącz drzewo plików", "command.review.toggle": "Przełącz przegląd", "command.terminal.new": "Nowy terminal", "command.terminal.new.description": "Utwórz nową kartę terminala", @@ -137,6 +141,8 @@ export const dict = { "provider.connect.toast.connected.title": "Połączono {{provider}}", "provider.connect.toast.connected.description": "Modele {{provider}} są teraz dostępne do użycia.", + "provider.disconnect.toast.disconnected.title": "Rozłączono {{provider}}", + "provider.disconnect.toast.disconnected.description": "Modele {{provider}} nie są już dostępne.", "model.tag.free": "Darmowy", "model.tag.latest": "Najnowszy", "model.provider.anthropic": "Anthropic", @@ -159,6 +165,8 @@ export const dict = { "common.loading": "Ładowanie", "common.loading.ellipsis": "...", "common.cancel": "Anuluj", + "common.connect": "Połącz", + "common.disconnect": "Rozłącz", "common.submit": "Prześlij", "common.save": "Zapisz", "common.saving": "Zapisywanie...", @@ -167,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "Wpisz polecenie terminala...", "prompt.placeholder.normal": 'Zapytaj o cokolwiek... "{{example}}"', + "prompt.placeholder.summarizeComments": "Podsumuj komentarze…", + "prompt.placeholder.summarizeComment": "Podsumuj komentarz…", "prompt.mode.shell": "Terminal", "prompt.mode.shell.exit": "esc aby wyjść", @@ -270,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "Kolor", "dialog.project.edit.color.select": "Wybierz kolor {{color}}", + "dialog.project.edit.worktree.startup": "Skrypt uruchamiania przestrzeni roboczej", + "dialog.project.edit.worktree.startup.description": "Uruchamiany po utworzeniu nowej przestrzeni roboczej (drzewa roboczego).", + "dialog.project.edit.worktree.startup.placeholder": "np. bun install", "context.breakdown.title": "Podział kontekstu", "context.breakdown.note": 'Przybliżony podział tokenów wejściowych. "Inne" obejmuje definicje narzędzi i narzut.', "context.breakdown.system": "System", @@ -335,6 +348,9 @@ export const dict = { "toast.file.loadFailed.title": "Nie udało się załadować pliku", + "toast.file.listFailed.title": "Nie udało się wyświetlić listy plików", + "toast.context.noLineSelection.title": "Brak zaznaczenia linii", + "toast.context.noLineSelection.description": "Najpierw wybierz zakres linii w zakładce pliku.", "toast.session.share.copyFailed.title": "Nie udało się skopiować URL do schowka", "toast.session.share.success.title": "Sesja udostępniona", "toast.session.share.success.description": "Link udostępniania skopiowany do schowka!", @@ -410,8 +426,13 @@ export const dict = { "session.tab.context": "Kontekst", "session.panel.reviewAndFiles": "Przegląd i pliki", "session.review.filesChanged": "Zmieniono {{count}} plików", + "session.review.change.one": "Zmiana", + "session.review.change.other": "Zmiany", "session.review.loadingChanges": "Ładowanie zmian...", "session.review.empty": "Brak zmian w tej sesji", + "session.review.noChanges": "Brak zmian", + "session.files.selectToOpen": "Wybierz plik do otwarcia", + "session.files.all": "Wszystkie pliki", "session.messages.renderEarlier": "Renderuj wcześniejsze wiadomości", "session.messages.loadingEarlier": "Ładowanie wcześniejszych wiadomości...", "session.messages.loadEarlier": "Załaduj wcześniejsze wiadomości", @@ -488,7 +509,9 @@ export const dict = { "sidebar.project.recentSessions": "Ostatnie sesje", "sidebar.project.viewAllSessions": "Zobacz wszystkie sesje", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Pulpit", + "settings.section.server": "Serwer", "settings.tab.general": "Ogólne", "settings.tab.shortcuts": "Skróty", @@ -510,6 +533,7 @@ export const dict = { "font.option.hack": "Hack", "font.option.inconsolata": "Inconsolata", "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", "font.option.jetbrainsMono": "JetBrains Mono", "font.option.mesloLgs": "Meslo LGS", "font.option.robotoMono": "Roboto Mono", @@ -597,6 +621,13 @@ export const dict = { "settings.providers.title": "Dostawcy", "settings.providers.description": "Ustawienia dostawców będą tutaj konfigurowalne.", + "settings.providers.section.connected": "Połączeni dostawcy", + "settings.providers.connected.empty": "Brak połączonych dostawców", + "settings.providers.section.popular": "Popularni dostawcy", + "settings.providers.tag.environment": "Środowisko", + "settings.providers.tag.config": "Konfiguracja", + "settings.providers.tag.custom": "Niestandardowe", + "settings.providers.tag.other": "Inne", "settings.models.title": "Modele", "settings.models.description": "Ustawienia modeli będą tutaj konfigurowalne.", "settings.agents.title": "Agenci", @@ -663,6 +694,7 @@ export const dict = { "workspace.reset.failed.title": "Nie udało się zresetować przestrzeni roboczej", "workspace.reset.success.title": "Przestrzeń robocza zresetowana", "workspace.reset.success.description": "Przestrzeń robocza odpowiada teraz domyślnej gałęzi.", + "workspace.error.stillPreparing": "Przestrzeń robocza jest wciąż przygotowywana", "workspace.status.checking": "Sprawdzanie niezscalonych zmian...", "workspace.status.error": "Nie można zweryfikować statusu git.", "workspace.status.clean": "Nie wykryto niezscalonych zmian.", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index d8b94cb107..766c253e3b 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -8,6 +8,7 @@ export const dict = { "command.category.theme": "Тема", "command.category.language": "Язык", "command.category.file": "Файл", + "command.category.context": "Контекст", "command.category.terminal": "Терминал", "command.category.model": "Модель", "command.category.mcp": "MCP", @@ -42,7 +43,10 @@ export const dict = { "command.session.new": "Новая сессия", "command.file.open": "Открыть файл", "command.file.open.description": "Поиск файлов и команд", + "command.context.addSelection": "Добавить выделение в контекст", + "command.context.addSelection.description": "Добавить выбранные строки из текущего файла", "command.terminal.toggle": "Переключить терминал", + "command.fileTree.toggle": "Переключить дерево файлов", "command.review.toggle": "Переключить обзор", "command.terminal.new": "Новый терминал", "command.terminal.new.description": "Создать новую вкладку терминала", @@ -137,6 +141,8 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} подключён", "provider.connect.toast.connected.description": "Модели {{provider}} теперь доступны.", + "provider.disconnect.toast.disconnected.title": "{{provider}} отключён", + "provider.disconnect.toast.disconnected.description": "Модели {{provider}} больше недоступны.", "model.tag.free": "Бесплатно", "model.tag.latest": "Последняя", "model.provider.anthropic": "Anthropic", @@ -159,6 +165,8 @@ export const dict = { "common.loading": "Загрузка", "common.loading.ellipsis": "...", "common.cancel": "Отмена", + "common.connect": "Подключить", + "common.disconnect": "Отключить", "common.submit": "Отправить", "common.save": "Сохранить", "common.saving": "Сохранение...", @@ -167,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "Введите команду оболочки...", "prompt.placeholder.normal": 'Спросите что угодно... "{{example}}"', + "prompt.placeholder.summarizeComments": "Суммировать комментарии…", + "prompt.placeholder.summarizeComment": "Суммировать комментарий…", "prompt.mode.shell": "Оболочка", "prompt.mode.shell.exit": "esc для выхода", @@ -270,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "Цвет", "dialog.project.edit.color.select": "Выбрать цвет {{color}}", + "dialog.project.edit.worktree.startup": "Скрипт запуска рабочего пространства", + "dialog.project.edit.worktree.startup.description": "Запускается после создания нового рабочего пространства (worktree).", + "dialog.project.edit.worktree.startup.placeholder": "например, bun install", "context.breakdown.title": "Разбивка контекста", "context.breakdown.note": 'Приблизительная разбивка входных токенов. "Другое" включает определения инструментов и накладные расходы.', @@ -336,6 +349,9 @@ export const dict = { "toast.file.loadFailed.title": "Не удалось загрузить файл", + "toast.file.listFailed.title": "Не удалось получить список файлов", + "toast.context.noLineSelection.title": "Нет выделения строк", + "toast.context.noLineSelection.description": "Сначала выберите диапазон строк во вкладке файла.", "toast.session.share.copyFailed.title": "Не удалось скопировать URL в буфер обмена", "toast.session.share.success.title": "Сессия опубликована", "toast.session.share.success.description": "URL скопирован в буфер обмена!", @@ -412,8 +428,13 @@ export const dict = { "session.tab.context": "Контекст", "session.panel.reviewAndFiles": "Обзор и файлы", "session.review.filesChanged": "{{count}} файлов изменено", + "session.review.change.one": "Изменение", + "session.review.change.other": "Изменения", "session.review.loadingChanges": "Загрузка изменений...", "session.review.empty": "Изменений в этой сессии пока нет", + "session.review.noChanges": "Нет изменений", + "session.files.selectToOpen": "Выберите файл, чтобы открыть", + "session.files.all": "Все файлы", "session.messages.renderEarlier": "Показать предыдущие сообщения", "session.messages.loadingEarlier": "Загрузка предыдущих сообщений...", "session.messages.loadEarlier": "Загрузить предыдущие сообщения", @@ -491,7 +512,9 @@ export const dict = { "sidebar.project.recentSessions": "Недавние сессии", "sidebar.project.viewAllSessions": "Посмотреть все сессии", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Приложение", + "settings.section.server": "Сервер", "settings.tab.general": "Основные", "settings.tab.shortcuts": "Горячие клавиши", @@ -513,6 +536,7 @@ export const dict = { "font.option.hack": "Hack", "font.option.inconsolata": "Inconsolata", "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", "font.option.jetbrainsMono": "JetBrains Mono", "font.option.mesloLgs": "Meslo LGS", "font.option.robotoMono": "Roboto Mono", @@ -600,6 +624,13 @@ export const dict = { "settings.providers.title": "Провайдеры", "settings.providers.description": "Настройки провайдеров будут доступны здесь.", + "settings.providers.section.connected": "Подключённые провайдеры", + "settings.providers.connected.empty": "Нет подключённых провайдеров", + "settings.providers.section.popular": "Популярные провайдеры", + "settings.providers.tag.environment": "Среда", + "settings.providers.tag.config": "Конфигурация", + "settings.providers.tag.custom": "Пользовательский", + "settings.providers.tag.other": "Другое", "settings.models.title": "Модели", "settings.models.description": "Настройки моделей будут доступны здесь.", "settings.agents.title": "Агенты", @@ -667,6 +698,7 @@ export const dict = { "workspace.reset.failed.title": "Не удалось сбросить рабочее пространство", "workspace.reset.success.title": "Рабочее пространство сброшено", "workspace.reset.success.description": "Рабочее пространство теперь соответствует ветке по умолчанию.", + "workspace.error.stillPreparing": "Рабочее пространство всё ещё готовится", "workspace.status.checking": "Проверка наличия неслитых изменений...", "workspace.status.error": "Не удалось проверить статус git.", "workspace.status.clean": "Неслитые изменения не обнаружены.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index cfecb739d8..863df08df9 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -12,6 +12,7 @@ export const dict = { "command.category.theme": "主题", "command.category.language": "语言", "command.category.file": "文件", + "command.category.context": "上下文", "command.category.terminal": "终端", "command.category.model": "模型", "command.category.mcp": "MCP", @@ -19,6 +20,7 @@ export const dict = { "command.category.permissions": "权限", "command.category.workspace": "工作区", + "command.category.settings": "设置", "theme.scheme.system": "系统", "theme.scheme.light": "浅色", "theme.scheme.dark": "深色", @@ -27,6 +29,7 @@ export const dict = { "command.project.open": "打开项目", "command.provider.connect": "连接提供商", "command.server.switch": "切换服务器", + "command.settings.open": "打开设置", "command.session.previous": "上一个会话", "command.session.next": "下一个会话", "command.session.archive": "归档会话", @@ -44,7 +47,10 @@ export const dict = { "command.session.new": "新建会话", "command.file.open": "打开文件", "command.file.open.description": "搜索文件和命令", + "command.context.addSelection": "将所选内容添加到上下文", + "command.context.addSelection.description": "添加当前文件中选中的行", "command.terminal.toggle": "切换终端", + "command.fileTree.toggle": "切换文件树", "command.review.toggle": "切换审查", "command.terminal.new": "新建终端", "command.terminal.new.description": "创建新的终端标签页", @@ -119,6 +125,7 @@ export const dict = { "provider.connect.opencodeZen.line1": "OpenCode Zen 为你提供一组精选的可靠优化模型,用于代码智能体。", "provider.connect.opencodeZen.line2": "只需一个 API 密钥,你就能使用 Claude、GPT、Gemini、GLM 等模型。", "provider.connect.opencodeZen.visit.prefix": "访问 ", + "provider.connect.opencodeZen.visit.link": "opencode.ai/zen", "provider.connect.opencodeZen.visit.suffix": " 获取你的 API 密钥。", "provider.connect.oauth.code.visit.prefix": "访问 ", "provider.connect.oauth.code.visit.link": "此链接", @@ -134,13 +141,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} 已连接", "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。", + "provider.disconnect.toast.disconnected.title": "{{provider}} 已断开连接", + "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", "model.tag.free": "免费", "model.tag.latest": "最新", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "文本", + "model.input.image": "图像", + "model.input.audio": "音频", + "model.input.video": "视频", + "model.input.pdf": "pdf", + "model.tooltip.allows": "支持: {{inputs}}", + "model.tooltip.reasoning.allowed": "支持推理", + "model.tooltip.reasoning.none": "不支持推理", + "model.tooltip.context": "上下文上限 {{limit}}", "common.search.placeholder": "搜索", "common.goBack": "返回", "common.loading": "加载中", + "common.loading.ellipsis": "...", "common.cancel": "取消", + "common.connect": "连接", + "common.disconnect": "断开连接", "common.submit": "提交", "common.save": "保存", "common.saving": "保存中...", @@ -149,6 +175,8 @@ export const dict = { "prompt.placeholder.shell": "输入 shell 命令...", "prompt.placeholder.normal": '随便问点什么... "{{example}}"', + "prompt.placeholder.summarizeComments": "总结评论…", + "prompt.placeholder.summarizeComment": "总结该评论…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "按 esc 退出", @@ -251,6 +279,9 @@ export const dict = { "dialog.project.edit.color": "颜色", "dialog.project.edit.color.select": "选择{{color}}颜色", + "dialog.project.edit.worktree.startup": "工作区启动脚本", + "dialog.project.edit.worktree.startup.description": "在创建新的工作区 (worktree) 后运行。", + "dialog.project.edit.worktree.startup.placeholder": "例如 bun install", "context.breakdown.title": "上下文拆分", "context.breakdown.note": "输入 token 的大致拆分。“其他”包含工具定义和开销。", "context.breakdown.system": "系统", @@ -316,6 +347,9 @@ export const dict = { "toast.file.loadFailed.title": "加载文件失败", + "toast.file.listFailed.title": "列出文件失败", + "toast.context.noLineSelection.title": "未选择行", + "toast.context.noLineSelection.description": "请先在文件标签中选择行范围。", "toast.session.share.copyFailed.title": "无法复制链接到剪贴板", "toast.session.share.success.title": "会话已分享", "toast.session.share.success.description": "分享链接已复制到剪贴板", @@ -388,13 +422,19 @@ export const dict = { "session.tab.context": "上下文", "session.panel.reviewAndFiles": "审查和文件", "session.review.filesChanged": "{{count}} 个文件变更", + "session.review.change.one": "更改", + "session.review.change.other": "更改", "session.review.loadingChanges": "正在加载更改...", "session.review.empty": "此会话暂无更改", + "session.review.noChanges": "无更改", + "session.files.selectToOpen": "选择要打开的文件", + "session.files.all": "所有文件", "session.messages.renderEarlier": "显示更早的消息", "session.messages.loadingEarlier": "正在加载更早的消息...", "session.messages.loadEarlier": "加载更早的消息", "session.messages.loading": "正在加载消息...", + "session.messages.jumpToLatest": "跳转到最新", "session.context.addToContext": "将 {{selection}} 添加到上下文", "session.new.worktree.main": "主分支", @@ -434,6 +474,8 @@ export const dict = { "terminal.title.numbered": "终端 {{number}}", "terminal.close": "关闭终端", + "terminal.connectionLost.title": "连接已丢失", + "terminal.connectionLost.description": "终端连接已中断。这可能发生在服务器重启时。", "common.closeTab": "关闭标签页", "common.dismiss": "忽略", "common.requestFailed": "请求失败", @@ -447,6 +489,8 @@ export const dict = { "common.edit": "编辑", "common.loadMore": "加载更多", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "切换菜单", "sidebar.nav.projectsAndSessions": "项目和会话", "sidebar.settings": "设置", "sidebar.help": "帮助", @@ -458,7 +502,9 @@ export const dict = { "sidebar.project.recentSessions": "最近会话", "sidebar.project.viewAllSessions": "查看全部会话", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "桌面", + "settings.section.server": "服务器", "settings.tab.general": "通用", "settings.tab.shortcuts": "快捷键", @@ -475,6 +521,63 @@ export const dict = { "settings.general.row.font.title": "字体", "settings.general.row.font.description": "自定义代码块使用的等宽字体", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "警报 01", + "sound.option.alert02": "警报 02", + "sound.option.alert03": "警报 03", + "sound.option.alert04": "警报 04", + "sound.option.alert05": "警报 05", + "sound.option.alert06": "警报 06", + "sound.option.alert07": "警报 07", + "sound.option.alert08": "警报 08", + "sound.option.alert09": "警报 09", + "sound.option.alert10": "警报 10", + "sound.option.bipbop01": "哔啵 01", + "sound.option.bipbop02": "哔啵 02", + "sound.option.bipbop03": "哔啵 03", + "sound.option.bipbop04": "哔啵 04", + "sound.option.bipbop05": "哔啵 05", + "sound.option.bipbop06": "哔啵 06", + "sound.option.bipbop07": "哔啵 07", + "sound.option.bipbop08": "哔啵 08", + "sound.option.bipbop09": "哔啵 09", + "sound.option.bipbop10": "哔啵 10", + "sound.option.staplebops01": "斯泰普博普斯 01", + "sound.option.staplebops02": "斯泰普博普斯 02", + "sound.option.staplebops03": "斯泰普博普斯 03", + "sound.option.staplebops04": "斯泰普博普斯 04", + "sound.option.staplebops05": "斯泰普博普斯 05", + "sound.option.staplebops06": "斯泰普博普斯 06", + "sound.option.staplebops07": "斯泰普博普斯 07", + "sound.option.nope01": "否 01", + "sound.option.nope02": "否 02", + "sound.option.nope03": "否 03", + "sound.option.nope04": "否 04", + "sound.option.nope05": "否 05", + "sound.option.nope06": "否 06", + "sound.option.nope07": "否 07", + "sound.option.nope08": "否 08", + "sound.option.nope09": "否 09", + "sound.option.nope10": "否 10", + "sound.option.nope11": "否 11", + "sound.option.nope12": "否 12", + "sound.option.yup01": "是 01", + "sound.option.yup02": "是 02", + "sound.option.yup03": "是 03", + "sound.option.yup04": "是 04", + "sound.option.yup05": "是 05", + "sound.option.yup06": "是 06", "settings.general.notifications.agent.title": "智能体", "settings.general.notifications.agent.description": "当智能体完成或需要注意时显示系统通知", "settings.general.notifications.permissions.title": "权限", @@ -509,6 +612,13 @@ export const dict = { "settings.providers.title": "提供商", "settings.providers.description": "提供商设置将在此处可配置。", + "settings.providers.section.connected": "已连接的提供商", + "settings.providers.connected.empty": "没有已连接的提供商", + "settings.providers.section.popular": "热门提供商", + "settings.providers.tag.environment": "环境", + "settings.providers.tag.config": "配置", + "settings.providers.tag.custom": "自定义", + "settings.providers.tag.other": "其他", "settings.models.title": "模型", "settings.models.description": "模型设置将在此处可配置。", "settings.agents.title": "智能体", @@ -575,6 +685,7 @@ export const dict = { "workspace.reset.failed.title": "重置工作区失败", "workspace.reset.success.title": "工作区已重置", "workspace.reset.success.description": "工作区已与默认分支保持一致。", + "workspace.error.stillPreparing": "工作区仍在准备中", "workspace.status.checking": "正在检查未合并的更改...", "workspace.status.error": "无法验证 git 状态。", "workspace.status.clean": "未检测到未合并的更改。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index 050c160cdf..98e74e805b 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -12,6 +12,7 @@ export const dict = { "command.category.theme": "主題", "command.category.language": "語言", "command.category.file": "檔案", + "command.category.context": "上下文", "command.category.terminal": "終端機", "command.category.model": "模型", "command.category.mcp": "MCP", @@ -19,6 +20,7 @@ export const dict = { "command.category.permissions": "權限", "command.category.workspace": "工作區", + "command.category.settings": "設定", "theme.scheme.system": "系統", "theme.scheme.light": "淺色", "theme.scheme.dark": "深色", @@ -27,6 +29,7 @@ export const dict = { "command.project.open": "開啟專案", "command.provider.connect": "連接提供者", "command.server.switch": "切換伺服器", + "command.settings.open": "開啟設定", "command.session.previous": "上一個工作階段", "command.session.next": "下一個工作階段", "command.session.archive": "封存工作階段", @@ -44,7 +47,10 @@ export const dict = { "command.session.new": "新增工作階段", "command.file.open": "開啟檔案", "command.file.open.description": "搜尋檔案和命令", + "command.context.addSelection": "將選取內容加入上下文", + "command.context.addSelection.description": "加入目前檔案中選取的行", "command.terminal.toggle": "切換終端機", + "command.fileTree.toggle": "切換檔案樹", "command.review.toggle": "切換審查", "command.terminal.new": "新增終端機", "command.terminal.new.description": "建立新的終端機標籤頁", @@ -136,13 +142,32 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} 已連線", "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", + "provider.disconnect.toast.disconnected.title": "{{provider}} 已中斷連線", + "provider.disconnect.toast.disconnected.description": "{{provider}} 模型已不再可用。", "model.tag.free": "免費", "model.tag.latest": "最新", + "model.provider.anthropic": "Anthropic", + "model.provider.openai": "OpenAI", + "model.provider.google": "Google", + "model.provider.xai": "xAI", + "model.provider.meta": "Meta", + "model.input.text": "文字", + "model.input.image": "圖片", + "model.input.audio": "音訊", + "model.input.video": "影片", + "model.input.pdf": "pdf", + "model.tooltip.allows": "支援: {{inputs}}", + "model.tooltip.reasoning.allowed": "支援推理", + "model.tooltip.reasoning.none": "不支援推理", + "model.tooltip.context": "上下文上限 {{limit}}", "common.search.placeholder": "搜尋", "common.goBack": "返回", "common.loading": "載入中", + "common.loading.ellipsis": "...", "common.cancel": "取消", + "common.connect": "連線", + "common.disconnect": "中斷連線", "common.submit": "提交", "common.save": "儲存", "common.saving": "儲存中...", @@ -151,6 +176,8 @@ export const dict = { "prompt.placeholder.shell": "輸入 shell 命令...", "prompt.placeholder.normal": '隨便問點什麼... "{{example}}"', + "prompt.placeholder.summarizeComments": "摘要評論…", + "prompt.placeholder.summarizeComment": "摘要這則評論…", "prompt.mode.shell": "Shell", "prompt.mode.shell.exit": "按 esc 退出", @@ -253,6 +280,9 @@ export const dict = { "dialog.project.edit.color": "顏色", "dialog.project.edit.color.select": "選擇{{color}}顏色", + "dialog.project.edit.worktree.startup": "工作區啟動腳本", + "dialog.project.edit.worktree.startup.description": "在建立新的工作區 (worktree) 後執行。", + "dialog.project.edit.worktree.startup.placeholder": "例如 bun install", "context.breakdown.title": "上下文拆分", "context.breakdown.note": "輸入 token 的大致拆分。「其他」包含工具定義和額外開銷。", "context.breakdown.system": "系統", @@ -318,6 +348,9 @@ export const dict = { "toast.file.loadFailed.title": "載入檔案失敗", + "toast.file.listFailed.title": "列出檔案失敗", + "toast.context.noLineSelection.title": "未選取行", + "toast.context.noLineSelection.description": "請先在檔案分頁中選取行範圍。", "toast.session.share.copyFailed.title": "無法複製連結到剪貼簿", "toast.session.share.success.title": "工作階段已分享", "toast.session.share.success.description": "分享連結已複製到剪貼簿", @@ -390,13 +423,19 @@ export const dict = { "session.tab.context": "上下文", "session.panel.reviewAndFiles": "審查與檔案", "session.review.filesChanged": "{{count}} 個檔案變更", + "session.review.change.one": "變更", + "session.review.change.other": "變更", "session.review.loadingChanges": "正在載入變更...", "session.review.empty": "此工作階段暫無變更", + "session.review.noChanges": "沒有變更", + "session.files.selectToOpen": "選取要開啟的檔案", + "session.files.all": "所有檔案", "session.messages.renderEarlier": "顯示更早的訊息", "session.messages.loadingEarlier": "正在載入更早的訊息...", "session.messages.loadEarlier": "載入更早的訊息", "session.messages.loading": "正在載入訊息...", + "session.messages.jumpToLatest": "跳到最新", "session.context.addToContext": "將 {{selection}} 新增到上下文", "session.new.worktree.main": "主分支", @@ -436,6 +475,8 @@ export const dict = { "terminal.title.numbered": "終端機 {{number}}", "terminal.close": "關閉終端機", + "terminal.connectionLost.title": "連線中斷", + "terminal.connectionLost.description": "終端機連線已中斷。這可能會在伺服器重新啟動時發生。", "common.closeTab": "關閉標籤頁", "common.dismiss": "忽略", "common.requestFailed": "要求失敗", @@ -449,6 +490,8 @@ export const dict = { "common.edit": "編輯", "common.loadMore": "載入更多", + "common.key.esc": "ESC", + "sidebar.menu.toggle": "切換選單", "sidebar.nav.projectsAndSessions": "專案與工作階段", "sidebar.settings": "設定", "sidebar.help": "說明", @@ -460,7 +503,9 @@ export const dict = { "sidebar.project.recentSessions": "最近工作階段", "sidebar.project.viewAllSessions": "查看全部工作階段", + "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "桌面", + "settings.section.server": "伺服器", "settings.tab.general": "一般", "settings.tab.shortcuts": "快速鍵", @@ -477,6 +522,63 @@ export const dict = { "settings.general.row.font.title": "字型", "settings.general.row.font.description": "自訂程式碼區塊使用的等寬字型", + "font.option.ibmPlexMono": "IBM Plex Mono", + "font.option.cascadiaCode": "Cascadia Code", + "font.option.firaCode": "Fira Code", + "font.option.hack": "Hack", + "font.option.inconsolata": "Inconsolata", + "font.option.intelOneMono": "Intel One Mono", + "font.option.iosevka": "Iosevka", + "font.option.jetbrainsMono": "JetBrains Mono", + "font.option.mesloLgs": "Meslo LGS", + "font.option.robotoMono": "Roboto Mono", + "font.option.sourceCodePro": "Source Code Pro", + "font.option.ubuntuMono": "Ubuntu Mono", + "sound.option.alert01": "警報 01", + "sound.option.alert02": "警報 02", + "sound.option.alert03": "警報 03", + "sound.option.alert04": "警報 04", + "sound.option.alert05": "警報 05", + "sound.option.alert06": "警報 06", + "sound.option.alert07": "警報 07", + "sound.option.alert08": "警報 08", + "sound.option.alert09": "警報 09", + "sound.option.alert10": "警報 10", + "sound.option.bipbop01": "嗶啵 01", + "sound.option.bipbop02": "嗶啵 02", + "sound.option.bipbop03": "嗶啵 03", + "sound.option.bipbop04": "嗶啵 04", + "sound.option.bipbop05": "嗶啵 05", + "sound.option.bipbop06": "嗶啵 06", + "sound.option.bipbop07": "嗶啵 07", + "sound.option.bipbop08": "嗶啵 08", + "sound.option.bipbop09": "嗶啵 09", + "sound.option.bipbop10": "嗶啵 10", + "sound.option.staplebops01": "斯泰普博普斯 01", + "sound.option.staplebops02": "斯泰普博普斯 02", + "sound.option.staplebops03": "斯泰普博普斯 03", + "sound.option.staplebops04": "斯泰普博普斯 04", + "sound.option.staplebops05": "斯泰普博普斯 05", + "sound.option.staplebops06": "斯泰普博普斯 06", + "sound.option.staplebops07": "斯泰普博普斯 07", + "sound.option.nope01": "否 01", + "sound.option.nope02": "否 02", + "sound.option.nope03": "否 03", + "sound.option.nope04": "否 04", + "sound.option.nope05": "否 05", + "sound.option.nope06": "否 06", + "sound.option.nope07": "否 07", + "sound.option.nope08": "否 08", + "sound.option.nope09": "否 09", + "sound.option.nope10": "否 10", + "sound.option.nope11": "否 11", + "sound.option.nope12": "否 12", + "sound.option.yup01": "是 01", + "sound.option.yup02": "是 02", + "sound.option.yup03": "是 03", + "sound.option.yup04": "是 04", + "sound.option.yup05": "是 05", + "sound.option.yup06": "是 06", "settings.general.notifications.agent.title": "代理程式", "settings.general.notifications.agent.description": "當代理程式完成或需要注意時顯示系統通知", "settings.general.notifications.permissions.title": "權限", @@ -511,6 +613,13 @@ export const dict = { "settings.providers.title": "提供者", "settings.providers.description": "提供者設定將在此處可設定。", + "settings.providers.section.connected": "已連線的提供商", + "settings.providers.connected.empty": "沒有已連線的提供商", + "settings.providers.section.popular": "熱門提供商", + "settings.providers.tag.environment": "環境", + "settings.providers.tag.config": "配置", + "settings.providers.tag.custom": "自訂", + "settings.providers.tag.other": "其他", "settings.models.title": "模型", "settings.models.description": "模型設定將在此處可設定。", "settings.agents.title": "代理程式", @@ -577,6 +686,7 @@ export const dict = { "workspace.reset.failed.title": "重設工作區失敗", "workspace.reset.success.title": "工作區已重設", "workspace.reset.success.description": "工作區已與預設分支保持一致。", + "workspace.error.stillPreparing": "工作區仍在準備中", "workspace.status.checking": "正在檢查未合併的變更...", "workspace.status.error": "無法驗證 git 狀態。", "workspace.status.clean": "未偵測到未合併的變更。", diff --git a/packages/ui/src/i18n/ar.ts b/packages/ui/src/i18n/ar.ts index 27bd3a75da..a4179a4144 100644 --- a/packages/ui/src/i18n/ar.ts +++ b/packages/ui/src/i18n/ar.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "توسيع الكل", "ui.sessionReview.collapseAll": "طي الكل", + "ui.sessionReview.change.added": "مضاف", + "ui.sessionReview.change.removed": "محذوف", + "ui.lineComment.label.prefix": "تعليق على ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "جارٍ التعليق على ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "أضف تعليقًا", + "ui.lineComment.submit": "تعليق", "ui.sessionTurn.steps.show": "إظهار الخطوات", "ui.sessionTurn.steps.hide": "إخفاء الخطوات", "ui.sessionTurn.summary.response": "استجابة", diff --git a/packages/ui/src/i18n/br.ts b/packages/ui/src/i18n/br.ts index d8e987a06b..b8e6fdbe53 100644 --- a/packages/ui/src/i18n/br.ts +++ b/packages/ui/src/i18n/br.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "Expandir tudo", "ui.sessionReview.collapseAll": "Recolher tudo", + "ui.sessionReview.change.added": "Adicionado", + "ui.sessionReview.change.removed": "Removido", + "ui.lineComment.label.prefix": "Comentar em ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Comentando em ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Adicionar comentário", + "ui.lineComment.submit": "Comentar", "ui.sessionTurn.steps.show": "Mostrar passos", "ui.sessionTurn.steps.hide": "Ocultar passos", "ui.sessionTurn.summary.response": "Resposta", diff --git a/packages/ui/src/i18n/da.ts b/packages/ui/src/i18n/da.ts index dc9dab5a8f..6e8f6b46e2 100644 --- a/packages/ui/src/i18n/da.ts +++ b/packages/ui/src/i18n/da.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "Udvid alle", "ui.sessionReview.collapseAll": "Skjul alle", + "ui.sessionReview.change.added": "Tilføjet", + "ui.sessionReview.change.removed": "Fjernet", + "ui.lineComment.label.prefix": "Kommenter på ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Kommenterer på ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Tilføj kommentar", + "ui.lineComment.submit": "Kommenter", "ui.sessionTurn.steps.show": "Vis trin", "ui.sessionTurn.steps.hide": "Skjul trin", "ui.sessionTurn.summary.response": "Svar", diff --git a/packages/ui/src/i18n/de.ts b/packages/ui/src/i18n/de.ts index 05c024fc26..6d20860232 100644 --- a/packages/ui/src/i18n/de.ts +++ b/packages/ui/src/i18n/de.ts @@ -9,6 +9,14 @@ export const dict = { "ui.sessionReview.expandAll": "Alle erweitern", "ui.sessionReview.collapseAll": "Alle reduzieren", + "ui.sessionReview.change.added": "Hinzugefügt", + "ui.sessionReview.change.removed": "Entfernt", + "ui.lineComment.label.prefix": "Kommentar zu ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Kommentiere ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Kommentar hinzufügen", + "ui.lineComment.submit": "Kommentieren", "ui.sessionTurn.steps.show": "Schritte anzeigen", "ui.sessionTurn.steps.hide": "Schritte ausblenden", "ui.sessionTurn.summary.response": "Antwort", diff --git a/packages/ui/src/i18n/es.ts b/packages/ui/src/i18n/es.ts index f836159616..19df81a6b9 100644 --- a/packages/ui/src/i18n/es.ts +++ b/packages/ui/src/i18n/es.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "Expandir todo", "ui.sessionReview.collapseAll": "Colapsar todo", + "ui.sessionReview.change.added": "Añadido", + "ui.sessionReview.change.removed": "Eliminado", + "ui.lineComment.label.prefix": "Comentar en ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Comentando en ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Añadir comentario", + "ui.lineComment.submit": "Comentar", "ui.sessionTurn.steps.show": "Mostrar pasos", "ui.sessionTurn.steps.hide": "Ocultar pasos", "ui.sessionTurn.summary.response": "Respuesta", diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index c479411995..b563f9a028 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "Tout développer", "ui.sessionReview.collapseAll": "Tout réduire", + "ui.sessionReview.change.added": "Ajouté", + "ui.sessionReview.change.removed": "Supprimé", + "ui.lineComment.label.prefix": "Commenter sur ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Commentaire sur ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Ajouter un commentaire", + "ui.lineComment.submit": "Commenter", "ui.sessionTurn.steps.show": "Afficher les étapes", "ui.sessionTurn.steps.hide": "Masquer les étapes", "ui.sessionTurn.summary.response": "Réponse", diff --git a/packages/ui/src/i18n/ja.ts b/packages/ui/src/i18n/ja.ts index 13cf0e3cb1..7bd4d8e401 100644 --- a/packages/ui/src/i18n/ja.ts +++ b/packages/ui/src/i18n/ja.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "すべて展開", "ui.sessionReview.collapseAll": "すべて折りたたむ", + "ui.sessionReview.change.added": "追加", + "ui.sessionReview.change.removed": "削除", + "ui.lineComment.label.prefix": "", + "ui.lineComment.label.suffix": "へのコメント", + "ui.lineComment.editorLabel.prefix": "", + "ui.lineComment.editorLabel.suffix": "へのコメントを作成中", + "ui.lineComment.placeholder": "コメントを追加", + "ui.lineComment.submit": "コメント", "ui.sessionTurn.steps.show": "ステップを表示", "ui.sessionTurn.steps.hide": "ステップを隠す", "ui.sessionTurn.summary.response": "応答", diff --git a/packages/ui/src/i18n/ko.ts b/packages/ui/src/i18n/ko.ts index 9c3fbaa357..b83d7ef375 100644 --- a/packages/ui/src/i18n/ko.ts +++ b/packages/ui/src/i18n/ko.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "모두 펼치기", "ui.sessionReview.collapseAll": "모두 접기", + "ui.sessionReview.change.added": "추가됨", + "ui.sessionReview.change.removed": "삭제됨", + "ui.lineComment.label.prefix": "", + "ui.lineComment.label.suffix": "에 댓글 달기", + "ui.lineComment.editorLabel.prefix": "", + "ui.lineComment.editorLabel.suffix": "에 댓글 작성 중", + "ui.lineComment.placeholder": "댓글 추가", + "ui.lineComment.submit": "댓글", "ui.sessionTurn.steps.show": "단계 표시", "ui.sessionTurn.steps.hide": "단계 숨기기", "ui.sessionTurn.summary.response": "응답", diff --git a/packages/ui/src/i18n/no.ts b/packages/ui/src/i18n/no.ts index eefaef2a46..c9a7481c94 100644 --- a/packages/ui/src/i18n/no.ts +++ b/packages/ui/src/i18n/no.ts @@ -8,6 +8,14 @@ export const dict: Record = { "ui.sessionReview.expandAll": "Utvid alle", "ui.sessionReview.collapseAll": "Fold sammen alle", + "ui.sessionReview.change.added": "Lagt til", + "ui.sessionReview.change.removed": "Fjernet", + "ui.lineComment.label.prefix": "Kommenter på ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Kommenterer på ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Legg til kommentar", + "ui.lineComment.submit": "Kommenter", "ui.sessionTurn.steps.show": "Vis trinn", "ui.sessionTurn.steps.hide": "Skjul trinn", "ui.sessionTurn.summary.response": "Svar", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index f42955d7f4..5531a7473c 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "Rozwiń wszystko", "ui.sessionReview.collapseAll": "Zwiń wszystko", + "ui.sessionReview.change.added": "Dodano", + "ui.sessionReview.change.removed": "Usunięto", + "ui.lineComment.label.prefix": "Komentarz do ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Komentowanie: ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Dodaj komentarz", + "ui.lineComment.submit": "Skomentuj", "ui.sessionTurn.steps.show": "Pokaż kroki", "ui.sessionTurn.steps.hide": "Ukryj kroki", "ui.sessionTurn.summary.response": "Odpowiedź", diff --git a/packages/ui/src/i18n/ru.ts b/packages/ui/src/i18n/ru.ts index aae3df401f..8af9e05a5e 100644 --- a/packages/ui/src/i18n/ru.ts +++ b/packages/ui/src/i18n/ru.ts @@ -5,6 +5,14 @@ export const dict = { "ui.sessionReview.expandAll": "Развернуть всё", "ui.sessionReview.collapseAll": "Свернуть всё", + "ui.sessionReview.change.added": "Добавлено", + "ui.sessionReview.change.removed": "Удалено", + "ui.lineComment.label.prefix": "Комментарий к ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "Комментирование: ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "Добавить комментарий", + "ui.lineComment.submit": "Комментировать", "ui.sessionTurn.steps.show": "Показать шаги", "ui.sessionTurn.steps.hide": "Скрыть шаги", "ui.sessionTurn.summary.response": "Ответ", diff --git a/packages/ui/src/i18n/zh.ts b/packages/ui/src/i18n/zh.ts index 4986a5b841..c81f4725bd 100644 --- a/packages/ui/src/i18n/zh.ts +++ b/packages/ui/src/i18n/zh.ts @@ -9,6 +9,14 @@ export const dict = { "ui.sessionReview.expandAll": "全部展开", "ui.sessionReview.collapseAll": "全部收起", + "ui.sessionReview.change.added": "已添加", + "ui.sessionReview.change.removed": "已移除", + "ui.lineComment.label.prefix": "评论 ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "正在评论 ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "添加评论", + "ui.lineComment.submit": "评论", "ui.sessionTurn.steps.show": "显示步骤", "ui.sessionTurn.steps.hide": "隐藏步骤", "ui.sessionTurn.summary.response": "回复", diff --git a/packages/ui/src/i18n/zht.ts b/packages/ui/src/i18n/zht.ts index 37bf0c804c..906f602f98 100644 --- a/packages/ui/src/i18n/zht.ts +++ b/packages/ui/src/i18n/zht.ts @@ -9,6 +9,14 @@ export const dict = { "ui.sessionReview.expandAll": "全部展開", "ui.sessionReview.collapseAll": "全部收合", + "ui.sessionReview.change.added": "已新增", + "ui.sessionReview.change.removed": "已移除", + "ui.lineComment.label.prefix": "評論 ", + "ui.lineComment.label.suffix": "", + "ui.lineComment.editorLabel.prefix": "正在評論 ", + "ui.lineComment.editorLabel.suffix": "", + "ui.lineComment.placeholder": "新增評論", + "ui.lineComment.submit": "評論", "ui.sessionTurn.steps.show": "顯示步驟", "ui.sessionTurn.steps.hide": "隱藏步驟", "ui.sessionTurn.summary.response": "回覆", From d4e3acf17e3c275bb6cd27d41eb35cf7a7fc8767 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 13:36:36 -0600 Subject: [PATCH 064/294] fix(app): session sync issue --- packages/app/src/context/sync.tsx | 43 +++++++-------------- packages/ui/src/components/session-turn.tsx | 1 + 2 files changed, 16 insertions(+), 28 deletions(-) diff --git a/packages/app/src/context/sync.tsx b/packages/app/src/context/sync.tsx index 244e5e07bb..5c8e140c39 100644 --- a/packages/app/src/context/sync.tsx +++ b/packages/app/src/context/sync.tsx @@ -16,7 +16,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const sdk = useSDK() type Child = ReturnType<(typeof globalSync)["child"]> - type Store = Child[0] type Setter = Child[1] const current = createMemo(() => globalSync.child(sdk.directory)) @@ -43,18 +42,6 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ return Math.ceil(count / chunk) * chunk } - const hydrateMessages = (directory: string, store: Store, sessionID: string) => { - const key = keyFor(directory, sessionID) - if (meta.limit[key] !== undefined) return - - const messages = store.message[sessionID] - if (!messages) return - - const limit = limitFor(messages.length) - setMeta("limit", key, limit) - setMeta("complete", key, messages.length < limit) - } - const loadMessages = async (input: { directory: string client: typeof sdk.client @@ -150,21 +137,20 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ const directory = sdk.directory const client = sdk.client const [store, setStore] = globalSync.child(directory) + const key = keyFor(directory, sessionID) const hasSession = (() => { const match = Binary.search(store.session, sessionID, (s) => s.id) return match.found })() - hydrateMessages(directory, store, sessionID) - const hasMessages = store.message[sessionID] !== undefined - if (hasSession && hasMessages) return - - const key = keyFor(directory, sessionID) + const hydrated = meta.limit[key] !== undefined + if (hasSession && hasMessages && hydrated) return const pending = inflight.get(key) if (pending) return pending - const limit = meta.limit[key] ?? chunk + const count = store.message[sessionID]?.length ?? 0 + const limit = hydrated ? (meta.limit[key] ?? chunk) : limitFor(count) const sessionReq = hasSession ? Promise.resolve() @@ -184,15 +170,16 @@ export const { use: useSync, provider: SyncProvider } = createSimpleContext({ ) }) - const messagesReq = hasMessages - ? Promise.resolve() - : loadMessages({ - directory, - client, - setStore, - sessionID, - limit, - }) + const messagesReq = + hasMessages && hydrated + ? Promise.resolve() + : loadMessages({ + directory, + client, + setStore, + sessionID, + limit, + }) const promise = Promise.all([sessionReq, messagesReq]) .then(() => {}) diff --git a/packages/ui/src/components/session-turn.tsx b/packages/ui/src/components/session-turn.tsx index 4f8ce37fbb..5aa79e0b93 100644 --- a/packages/ui/src/components/session-turn.tsx +++ b/packages/ui/src/components/session-turn.tsx @@ -283,6 +283,7 @@ export function SessionTurn( const shellModePart = createMemo(() => { const p = parts() + if (p.length === 0) return if (!p.every((part) => part?.type === "text" && part?.synthetic)) return const msgs = assistantMessages() From 36b832880d54066648af08618dfd3f5bf70a532a Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 26 Jan 2026 19:37:41 +0000 Subject: [PATCH 065/294] chore: generate --- packages/app/src/i18n/de.ts | 6 ++++-- packages/app/src/i18n/es.ts | 6 ++++-- packages/app/src/i18n/fr.ts | 6 ++++-- packages/app/src/i18n/ja.ts | 6 ++++-- packages/app/src/i18n/ko.ts | 3 ++- packages/app/src/i18n/no.ts | 2 +- packages/app/src/i18n/pl.ts | 3 ++- packages/app/src/i18n/ru.ts | 3 ++- 8 files changed, 23 insertions(+), 12 deletions(-) diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index a54f19d180..3f40a9a796 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -286,7 +286,8 @@ export const dict = { "dialog.project.edit.color.select": "{{color}}-Farbe auswählen", "dialog.project.edit.worktree.startup": "Startup-Skript für Arbeitsbereich", - "dialog.project.edit.worktree.startup.description": "Wird nach dem Erstellen eines neuen Arbeitsbereichs (Worktree) ausgeführt.", + "dialog.project.edit.worktree.startup.description": + "Wird nach dem Erstellen eines neuen Arbeitsbereichs (Worktree) ausgeführt.", "dialog.project.edit.worktree.startup.placeholder": "z. B. bun install", "context.breakdown.title": "Kontext-Aufschlüsselung", "context.breakdown.note": @@ -488,7 +489,8 @@ export const dict = { "terminal.close": "Terminal schließen", "terminal.connectionLost.title": "Verbindung verloren", - "terminal.connectionLost.description": "Die Terminalverbindung wurde unterbrochen. Das kann passieren, wenn der Server neu startet.", + "terminal.connectionLost.description": + "Die Terminalverbindung wurde unterbrochen. Das kann passieren, wenn der Server neu startet.", "common.closeTab": "Tab schließen", "common.dismiss": "Verwerfen", "common.requestFailed": "Anfrage fehlgeschlagen", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 06f92e07f0..542143b40a 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -281,7 +281,8 @@ export const dict = { "dialog.project.edit.color.select": "Seleccionar color {{color}}", "dialog.project.edit.worktree.startup": "Script de inicio del espacio de trabajo", - "dialog.project.edit.worktree.startup.description": "Se ejecuta después de crear un nuevo espacio de trabajo (árbol de trabajo).", + "dialog.project.edit.worktree.startup.description": + "Se ejecuta después de crear un nuevo espacio de trabajo (árbol de trabajo).", "dialog.project.edit.worktree.startup.placeholder": "p. ej. bun install", "context.breakdown.title": "Desglose de Contexto", "context.breakdown.note": @@ -482,7 +483,8 @@ export const dict = { "terminal.close": "Cerrar terminal", "terminal.connectionLost.title": "Conexión perdida", - "terminal.connectionLost.description": "La conexión del terminal se interrumpió. Esto puede ocurrir cuando el servidor se reinicia.", + "terminal.connectionLost.description": + "La conexión del terminal se interrumpió. Esto puede ocurrir cuando el servidor se reinicia.", "common.closeTab": "Cerrar pestaña", "common.dismiss": "Descartar", "common.requestFailed": "Solicitud fallida", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index 3a3017f080..217e453918 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -281,7 +281,8 @@ export const dict = { "dialog.project.edit.color.select": "Sélectionner la couleur {{color}}", "dialog.project.edit.worktree.startup": "Script de démarrage de l'espace de travail", - "dialog.project.edit.worktree.startup.description": "S'exécute après la création d'un nouvel espace de travail (arbre de travail).", + "dialog.project.edit.worktree.startup.description": + "S'exécute après la création d'un nouvel espace de travail (arbre de travail).", "dialog.project.edit.worktree.startup.placeholder": "p. ex. bun install", "context.breakdown.title": "Répartition du contexte", "context.breakdown.note": @@ -487,7 +488,8 @@ export const dict = { "terminal.close": "Fermer le terminal", "terminal.connectionLost.title": "Connexion perdue", - "terminal.connectionLost.description": "La connexion au terminal a été interrompue. Cela peut arriver lorsque le serveur redémarre.", + "terminal.connectionLost.description": + "La connexion au terminal a été interrompue. Cela peut arriver lorsque le serveur redémarre.", "common.closeTab": "Fermer l'onglet", "common.dismiss": "Ignorer", "common.requestFailed": "La demande a échoué", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 7c629c3a01..02a787c026 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -280,7 +280,8 @@ export const dict = { "dialog.project.edit.color.select": "{{color}}の色を選択", "dialog.project.edit.worktree.startup": "ワークスペース起動スクリプト", - "dialog.project.edit.worktree.startup.description": "新しいワークスペース (ワークツリー) を作成した後に実行されます。", + "dialog.project.edit.worktree.startup.description": + "新しいワークスペース (ワークツリー) を作成した後に実行されます。", "dialog.project.edit.worktree.startup.placeholder": "例: bun install", "context.breakdown.title": "コンテキストの内訳", "context.breakdown.note": '入力トークンのおおよその内訳です。"その他"にはツールの定義やオーバーヘッドが含まれます。', @@ -479,7 +480,8 @@ export const dict = { "terminal.close": "ターミナルを閉じる", "terminal.connectionLost.title": "接続が失われました", - "terminal.connectionLost.description": "ターミナルの接続が中断されました。これはサーバーが再起動したときに発生することがあります。", + "terminal.connectionLost.description": + "ターミナルの接続が中断されました。これはサーバーが再起動したときに発生することがあります。", "common.closeTab": "タブを閉じる", "common.dismiss": "閉じる", "common.requestFailed": "リクエスト失敗", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 0a04d62511..8360b46ab0 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -481,7 +481,8 @@ export const dict = { "terminal.close": "터미널 닫기", "terminal.connectionLost.title": "연결 끊김", - "terminal.connectionLost.description": "터미널 연결이 중단되었습니다. 서버가 재시작하면 이런 일이 발생할 수 있습니다.", + "terminal.connectionLost.description": + "터미널 연결이 중단되었습니다. 서버가 재시작하면 이런 일이 발생할 수 있습니다.", "common.closeTab": "탭 닫기", "common.dismiss": "닫기", "common.requestFailed": "요청 실패", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c2992d57a6..3d3f382a6b 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -683,7 +683,7 @@ export const dict = { "session.delete.failed.title": "Kunne ikke slette sesjon", "session.delete.title": "Slett sesjon", - "session.delete.confirm": "Slette sesjonen \"{{name}}\"?", + "session.delete.confirm": 'Slette sesjonen "{{name}}"?', "session.delete.button": "Slett sesjon", "workspace.new": "Nytt arbeidsområde", "workspace.type.local": "lokal", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 75b461487b..5b7bec06bc 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -281,7 +281,8 @@ export const dict = { "dialog.project.edit.color.select": "Wybierz kolor {{color}}", "dialog.project.edit.worktree.startup": "Skrypt uruchamiania przestrzeni roboczej", - "dialog.project.edit.worktree.startup.description": "Uruchamiany po utworzeniu nowej przestrzeni roboczej (drzewa roboczego).", + "dialog.project.edit.worktree.startup.description": + "Uruchamiany po utworzeniu nowej przestrzeni roboczej (drzewa roboczego).", "dialog.project.edit.worktree.startup.placeholder": "np. bun install", "context.breakdown.title": "Podział kontekstu", "context.breakdown.note": 'Przybliżony podział tokenów wejściowych. "Inne" obejmuje definicje narzędzi i narzut.', diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 766c253e3b..ab579c683c 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -281,7 +281,8 @@ export const dict = { "dialog.project.edit.color.select": "Выбрать цвет {{color}}", "dialog.project.edit.worktree.startup": "Скрипт запуска рабочего пространства", - "dialog.project.edit.worktree.startup.description": "Запускается после создания нового рабочего пространства (worktree).", + "dialog.project.edit.worktree.startup.description": + "Запускается после создания нового рабочего пространства (worktree).", "dialog.project.edit.worktree.startup.placeholder": "например, bun install", "context.breakdown.title": "Разбивка контекста", "context.breakdown.note": From 810bc012b6ba5adfb42d8c41032491d2f659b1c5 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 15:45:57 +0000 Subject: [PATCH 066/294] fix(ui): update button styles and disconnect button size --- packages/app/src/components/settings-providers.tsx | 2 +- packages/ui/src/components/button.css | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index aec6d6c4f0..2b307a04b4 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -91,7 +91,7 @@ export const SettingsProviders: Component = () => { {type(item)}
- diff --git a/packages/ui/src/components/button.css b/packages/ui/src/components/button.css index 225e64c775..933c7ce5f2 100644 --- a/packages/ui/src/components/button.css +++ b/packages/ui/src/components/button.css @@ -148,7 +148,7 @@ padding: 0 12px 0 8px; } - gap: 8px; + gap: 4px; /* text-14-medium */ font-family: var(--font-family-sans); From e0e97e9d93c2dc19b41222e72b5b5965ad125e4a Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 16:00:30 +0000 Subject: [PATCH 067/294] fix(app): set provider row height to 56px --- packages/app/src/components/settings-providers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 2b307a04b4..a1819678eb 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -84,7 +84,7 @@ export const SettingsProviders: Component = () => { > {(item) => ( -
+
{item.name} @@ -107,7 +107,7 @@ export const SettingsProviders: Component = () => {
{(item) => ( -
+
{item.name} From 9346c1ae3ffa9781521bbc81a24141d3227ad41b Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 16:09:49 +0000 Subject: [PATCH 068/294] fix(app): add hover text for env-connected providers --- packages/app/src/components/settings-providers.tsx | 11 +++++++++-- 1 file changed, 9 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index a1819678eb..3f62199ccf 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -84,13 +84,20 @@ export const SettingsProviders: Component = () => { > {(item) => ( -
+
{item.name} {type(item)}
- + + Connected from your environment variables + + } + > From 7c96d704d306e72d5e806fd6db58204f0af421b2 Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 16:17:35 +0000 Subject: [PATCH 069/294] fix(app): use default cursor for env provider text --- packages/app/src/components/settings-providers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 3f62199ccf..7783c142d9 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -93,7 +93,7 @@ export const SettingsProviders: Component = () => { + Connected from your environment variables } From 6f3d4134722981adee00716eab77b961737d263d Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 16:20:01 +0000 Subject: [PATCH 070/294] feat(ui): add providers icon and use in settings --- packages/app/src/components/dialog-settings.tsx | 2 +- packages/ui/src/components/icon.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index dbae37c9d8..db26d4ca0c 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -38,7 +38,7 @@ export const DialogSettings: Component = () => { {language.t("settings.section.server")}
- + {language.t("settings.providers.title")} diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index f082605b49..0328553287 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -73,6 +73,7 @@ const icons = { selector: ``, "arrow-down-to-line": ``, link: ``, + providers: ``, } export interface IconProps extends ComponentProps<"svg"> { From ecd04a118a42fd558ee2e0442bcbdee260b4a10d Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 16:23:48 +0000 Subject: [PATCH 071/294] feat(ui): add models icon and use in settings --- packages/app/src/components/dialog-settings.tsx | 2 +- packages/ui/src/components/icon.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/dialog-settings.tsx b/packages/app/src/components/dialog-settings.tsx index db26d4ca0c..d77004d914 100644 --- a/packages/app/src/components/dialog-settings.tsx +++ b/packages/app/src/components/dialog-settings.tsx @@ -42,7 +42,7 @@ export const DialogSettings: Component = () => { {language.t("settings.providers.title")} - + {language.t("settings.models.title")}
diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index 0328553287..ddedddb36e 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -74,6 +74,7 @@ const icons = { "arrow-down-to-line": ``, link: ``, providers: ``, + models: ``, } export interface IconProps extends ComponentProps<"svg"> { From 0a572afd46ecdaaee3d411fe95f0ff9c26b8e1ab Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 16:29:24 +0000 Subject: [PATCH 072/294] fix(app): style view all button with interactive color and margin --- packages/app/src/components/settings-providers.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 7783c142d9..eec0ac7b9b 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -148,7 +148,7 @@ export const SettingsProviders: Component = () => {
- +
)} From 5a16d99b601a08deedf8e687c7cb70b605a6b02c Mon Sep 17 00:00:00 2001 From: David Hill Date: Mon, 26 Jan 2026 19:53:05 +0000 Subject: [PATCH 090/294] fix(app): disable tooltips in filetree tabs --- packages/app/src/pages/session.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index d717cc3d8c..9fcf68a87a 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2672,7 +2672,12 @@ export default function Page() { - openTab(file.tab(node.path))} /> + openTab(file.tab(node.path))} + />
From ae815cca3a2f39fb21ed63c92c3ab78c42f53389 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 14:32:49 -0600 Subject: [PATCH 091/294] test(app): fix e2e test --- packages/app/e2e/fixtures.ts | 51 +++++++++++++++++++++++++++++++++++- 1 file changed, 50 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/fixtures.ts b/packages/app/e2e/fixtures.ts index 721d60049c..c5315ff194 100644 --- a/packages/app/e2e/fixtures.ts +++ b/packages/app/e2e/fixtures.ts @@ -1,5 +1,5 @@ import { test as base, expect } from "@playwright/test" -import { createSdk, dirSlug, getWorktree, promptSelector, sessionPath } from "./utils" +import { createSdk, dirSlug, getWorktree, promptSelector, serverUrl, sessionPath } from "./utils" type TestFixtures = { sdk: ReturnType @@ -29,6 +29,55 @@ export const test = base.extend({ await use(createSdk(directory)) }, gotoSession: async ({ page, directory }, use) => { + await page.addInitScript( + (input: { directory: string; serverUrl: string }) => { + const key = "opencode.global.dat:server" + const raw = localStorage.getItem(key) + const parsed = (() => { + if (!raw) return undefined + try { + return JSON.parse(raw) as unknown + } catch { + return undefined + } + })() + + const store = parsed && typeof parsed === "object" ? (parsed as Record) : {} + const list = Array.isArray(store.list) ? store.list : [] + const lastProject = store.lastProject && typeof store.lastProject === "object" ? store.lastProject : {} + const projects = store.projects && typeof store.projects === "object" ? store.projects : {} + const nextProjects = { ...(projects as Record) } + + const add = (origin: string) => { + const current = nextProjects[origin] + const items = Array.isArray(current) ? current : [] + const existing = items.filter( + (p): p is { worktree: string; expanded?: boolean } => + !!p && + typeof p === "object" && + "worktree" in p && + typeof (p as { worktree?: unknown }).worktree === "string", + ) + + if (existing.some((p) => p.worktree === input.directory)) return + nextProjects[origin] = [{ worktree: input.directory, expanded: true }, ...existing] + } + + add("local") + add(input.serverUrl) + + localStorage.setItem( + key, + JSON.stringify({ + list, + projects: nextProjects, + lastProject, + }), + ) + }, + { directory, serverUrl }, + ) + const gotoSession = async (sessionID?: string) => { await page.goto(sessionPath(directory, sessionID)) await expect(page.locator(promptSelector)).toBeVisible() From c700b928e4c42dbb7f5807cd4de81ea85c44aca4 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 15:38:34 -0500 Subject: [PATCH 092/294] ci: add stale pr job --- .github/workflows/close-stale-prs.yml | 78 +++++++++++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 .github/workflows/close-stale-prs.yml diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml new file mode 100644 index 0000000000..a9a0502ea5 --- /dev/null +++ b/.github/workflows/close-stale-prs.yml @@ -0,0 +1,78 @@ +name: Close stale PRs + +on: + workflow_dispatch: + schedule: + - cron: "0 6 * * *" + +permissions: + contents: read + issues: write + pull-requests: write + +jobs: + close-stale-prs: + runs-on: ubuntu-latest + steps: + - name: Close inactive PRs + uses: actions/github-script@v8 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + script: | + const DAYS_INACTIVE = 60 + const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) + const { owner, repo } = context.repo + const dryRun = false + const stalePrs = [] + + core.info(`Dry run mode: ${dryRun}`) + + const prs = await github.paginate(github.rest.pulls.list, { + owner, + repo, + state: "open", + per_page: 100, + sort: "updated", + direction: "asc", + }) + + for (const pr of prs) { + const lastUpdated = new Date(pr.updated_at) + if (lastUpdated > cutoff) { + core.info(`PR ${pr.number} is fresh`) + continue + } + + stalePrs.push(pr) + } + + if (!stalePrs.length) { + core.info("No stale pull requests found.") + return + } + + for (const pr of stalePrs) { + const issue_number = pr.number + const closeComment = `Closing this pull request because it has had no updates for more than ${DAYS_INACTIVE} days. If you plan to continue working on it, feel free to reopen or open a new PR.` + + if (dryRun) { + core.info(`[dry-run] Would close PR #${issue_number} from ${pr.user.login}`) + continue + } + + await github.rest.issues.createComment({ + owner, + repo, + issue_number, + body: closeComment, + }) + + await github.rest.pulls.update({ + owner, + repo, + pull_number: issue_number, + state: "closed", + }) + + core.info(`Closed PR #${issue_number} from ${pr.user.login}`) + } From 6a62b44593b7be108af2245730175868ec967c1e Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 15:42:50 -0500 Subject: [PATCH 093/294] ci: add dry-run option to stale PR closer workflow Allows testing stale PR closure without actually closing PRs --- .github/workflows/close-stale-prs.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.github/workflows/close-stale-prs.yml b/.github/workflows/close-stale-prs.yml index a9a0502ea5..787ee02e62 100644 --- a/.github/workflows/close-stale-prs.yml +++ b/.github/workflows/close-stale-prs.yml @@ -2,6 +2,11 @@ name: Close stale PRs on: workflow_dispatch: + inputs: + dryRun: + description: "Log actions without closing PRs" + type: boolean + default: false schedule: - cron: "0 6 * * *" @@ -22,7 +27,7 @@ jobs: const DAYS_INACTIVE = 60 const cutoff = new Date(Date.now() - DAYS_INACTIVE * 24 * 60 * 60 * 1000) const { owner, repo } = context.repo - const dryRun = false + const dryRun = context.payload.inputs?.dryRun === "true" const stalePrs = [] core.info(`Dry run mode: ${dryRun}`) From 8b5dde5536b5725e68eb43933d23dae22c298b35 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 16:12:41 -0500 Subject: [PATCH 094/294] tweak: retry logic to catch certain provider problems --- packages/opencode/src/session/retry.ts | 49 ++++++++++++-------- packages/opencode/test/session/retry.test.ts | 27 +++++++++++ 2 files changed, 56 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index dd0fe23802..8d3f72844f 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -1,5 +1,6 @@ import type { NamedError } from "@opencode-ai/util/error" import { MessageV2 } from "./message-v2" +import { iife } from "@/util/iife" export namespace SessionRetry { export const RETRY_INITIAL_DELAY = 2000 @@ -63,28 +64,36 @@ export namespace SessionRetry { return error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } - if (typeof error.data?.message === "string") { + const json = iife(() => { try { - const json = JSON.parse(error.data.message) - if (json.type === "error" && json.error?.type === "too_many_requests") { - return "Too Many Requests" + if (typeof error.data?.message === "string") { + const parsed = JSON.parse(error.data.message) + return parsed } - if (json.code.includes("exhausted") || json.code.includes("unavailable")) { - return "Provider is overloaded" - } - if (json.type === "error" && json.error?.code?.includes("rate_limit")) { - return "Rate Limited" - } - if ( - json.error?.message?.includes("no_kv_space") || - (json.type === "error" && json.error?.type === "server_error") || - !!json.error - ) { - return "Provider Server Error" - } - } catch {} - } - return undefined + return JSON.parse(error.data.message) + } catch { + return undefined + } + }) + if (!json || typeof json !== "object") return undefined + const code = typeof json.code === "string" ? json.code : "" + + if (json.type === "error" && json.error?.type === "too_many_requests") { + return "Too Many Requests" + } + if (code.includes("exhausted") || code.includes("unavailable")) { + return "Provider is overloaded" + } + if (json.type === "error" && json.error?.code?.includes("rate_limit")) { + return "Rate Limited" + } + if ( + json.error?.message?.includes("no_kv_space") || + (json.type === "error" && json.error?.type === "server_error") || + !!json.error + ) { + return "Provider Server Error" + } } } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 22ffb0cb11..6866b03a15 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -1,4 +1,5 @@ import { describe, expect, test } from "bun:test" +import type { NamedError } from "@opencode-ai/util/error" import { APICallError } from "ai" import { SessionRetry } from "../../src/session/retry" import { MessageV2 } from "../../src/session/message-v2" @@ -11,6 +12,10 @@ function apiError(headers?: Record): MessageV2.APIError { }).toObject() as MessageV2.APIError } +function wrap(message: unknown): ReturnType { + return { data: { message } } as ReturnType +} + describe("session.retry.delay", () => { test("caps delay at 30 seconds when headers missing", () => { const error = apiError() @@ -81,6 +86,28 @@ describe("session.retry.delay", () => { }) }) +describe("session.retry.retryable", () => { + test("maps too_many_requests json messages", () => { + const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) + expect(SessionRetry.retryable(error)).toBe("Too Many Requests") + }) + + test("maps overloaded provider codes", () => { + const error = wrap(JSON.stringify({ code: "resource_exhausted" })) + expect(SessionRetry.retryable(error)).toBe("Provider is overloaded") + }) + + test("handles json messages without code", () => { + const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) + expect(SessionRetry.retryable(error)).toBe("Provider Server Error") + }) + + test("returns undefined for non-json message", () => { + const error = wrap("not-json") + expect(SessionRetry.retryable(error)).toBeUndefined() + }) +}) + describe("session.message-v2.fromError", () => { test.concurrent( "converts ECONNRESET socket errors to retryable APIError", From cbe8f265b96dce46b187d27978cc1728974a2bfb Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:08:25 -0600 Subject: [PATCH 095/294] fix(app): disconnect zen provider --- packages/app/src/components/settings-providers.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index dbf2ffc908..e39e8a8176 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -20,7 +20,10 @@ export const SettingsProviders: Component = () => { const globalSDK = useGlobalSDK() const providers = useProviders() - const connected = createMemo(() => providers.connected()) + const connected = createMemo(() => { + const paid = providers.paid().length > 0 + return providers.connected().filter((p) => p.id !== "opencode" || paid) + }) const popular = createMemo(() => { const connectedIDs = new Set(connected().map((p) => p.id)) const items = providers From d82e94c209c5a8c25d9646144ff92a95e739f065 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:23:20 -0600 Subject: [PATCH 096/294] fix(app): zen disconnect not working --- packages/app/src/components/settings-providers.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index e39e8a8176..104663c20a 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -21,9 +21,9 @@ export const SettingsProviders: Component = () => { const providers = useProviders() const connected = createMemo(() => { - const paid = providers.paid().length > 0 - return providers.connected().filter((p) => p.id !== "opencode" || paid) + return providers.connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) }) + const popular = createMemo(() => { const connectedIDs = new Set(connected().map((p) => p.id)) const items = providers From b21f82f5b0b8bfce977f91c7bfc3eb9443e29250 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 26 Jan 2026 21:24:09 +0000 Subject: [PATCH 097/294] chore: generate --- packages/app/src/components/settings-providers.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 104663c20a..d098db279d 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -21,7 +21,9 @@ export const SettingsProviders: Component = () => { const providers = useProviders() const connected = createMemo(() => { - return providers.connected().filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) + return providers + .connected() + .filter((p) => p.id !== "opencode" || Object.values(p.models).find((m) => m.cost?.input)) }) const popular = createMemo(() => { From 7b3d5f1d68c570335d8111ca735168904e10d5d0 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:29:54 -0600 Subject: [PATCH 098/294] chore: cleanup --- packages/app/src/components/session/session-header.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index 1e18d2915a..4070b371e0 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -315,7 +315,7 @@ export function SessionHeader() { + ) : ( + + )} +
+ + {total > 1 && ( +
+ {release.features.map((_, i) => ( + + ))} +
+ )} +
+
+ + {/* Right side - Media content (edge to edge) */} + {feature().media && ( +
+ {feature().media!.type === "image" ? ( + {feature().media!.alt + ) : ( +
+ )} + + ) +} diff --git a/packages/app/src/components/release-notes-handler.tsx b/packages/app/src/components/release-notes-handler.tsx new file mode 100644 index 0000000000..45237b5779 --- /dev/null +++ b/packages/app/src/components/release-notes-handler.tsx @@ -0,0 +1,31 @@ +import { onMount } from "solid-js" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { DialogReleaseNotes } from "./dialog-release-notes" +import { shouldShowReleaseNotes, markReleaseNotesSeen } from "@/lib/release-notes" + +/** + * Component that handles showing release notes modal on app startup. + * Shows the modal if: + * - DEV_ALWAYS_SHOW_RELEASE_NOTES is true in lib/release-notes.ts + * - OR the user hasn't seen the current version's release notes yet + * + * To disable the dev mode behavior, set DEV_ALWAYS_SHOW_RELEASE_NOTES to false + * in packages/app/src/lib/release-notes.ts + */ +export function ReleaseNotesHandler() { + const dialog = useDialog() + + onMount(() => { + // Small delay to ensure app is fully loaded before showing modal + setTimeout(() => { + if (shouldShowReleaseNotes()) { + dialog.show( + () => , + () => markReleaseNotesSeen(), + ) + } + }, 500) + }) + + return null +} diff --git a/packages/app/src/index.css b/packages/app/src/index.css index 3d7b9db7af..c0c7da8585 100644 --- a/packages/app/src/index.css +++ b/packages/app/src/index.css @@ -55,3 +55,30 @@ scrollbar-width: thin !important; scrollbar-color: var(--border-weak-base) transparent !important; } + +/* Wider dialog variant for release notes modal */ +[data-component="dialog"]:has(.dialog-release-notes) { + padding: 20px; + box-sizing: border-box; + + [data-slot="dialog-container"] { + width: min(100%, 720px); + height: min(100%, 400px); + margin-top: -80px; + + [data-slot="dialog-content"] { + min-height: auto; + overflow: hidden; + height: 100%; + border: none; + box-shadow: var(--shadow-lg-border-base); + } + + [data-slot="dialog-body"] { + overflow: hidden; + height: 100%; + display: flex; + flex-direction: row; + } + } +} diff --git a/packages/app/src/lib/release-notes.ts b/packages/app/src/lib/release-notes.ts new file mode 100644 index 0000000000..a28843acdf --- /dev/null +++ b/packages/app/src/lib/release-notes.ts @@ -0,0 +1,53 @@ +import { CURRENT_RELEASE } from "@/components/dialog-release-notes" + +const STORAGE_KEY = "opencode:last-seen-version" + +// ============================================================================ +// DEV MODE: Set this to true to always show the release notes modal on startup +// Set to false for production behavior (only shows after updates) +// ============================================================================ +const DEV_ALWAYS_SHOW_RELEASE_NOTES = true + +/** + * Check if release notes should be shown + * Returns true if: + * - DEV_ALWAYS_SHOW_RELEASE_NOTES is true (for development) + * - OR the current version is newer than the last seen version + */ +export function shouldShowReleaseNotes(): boolean { + if (DEV_ALWAYS_SHOW_RELEASE_NOTES) { + console.log("[ReleaseNotes] DEV mode: always showing release notes") + return true + } + + const lastSeen = localStorage.getItem(STORAGE_KEY) + if (!lastSeen) { + // First time user - show release notes + return true + } + + // Compare versions - show if current is newer + return CURRENT_RELEASE.version !== lastSeen +} + +/** + * Mark the current release notes as seen + * Call this when the user closes the release notes modal + */ +export function markReleaseNotesSeen(): void { + localStorage.setItem(STORAGE_KEY, CURRENT_RELEASE.version) +} + +/** + * Get the current version + */ +export function getCurrentVersion(): string { + return CURRENT_RELEASE.version +} + +/** + * Reset the seen status (useful for testing) + */ +export function resetReleaseNotesSeen(): void { + localStorage.removeItem(STORAGE_KEY) +} diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b13cb1ac34..601a240675 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -68,6 +68,7 @@ import { ConstrainDragXAxis } from "@/utils/solid-dnd" import { navStart } from "@/utils/perf" import { DialogSelectDirectory } from "@/components/dialog-select-directory" import { DialogEditProject } from "@/components/dialog-edit-project" +import { ReleaseNotesHandler } from "@/components/release-notes-handler" import { Titlebar } from "@/components/titlebar" import { useServer } from "@/context/server" import { useLanguage, type Locale } from "@/context/language" @@ -1443,6 +1444,11 @@ export default function Layout(props: ParentProps) { ), ) + createEffect(() => { + const sidebarWidth = layout.sidebar.opened() ? layout.sidebar.width() : 48 + document.documentElement.style.setProperty("--dialog-left-margin", `${sidebarWidth}px`) + }) + createEffect(() => { const project = currentProject() if (!project) return @@ -2791,6 +2797,7 @@ export default function Layout(props: ParentProps) {
+
) } From c1e840b9b229889c1a85b479b3cd601d6f313c87 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 05:58:38 -0600 Subject: [PATCH 101/294] chore: cleanup --- .../src/components/dialog-release-notes.tsx | 141 +++++++++++++++++- 1 file changed, 133 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c5a1e15c13..d6375dbbc4 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -4,6 +4,107 @@ import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { markReleaseNotesSeen } from "@/lib/release-notes" +const CHANGELOG_URL = "https://opencode.ai/changelog.json" + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null +} + +function getText(value: unknown): string | undefined { + if (typeof value === "string") { + const text = value.trim() + return text.length > 0 ? text : undefined + } + + if (!Array.isArray(value)) return + const parts = value.map((item) => (typeof item === "string" ? item.trim() : "")).filter((item) => item.length > 0) + if (parts.length === 0) return + return parts.join(" ") +} + +function normalizeRemoteUrl(url: string): string { + if (url.startsWith("https://") || url.startsWith("http://")) return url + if (url.startsWith("/")) return `https://opencode.ai${url}` + return `https://opencode.ai/${url}` +} + +function parseMedia(value: unknown): ReleaseFeature["media"] | undefined { + if (!isRecord(value)) return + + const type = getText(value.type)?.toLowerCase() + const src = getText(value.src) + if (!src) return + if (type !== "image" && type !== "video") return + + return { + type, + src: normalizeRemoteUrl(src), + alt: getText(value.alt), + } +} + +function parseFeature(value: unknown): ReleaseFeature | undefined { + if (!isRecord(value)) return + + const title = getText(value.title) ?? getText(value.name) ?? getText(value.heading) + const description = getText(value.description) ?? getText(value.body) ?? getText(value.text) + + if (!title) return + if (!description) return + + const tag = getText(value.tag) ?? getText(value.label) ?? "New" + + const media = (() => { + const parsed = parseMedia(value.media) + if (parsed) return parsed + + const alt = getText(value.alt) + const image = getText(value.image) + if (image) return { type: "image" as const, src: normalizeRemoteUrl(image), alt } + + const video = getText(value.video) + if (video) return { type: "video" as const, src: normalizeRemoteUrl(video), alt } + })() + + return { title, description, tag, media } +} + +function parseChangelog(value: unknown): ReleaseNote | undefined { + const releases = (() => { + if (Array.isArray(value)) return value + if (!isRecord(value)) return + if (Array.isArray(value.releases)) return value.releases + if (Array.isArray(value.versions)) return value.versions + if (Array.isArray(value.changelog)) return value.changelog + })() + + if (!releases) { + if (!isRecord(value)) return + if (!Array.isArray(value.highlights)) return + const features = value.highlights.map(parseFeature).filter((item): item is ReleaseFeature => item !== undefined) + if (features.length === 0) return + return { version: CURRENT_RELEASE.version, features: features.slice(0, 3) } + } + + const version = (() => { + const head = releases[0] + if (!isRecord(head)) return + return getText(head.version) ?? getText(head.tag_name) ?? getText(head.tag) ?? getText(head.name) + })() + + const features = releases + .flatMap((item) => { + if (!isRecord(item)) return [] + const highlights = item.highlights + if (!Array.isArray(highlights)) return [] + return highlights.map(parseFeature).filter((feature): feature is ReleaseFeature => feature !== undefined) + }) + .slice(0, 3) + + if (features.length === 0) return + return { version: version ?? CURRENT_RELEASE.version, features } +} + export interface ReleaseFeature { title: string description: string @@ -59,13 +160,13 @@ export const CURRENT_RELEASE: ReleaseNote = { export function DialogReleaseNotes(props: { release?: ReleaseNote }) { const dialog = useDialog() - const release = props.release ?? CURRENT_RELEASE + const [note, setNote] = createSignal(props.release ?? CURRENT_RELEASE) const [index, setIndex] = createSignal(0) - const feature = () => release.features[index()] - const total = release.features.length + const feature = () => note().features[index()] ?? note().features[0] ?? CURRENT_RELEASE.features[0]! + const total = () => note().features.length const isFirst = () => index() === 0 - const isLast = () => index() === total - 1 + const isLast = () => index() === total() - 1 function handleNext() { if (!isLast()) setIndex(index() + 1) @@ -97,6 +198,26 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { focusTrap?.focus() document.addEventListener("keydown", handleKeyDown) onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) + + const controller = new AbortController() + fetch(CHANGELOG_URL, { + signal: controller.signal, + headers: { Accept: "application/json" }, + }) + .then((response) => (response.ok ? (response.json() as Promise) : undefined)) + .then((json) => { + if (!json) return + const parsed = parseChangelog(json) + if (!parsed) return + setNote({ + version: parsed.version, + features: parsed.features, + }) + setIndex(0) + }) + .catch(() => undefined) + + onCleanup(() => controller.abort()) }) // Refocus the trap when index changes to ensure escape always works @@ -144,16 +265,20 @@ export function DialogReleaseNotes(props: { release?: ReleaseNote }) { )}
- {total > 1 && ( + {total() > 1 && (
- {release.features.map((_, i) => ( + {note().features.map((_, i) => (
-
) } From 53ac394c685780985d776c4f861c283714615999 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 07:25:25 -0600 Subject: [PATCH 103/294] wip: highlights --- packages/app/src/context/highlights.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index 4c2e8c838d..2d20660d72 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -6,7 +6,7 @@ import { usePlatform } from "@/context/platform" import { persisted } from "@/utils/persist" import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" -const CHANGELOG_URL = "https://opencode.ai/changelog.json" +const CHANGELOG_URL = "https://dev.opencode.ai/changelog.json" type Store = { version?: string @@ -81,6 +81,10 @@ function parseRelease(value: unknown): ParsedRelease | undefined { } function parseChangelog(value: unknown): ParsedRelease[] | undefined { + if (Array.isArray(value)) { + return value.map(parseRelease).filter((release): release is ParsedRelease => release !== undefined) + } + if (!isRecord(value)) return if (!Array.isArray(value.releases)) return @@ -163,6 +167,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple if (!json) return const releases = parseChangelog(json) if (!releases) return + if (releases.length === 0) return const highlights = sliceHighlights({ releases, current: platform.version, From ccc7aa49c33dc2247ceee0a51e7816ea5803c404 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 15:34:59 -0600 Subject: [PATCH 104/294] wip: highlights --- .../src/components/dialog-release-notes.tsx | 30 ++++++----- .../app/src/components/settings-general.tsx | 17 ++++++ packages/app/src/context/highlights.tsx | 54 +++++++++++++------ packages/app/src/context/settings.tsx | 6 +++ packages/app/src/i18n/en.ts | 4 ++ 5 files changed, 82 insertions(+), 29 deletions(-) diff --git a/packages/app/src/components/dialog-release-notes.tsx b/packages/app/src/components/dialog-release-notes.tsx index c62cbc1883..d3ee7e201a 100644 --- a/packages/app/src/components/dialog-release-notes.tsx +++ b/packages/app/src/components/dialog-release-notes.tsx @@ -2,11 +2,11 @@ import { createSignal, createEffect, onMount, onCleanup } from "solid-js" import { Dialog } from "@opencode-ai/ui/dialog" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettings } from "@/context/settings" export type Highlight = { title: string description: string - tag?: string media?: { type: "image" | "video" src: string @@ -16,6 +16,7 @@ export type Highlight = { export function DialogReleaseNotes(props: { highlights: Highlight[] }) { const dialog = useDialog() + const settings = useSettings() const [index, setIndex] = createSignal(0) const total = () => props.highlights.length @@ -34,9 +35,20 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { dialog.close() } + function handleDisable() { + settings.general.setReleaseNotes(false) + handleClose() + } + let focusTrap: HTMLDivElement | undefined function handleKeyDown(e: KeyboardEvent) { + if (e.key === "Escape") { + e.preventDefault() + handleClose() + return + } + if (!paged()) return if (e.key === "ArrowLeft" && !isFirst()) { e.preventDefault() @@ -50,8 +62,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { onMount(() => { focusTrap?.focus() - - if (!paged()) return document.addEventListener("keydown", handleKeyDown) onCleanup(() => document.removeEventListener("keydown", handleKeyDown)) }) @@ -72,14 +82,6 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) {

{feature()?.title ?? ""}

- {feature()?.tag && ( - - {feature()!.tag} - - )}

{feature()?.description ?? ""}

@@ -89,7 +91,7 @@ export function DialogReleaseNotes(props: { highlights: Highlight[] }) { {/* Bottom section - buttons and indicators (fixed position) */}
-
+
{isLast() ? ( )} + +
{paged() && ( diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index cfb8a998d3..e7e5b67f3a 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -214,6 +214,23 @@ export const SettingsGeneral: Component = () => {
+ {/* Updates Section */} +
+

{language.t("settings.general.section.updates")}

+ +
+ + settings.general.setReleaseNotes(checked)} + /> + +
+
+ {/* Sound effects Section */}

{language.t("settings.general.section.sounds")}

diff --git a/packages/app/src/context/highlights.tsx b/packages/app/src/context/highlights.tsx index 2d20660d72..e55bca675d 100644 --- a/packages/app/src/context/highlights.tsx +++ b/packages/app/src/context/highlights.tsx @@ -3,10 +3,11 @@ import { createStore } from "solid-js/store" import { createSimpleContext } from "@opencode-ai/ui/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { usePlatform } from "@/context/platform" +import { useSettings } from "@/context/settings" import { persisted } from "@/utils/persist" import { DialogReleaseNotes, type Highlight } from "@/components/dialog-release-notes" -const CHANGELOG_URL = "https://dev.opencode.ai/changelog.json" +const CHANGELOG_URL = "https://opencode.ai/changelog.json" type Store = { version?: string @@ -18,7 +19,7 @@ type ParsedRelease = { } function isRecord(value: unknown): value is Record { - return typeof value === "object" && value !== null + return typeof value === "object" && value !== null && !Array.isArray(value) } function getText(value: unknown): string | undefined { @@ -40,14 +41,14 @@ function normalizeVersion(value: string | undefined) { function parseMedia(value: unknown, alt: string): Highlight["media"] | undefined { if (!isRecord(value)) return const type = getText(value.type)?.toLowerCase() - const src = getText(value.src) + const src = getText(value.src) ?? getText(value.url) if (!src) return if (type !== "image" && type !== "video") return return { type, src, alt } } -function parseHighlight(value: unknown, tag: string | undefined): Highlight | undefined { +function parseHighlight(value: unknown): Highlight | undefined { if (!isRecord(value)) return const title = getText(value.title) @@ -57,7 +58,7 @@ function parseHighlight(value: unknown, tag: string | undefined): Highlight | un if (!description) return const media = parseMedia(value.media, title) - return { title, description, tag, media } + return { title, description, media } } function parseRelease(value: unknown): ParsedRelease | undefined { @@ -70,11 +71,18 @@ function parseRelease(value: unknown): ParsedRelease | undefined { const highlights = value.highlights.flatMap((group) => { if (!isRecord(group)) return [] - if (!Array.isArray(group.items)) return [] + const source = getText(group.source) - return group.items - .map((item) => parseHighlight(item, source)) - .filter((item): item is Highlight => item !== undefined) + if (!source) return [] + if (!source.toLowerCase().includes("desktop")) return [] + + if (Array.isArray(group.items)) { + return group.items.map((item) => parseHighlight(item)).filter((item): item is Highlight => item !== undefined) + } + + const item = parseHighlight(group) + if (!item) return [] + return [item] }) return { tag, highlights } @@ -108,10 +116,17 @@ function sliceHighlights(input: { releases: ParsedRelease[]; current?: string; p return index === -1 ? releases.length : index })() - return releases - .slice(start, end) - .flatMap((release) => release.highlights) - .slice(0, 3) + const highlights = releases.slice(start, end).flatMap((release) => release.highlights) + const seen = new Set() + const unique = highlights.filter((highlight) => { + const key = [highlight.title, highlight.description, highlight.media?.type ?? "", highlight.media?.src ?? ""].join( + "\n", + ) + if (seen.has(key)) return false + seen.add(key) + return true + }) + return unique.slice(0, 3) } export const { use: useHighlights, provider: HighlightsProvider } = createSimpleContext({ @@ -120,6 +135,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple init: () => { const platform = usePlatform() const dialog = useDialog() + const settings = useSettings() const [store, setStore, _, ready] = persisted("highlights.v1", createStore({ version: undefined })) const [from, setFrom] = createSignal(undefined) @@ -135,6 +151,7 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple createEffect(() => { if (state.started) return if (!ready()) return + if (!settings.ready()) return if (!platform.version) return state.started = true @@ -149,6 +166,11 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple setFrom(previous) setTo(platform.version) + if (!settings.general.releaseNotes()) { + markSeen() + return + } + const fetcher = platform.fetch ?? fetch const controller = new AbortController() onCleanup(() => { @@ -182,10 +204,8 @@ export const { use: useHighlights, provider: HighlightsProvider } = createSimple } const timer = setTimeout(() => { - dialog.show( - () => , - () => markSeen(), - ) + markSeen() + dialog.show(() => ) }, 500) setTimer(timer) }) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index d976cbc496..67e907a636 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -18,6 +18,7 @@ export interface SoundSettings { export interface Settings { general: { autoSave: boolean + releaseNotes: boolean } appearance: { fontSize: number @@ -34,6 +35,7 @@ export interface Settings { const defaultSettings: Settings = { general: { autoSave: true, + releaseNotes: true, }, appearance: { fontSize: 14, @@ -97,6 +99,10 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setAutoSave(value: boolean) { setStore("general", "autoSave", value) }, + releaseNotes: createMemo(() => store.general?.releaseNotes ?? defaultSettings.general.releaseNotes), + setReleaseNotes(value: boolean) { + setStore("general", "releaseNotes", value) + }, }, appearance: { fontSize: createMemo(() => store.appearance?.fontSize ?? defaultSettings.appearance.fontSize), diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index d368fff334..bca08275f7 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -525,6 +525,7 @@ export const dict = { "settings.general.section.appearance": "Appearance", "settings.general.section.notifications": "System notifications", + "settings.general.section.updates": "Updates", "settings.general.section.sounds": "Sound effects", "settings.general.row.language.title": "Language", @@ -535,6 +536,9 @@ export const dict = { "settings.general.row.theme.description": "Customise how OpenCode is themed.", "settings.general.row.font.title": "Font", "settings.general.row.font.description": "Customise the mono font used in code blocks", + + "settings.general.row.releaseNotes.title": "Release notes", + "settings.general.row.releaseNotes.description": "Show What's New popups after updates", "font.option.ibmPlexMono": "IBM Plex Mono", "font.option.cascadiaCode": "Cascadia Code", "font.option.firaCode": "Fira Code", From 45b09c14658d36f2107f16ad0c8bd31002a02cf0 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 17:17:37 -0500 Subject: [PATCH 105/294] tweak: when using messages api for copilot, attach anthropic beta headers --- packages/opencode/src/plugin/copilot.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/opencode/src/plugin/copilot.ts b/packages/opencode/src/plugin/copilot.ts index 659090dd3a..0be1345871 100644 --- a/packages/opencode/src/plugin/copilot.ts +++ b/packages/opencode/src/plugin/copilot.ts @@ -277,6 +277,11 @@ export async function CopilotAuthPlugin(input: PluginInput): Promise { }, "chat.headers": async (input, output) => { if (!input.model.providerID.includes("github-copilot")) return + + if (input.model.api.npm === "@ai-sdk/anthropic") { + output.headers["anthropic-beta"] = "interleaved-thinking-2025-05-14" + } + const session = await sdk.session .get({ path: { From 77f11dfabe8fbe1bd696e03b17fbfa459384666b Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:30:15 -0600 Subject: [PATCH 106/294] chore: don't flip github draft release automatically --- script/publish-complete.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/script/publish-complete.ts b/script/publish-complete.ts index a3bdceae07..4c7c7ac31c 100755 --- a/script/publish-complete.ts +++ b/script/publish-complete.ts @@ -1,11 +1,11 @@ #!/usr/bin/env bun -import { Script } from "@opencode-ai/script" +// import { Script } from "@opencode-ai/script" import { $ } from "bun" -if (!Script.preview) { - await $`gh release edit v${Script.version} --draft=false` -} +// if (!Script.preview) { +// await $`gh release edit v${Script.version} --draft=false` +// } await $`bun install` From b07d7cdb71301b9b0381f1a94b5670f954a56d4c Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:40:57 -0600 Subject: [PATCH 107/294] fix(app): file tree performance --- packages/app/src/components/file-tree.tsx | 59 +++++++++++++++++------ 1 file changed, 44 insertions(+), 15 deletions(-) diff --git a/packages/app/src/components/file-tree.tsx b/packages/app/src/components/file-tree.tsx index b7f8316d03..64988af532 100644 --- a/packages/app/src/components/file-tree.tsx +++ b/packages/app/src/components/file-tree.tsx @@ -17,6 +17,11 @@ import { import { Dynamic } from "solid-js/web" import type { FileNode } from "@opencode-ai/sdk/v2" +type Filter = { + files: Set + dirs: Set +} + export default function FileTree(props: { path: string class?: string @@ -27,26 +32,19 @@ export default function FileTree(props: { draggable?: boolean tooltip?: boolean onFileClick?: (file: FileNode) => void + + _filter?: Filter + _marks?: Set + _deeps?: Map }) { const file = useFile() const level = props.level ?? 0 const draggable = () => props.draggable ?? true const tooltip = () => props.tooltip ?? true - const maxOpen = (dir: string, lvl: number): number => { - const expanded = file.tree.state(dir)?.expanded ?? false - if (!expanded) return -1 - - const nodes = file.tree.children(dir) - const child = nodes.reduce((max, node) => { - if (node.type !== "directory") return max - return Math.max(max, maxOpen(node.path, lvl + 1)) - }, -1) - - return Math.max(lvl, child) - } - const filter = createMemo(() => { + if (props._filter) return props._filter + const allowed = props.allowed if (!allowed) return @@ -66,11 +64,38 @@ export default function FileTree(props: { }) const marks = createMemo(() => { + if (props._marks) return props._marks + const modified = props.modified if (!modified || modified.length === 0) return return new Set(modified) }) + const deeps = createMemo(() => { + if (props._deeps) return props._deeps + + const out = new Map() + + const visit = (dir: string, lvl: number): number => { + const expanded = file.tree.state(dir)?.expanded ?? false + if (!expanded) return -1 + + const nodes = file.tree.children(dir) + const max = nodes.reduce((max, node) => { + if (node.type !== "directory") return max + const open = file.tree.state(node.path)?.expanded ?? false + if (!open) return max + return Math.max(max, visit(node.path, lvl + 1)) + }, lvl) + + out.set(dir, max) + return max + } + + visit(props.path, level - 1) + return out + }) + createEffect(() => { const current = filter() if (!current) return @@ -84,7 +109,8 @@ export default function FileTree(props: { }) createEffect(() => { - void file.tree.list(props.path) + const path = props.path + untrack(() => void file.tree.list(path)) }) const nodes = createMemo(() => { @@ -165,7 +191,7 @@ export default function FileTree(props: { {(node) => { const expanded = () => file.tree.state(node.path)?.expanded ?? false - const deep = createMemo(() => (node.type === "directory" ? maxOpen(node.path, level) : -1)) + const deep = () => deeps().get(node.path) ?? -1 const Wrapper = (p: ParentProps) => { if (!tooltip()) return p.children return ( @@ -212,6 +238,9 @@ export default function FileTree(props: { draggable={props.draggable} tooltip={props.tooltip} onFileClick={props.onFileClick} + _filter={filter()} + _marks={marks()} + _deeps={deeps()} /> From 021d9d105e1dc8d096390b1b392de642c033901c Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:46:01 -0600 Subject: [PATCH 108/294] fix(app): reactive file tree --- packages/app/src/context/file.tsx | 12 ++++++++++++ packages/opencode/src/tool/apply_patch.ts | 15 ++++++++------- packages/opencode/src/tool/edit.ts | 10 ++++++++++ packages/opencode/src/tool/write.ts | 5 +++++ 4 files changed, 35 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 306bce922b..805936cd83 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -571,6 +571,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const kind = event.properties.event + if (kind === "change") { + const dir = (() => { + if (path === "") return "" + const node = tree.node[path] + if (node?.type !== "directory") return + return path + })() + if (dir === undefined) return + if (!tree.dir[dir]?.loaded) return + listDir(dir, { force: true }) + return + } if (kind !== "add" && kind !== "unlink") return const parent = path.split("/").slice(0, -1).join("/") diff --git a/packages/opencode/src/tool/apply_patch.ts b/packages/opencode/src/tool/apply_patch.ts index 8028ee8de8..1344467c71 100644 --- a/packages/opencode/src/tool/apply_patch.ts +++ b/packages/opencode/src/tool/apply_patch.ts @@ -185,7 +185,7 @@ export const ApplyPatchTool = Tool.define("apply_patch", { }) // Apply the changes - const changedFiles: string[] = [] + const updates: Array<{ file: string; event: "add" | "change" | "unlink" }> = [] for (const change of fileChanges) { const edited = change.type === "delete" ? undefined : (change.movePath ?? change.filePath) @@ -194,12 +194,12 @@ export const ApplyPatchTool = Tool.define("apply_patch", { // Create parent directories (recursive: true is safe on existing/root dirs) await fs.mkdir(path.dirname(change.filePath), { recursive: true }) await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) + updates.push({ file: change.filePath, event: "add" }) break case "update": await fs.writeFile(change.filePath, change.newContent, "utf-8") - changedFiles.push(change.filePath) + updates.push({ file: change.filePath, event: "change" }) break case "move": @@ -208,13 +208,14 @@ export const ApplyPatchTool = Tool.define("apply_patch", { await fs.mkdir(path.dirname(change.movePath), { recursive: true }) await fs.writeFile(change.movePath, change.newContent, "utf-8") await fs.unlink(change.filePath) - changedFiles.push(change.movePath) + updates.push({ file: change.filePath, event: "unlink" }) + updates.push({ file: change.movePath, event: "add" }) } break case "delete": await fs.unlink(change.filePath) - changedFiles.push(change.filePath) + updates.push({ file: change.filePath, event: "unlink" }) break } @@ -226,8 +227,8 @@ export const ApplyPatchTool = Tool.define("apply_patch", { } // Publish file change events - for (const filePath of changedFiles) { - await Bus.publish(FileWatcher.Event.Updated, { file: filePath, event: "change" }) + for (const update of updates) { + await Bus.publish(FileWatcher.Event.Updated, update) } // Notify LSP of file changes and collect diagnostics diff --git a/packages/opencode/src/tool/edit.ts b/packages/opencode/src/tool/edit.ts index 26db5b2283..0bf1d6792b 100644 --- a/packages/opencode/src/tool/edit.ts +++ b/packages/opencode/src/tool/edit.ts @@ -10,6 +10,7 @@ import { LSP } from "../lsp" import { createTwoFilesPatch, diffLines } from "diff" import DESCRIPTION from "./edit.txt" import { File } from "../file" +import { FileWatcher } from "../file/watcher" import { Bus } from "../bus" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" @@ -48,6 +49,7 @@ export const EditTool = Tool.define("edit", { let contentNew = "" await FileTime.withLock(filePath, async () => { if (params.oldString === "") { + const existed = await Bun.file(filePath).exists() contentNew = params.newString diff = trimDiff(createTwoFilesPatch(filePath, filePath, contentOld, contentNew)) await ctx.ask({ @@ -63,6 +65,10 @@ export const EditTool = Tool.define("edit", { await Bus.publish(File.Event.Edited, { file: filePath, }) + await Bus.publish(FileWatcher.Event.Updated, { + file: filePath, + event: existed ? "change" : "add", + }) FileTime.read(ctx.sessionID, filePath) return } @@ -92,6 +98,10 @@ export const EditTool = Tool.define("edit", { await Bus.publish(File.Event.Edited, { file: filePath, }) + await Bus.publish(FileWatcher.Event.Updated, { + file: filePath, + event: "change", + }) contentNew = await file.text() diff = trimDiff( createTwoFilesPatch(filePath, filePath, normalizeLineEndings(contentOld), normalizeLineEndings(contentNew)), diff --git a/packages/opencode/src/tool/write.ts b/packages/opencode/src/tool/write.ts index cfcf6a0dab..eca64d3037 100644 --- a/packages/opencode/src/tool/write.ts +++ b/packages/opencode/src/tool/write.ts @@ -6,6 +6,7 @@ import { createTwoFilesPatch } from "diff" import DESCRIPTION from "./write.txt" import { Bus } from "../bus" import { File } from "../file" +import { FileWatcher } from "../file/watcher" import { FileTime } from "../file/time" import { Filesystem } from "../util/filesystem" import { Instance } from "../project/instance" @@ -45,6 +46,10 @@ export const WriteTool = Tool.define("write", { await Bus.publish(File.Event.Edited, { file: filepath, }) + await Bus.publish(FileWatcher.Event.Updated, { + file: filepath, + event: exists ? "change" : "add", + }) FileTime.read(ctx.sessionID, filepath) let output = "Wrote file successfully." From 4075f9e1ab9c806f4a5f2a7503547169645f45ac Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Mon, 26 Jan 2026 22:49:23 +0000 Subject: [PATCH 109/294] chore: generate --- packages/sdk/js/src/v2/gen/types.gen.ts | 18 +++--- packages/sdk/openapi.json | 74 ++++++++++++------------- 2 files changed, 46 insertions(+), 46 deletions(-) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 9258bc0cde..2a63d72121 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -627,6 +627,14 @@ export type EventSessionCompacted = { } } +export type EventFileWatcherUpdated = { + type: "file.watcher.updated" + properties: { + file: string + event: "add" | "change" | "unlink" + } +} + export type Todo = { /** * Brief description of the task @@ -654,14 +662,6 @@ export type EventTodoUpdated = { } } -export type EventFileWatcherUpdated = { - type: "file.watcher.updated" - properties: { - file: string - event: "add" | "change" | "unlink" - } -} - export type EventTuiPromptAppend = { type: "tui.prompt.append" properties: { @@ -903,8 +903,8 @@ export type Event = | EventQuestionReplied | EventQuestionRejected | EventSessionCompacted - | EventTodoUpdated | EventFileWatcherUpdated + | EventTodoUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 8808bcf7d8..cf2f29d858 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -7528,6 +7528,41 @@ }, "required": ["type", "properties"] }, + "Event.file.watcher.updated": { + "type": "object", + "properties": { + "type": { + "type": "string", + "const": "file.watcher.updated" + }, + "properties": { + "type": "object", + "properties": { + "file": { + "type": "string" + }, + "event": { + "anyOf": [ + { + "type": "string", + "const": "add" + }, + { + "type": "string", + "const": "change" + }, + { + "type": "string", + "const": "unlink" + } + ] + } + }, + "required": ["file", "event"] + } + }, + "required": ["type", "properties"] + }, "Todo": { "type": "object", "properties": { @@ -7575,41 +7610,6 @@ }, "required": ["type", "properties"] }, - "Event.file.watcher.updated": { - "type": "object", - "properties": { - "type": { - "type": "string", - "const": "file.watcher.updated" - }, - "properties": { - "type": "object", - "properties": { - "file": { - "type": "string" - }, - "event": { - "anyOf": [ - { - "type": "string", - "const": "add" - }, - { - "type": "string", - "const": "change" - }, - { - "type": "string", - "const": "unlink" - } - ] - } - }, - "required": ["file", "event"] - } - }, - "required": ["type", "properties"] - }, "Event.tui.prompt.append": { "type": "object", "properties": { @@ -8276,10 +8276,10 @@ "$ref": "#/components/schemas/Event.session.compacted" }, { - "$ref": "#/components/schemas/Event.todo.updated" + "$ref": "#/components/schemas/Event.file.watcher.updated" }, { - "$ref": "#/components/schemas/Event.file.watcher.updated" + "$ref": "#/components/schemas/Event.todo.updated" }, { "$ref": "#/components/schemas/Event.tui.prompt.append" From bb178e93525b6ff1a69d00375571ba3b8cd86b84 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:56:35 -0600 Subject: [PATCH 110/294] chore: cleanup --- packages/app/src/context/file.tsx | 1 + packages/app/src/context/local.tsx | 244 +---------------------------- 2 files changed, 3 insertions(+), 242 deletions(-) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 805936cd83..962af19f0a 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -561,6 +561,7 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const stop = sdk.event.listen((e) => { const event = e.details + console.log(event) if (event.type !== "file.watcher.updated") return const path = normalize(event.properties.file) if (!path) return diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 4b27e6d37a..5f5d51dbdd 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,5 +1,5 @@ -import { createStore, produce, reconcile } from "solid-js/store" -import { batch, createEffect, createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import { batch, createMemo } from "solid-js" import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" @@ -7,8 +7,6 @@ import { useSync } from "./sync" import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" import { useModels } from "@/context/models" -import { showToast } from "@opencode-ai/ui/toast" -import { useLanguage } from "@/context/language" export type LocalFile = FileNode & Partial<{ @@ -41,7 +39,6 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ const sdk = useSDK() const sync = useSync() const providers = useProviders() - const language = useLanguage() function isModelValid(model: ModelKey) { const provider = providers.all().find((x) => x.id === model.providerID) @@ -246,247 +243,10 @@ export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ } })() - const file = (() => { - const [store, setStore] = createStore<{ - node: Record - }>({ - node: {}, // Object.fromEntries(sync.data.node.map((x) => [x.path, x])), - }) - - const scope = createMemo(() => sdk.directory) - createEffect(() => { - scope() - setStore("node", {}) - }) - - // const changeset = createMemo(() => new Set(sync.data.changes.map((f) => f.path))) - // const changes = createMemo(() => Array.from(changeset()).sort((a, b) => a.localeCompare(b))) - - // createEffect((prev: FileStatus[]) => { - // const removed = prev.filter((p) => !sync.data.changes.find((c) => c.path === p.path)) - // for (const p of removed) { - // setStore( - // "node", - // p.path, - // produce((draft) => { - // draft.status = undefined - // draft.view = "raw" - // }), - // ) - // load(p.path) - // } - // for (const p of sync.data.changes) { - // if (store.node[p.path] === undefined) { - // fetch(p.path).then(() => { - // if (store.node[p.path] === undefined) return - // setStore("node", p.path, "status", p) - // }) - // } else { - // setStore("node", p.path, "status", p) - // } - // } - // return sync.data.changes - // }, sync.data.changes) - - // const changed = (path: string) => { - // const node = store.node[path] - // if (node?.status) return true - // const set = changeset() - // if (set.has(path)) return true - // for (const p of set) { - // if (p.startsWith(path ? path + "/" : "")) return true - // } - // return false - // } - - // const resetNode = (path: string) => { - // setStore("node", path, { - // loaded: undefined, - // pinned: undefined, - // content: undefined, - // selection: undefined, - // scrollTop: undefined, - // folded: undefined, - // view: undefined, - // selectedChange: undefined, - // }) - // } - - const relative = (path: string) => path.replace(sync.data.path.directory + "/", "") - - const load = async (path: string) => { - const directory = scope() - const client = sdk.client - const relativePath = relative(path) - await client.file - .read({ path: relativePath }) - .then((x) => { - if (scope() !== directory) return - if (!store.node[relativePath]) return - setStore( - "node", - relativePath, - produce((draft) => { - draft.loaded = true - draft.content = x.data - }), - ) - }) - .catch((e) => { - if (scope() !== directory) return - showToast({ - variant: "error", - title: language.t("toast.file.loadFailed.title"), - description: e.message, - }) - }) - } - - const fetch = async (path: string) => { - const relativePath = relative(path) - const parent = relativePath.split("/").slice(0, -1).join("/") - if (parent) { - await list(parent) - } - } - - const init = async (path: string) => { - const relativePath = relative(path) - if (!store.node[relativePath]) await fetch(path) - if (store.node[relativePath]?.loaded) return - return load(relativePath) - } - - const open = async (path: string, options?: { pinned?: boolean; view?: LocalFile["view"] }) => { - const relativePath = relative(path) - if (!store.node[relativePath]) await fetch(path) - // setStore("opened", (x) => { - // if (x.includes(relativePath)) return x - // return [ - // ...opened() - // .filter((x) => x.pinned) - // .map((x) => x.path), - // relativePath, - // ] - // }) - // setStore("active", relativePath) - // context.addActive() - if (options?.pinned) setStore("node", path, "pinned", true) - if (options?.view && store.node[relativePath].view === undefined) setStore("node", path, "view", options.view) - if (store.node[relativePath]?.loaded) return - return load(relativePath) - } - - const list = async (path: string) => { - const directory = scope() - const client = sdk.client - return client.file - .list({ path: path + "/" }) - .then((x) => { - if (scope() !== directory) return - setStore( - "node", - produce((draft) => { - x.data!.forEach((node) => { - if (node.path in draft) return - draft[node.path] = node - }) - }), - ) - }) - .catch(() => {}) - } - - const searchFiles = (query: string) => sdk.client.find.files({ query, dirs: "false" }).then((x) => x.data!) - const searchFilesAndDirectories = (query: string) => - sdk.client.find.files({ query, dirs: "true" }).then((x) => x.data!) - - const unsub = sdk.event.listen((e) => { - const event = e.details - switch (event.type) { - case "file.watcher.updated": - const relativePath = relative(event.properties.file) - if (relativePath.startsWith(".git/")) return - if (store.node[relativePath]) load(relativePath) - break - } - }) - onCleanup(unsub) - - return { - node: async (path: string) => { - if (!store.node[path] || !store.node[path].loaded) { - await init(path) - } - return store.node[path] - }, - update: (path: string, node: LocalFile) => setStore("node", path, reconcile(node)), - open, - load, - init, - expand(path: string) { - setStore("node", path, "expanded", true) - if (store.node[path]?.loaded) return - setStore("node", path, "loaded", true) - list(path) - }, - collapse(path: string) { - setStore("node", path, "expanded", false) - }, - select(path: string, selection: TextSelection | undefined) { - setStore("node", path, "selection", selection) - }, - scroll(path: string, scrollTop: number) { - setStore("node", path, "scrollTop", scrollTop) - }, - view(path: string): View { - const n = store.node[path] - return n && n.view ? n.view : "raw" - }, - setView(path: string, view: View) { - setStore("node", path, "view", view) - }, - unfold(path: string, key: string) { - setStore("node", path, "folded", (xs) => { - const a = xs ?? [] - if (a.includes(key)) return a - return [...a, key] - }) - }, - fold(path: string, key: string) { - setStore("node", path, "folded", (xs) => (xs ?? []).filter((k) => k !== key)) - }, - folded(path: string) { - const n = store.node[path] - return n && n.folded ? n.folded : [] - }, - changeIndex(path: string) { - return store.node[path]?.selectedChange - }, - setChangeIndex(path: string, index: number | undefined) { - setStore("node", path, "selectedChange", index) - }, - // changes, - // changed, - children(path: string) { - return Object.values(store.node).filter( - (x) => - x.path.startsWith(path) && - x.path !== path && - !x.path.replace(new RegExp(`^${path + "/"}`), "").includes("/"), - ) - }, - searchFiles, - searchFilesAndDirectories, - relative, - } - })() - const result = { slug: createMemo(() => base64Encode(sdk.directory)), model, agent, - file, } return result }, From 36577479c57e0a3dd30f0e8e90f0c596f79550f1 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 16:57:36 -0600 Subject: [PATCH 111/294] fix(app): enable file watcher --- packages/desktop/src-tauri/src/cli.rs | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/desktop/src-tauri/src/cli.rs b/packages/desktop/src-tauri/src/cli.rs index b019d66b5c..f64beed6a1 100644 --- a/packages/desktop/src-tauri/src/cli.rs +++ b/packages/desktop/src-tauri/src/cli.rs @@ -157,6 +157,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { .unwrap() .args(args.split_whitespace()) .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") .env("OPENCODE_CLIENT", "desktop") .env("XDG_STATE_HOME", &state_dir); @@ -174,6 +175,7 @@ pub fn create_command(app: &tauri::AppHandle, args: &str) -> Command { app.shell() .command(&shell) .env("OPENCODE_EXPERIMENTAL_ICON_DISCOVERY", "true") + .env("OPENCODE_EXPERIMENTAL_FILEWATCHER", "true") .env("OPENCODE_CLIENT", "desktop") .env("XDG_STATE_HOME", &state_dir) .args(["-il", "-c", &cmd]) From 8371ba5aecd995f04653f21f02aff81178b16557 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:01:04 -0600 Subject: [PATCH 112/294] chore: cleanup --- packages/app/src/context/local.tsx | 24 ------------------------ 1 file changed, 24 deletions(-) diff --git a/packages/app/src/context/local.tsx b/packages/app/src/context/local.tsx index 5f5d51dbdd..f51bb69309 100644 --- a/packages/app/src/context/local.tsx +++ b/packages/app/src/context/local.tsx @@ -1,6 +1,5 @@ import { createStore } from "solid-js/store" import { batch, createMemo } from "solid-js" -import type { FileContent, FileNode, Model, Provider, File as FileStatus } from "@opencode-ai/sdk/v2" import { createSimpleContext } from "@opencode-ai/ui/context" import { useSDK } from "./sdk" import { useSync } from "./sync" @@ -8,31 +7,8 @@ import { base64Encode } from "@opencode-ai/util/encode" import { useProviders } from "@/hooks/use-providers" import { useModels } from "@/context/models" -export type LocalFile = FileNode & - Partial<{ - loaded: boolean - pinned: boolean - expanded: boolean - content: FileContent - selection: { startLine: number; startChar: number; endLine: number; endChar: number } - scrollTop: number - view: "raw" | "diff-unified" | "diff-split" - folded: string[] - selectedChange: number - status: FileStatus - }> -export type TextSelection = LocalFile["selection"] -export type View = LocalFile["view"] - -export type LocalModel = Omit & { - provider: Provider - latest?: boolean -} export type ModelKey = { providerID: string; modelID: string } -export type FileContext = { type: "file"; path: string; selection?: TextSelection } -export type ContextItem = FileContext - export const { use: useLocal, provider: LocalProvider } = createSimpleContext({ name: "Local", init: () => { From 6897bb7d02a67c516687a046c354c7faea983cc2 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:02:56 -0600 Subject: [PATCH 113/294] chore: cleanup --- packages/app/src/context/file.tsx | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 962af19f0a..805936cd83 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -561,7 +561,6 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ const stop = sdk.event.listen((e) => { const event = e.details - console.log(event) if (event.type !== "file.watcher.updated") return const path = normalize(event.properties.file) if (!path) return From b24fd90fe8a3433333646f61be6ca882b55d1155 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:42:23 -0600 Subject: [PATCH 114/294] test(app): file tree spec --- packages/app/e2e/file-tree.spec.ts | 36 ++++++++++++++++++++++++++++++ 1 file changed, 36 insertions(+) create mode 100644 packages/app/e2e/file-tree.spec.ts diff --git a/packages/app/e2e/file-tree.spec.ts b/packages/app/e2e/file-tree.spec.ts new file mode 100644 index 0000000000..12ea7a081f --- /dev/null +++ b/packages/app/e2e/file-tree.spec.ts @@ -0,0 +1,36 @@ +import { test, expect } from "./fixtures" + +test("file tree can expand folders and open a file", async ({ page, gotoSession }) => { + await gotoSession() + + await page.getByRole("button", { name: "Toggle file tree" }).click() + + const treeTabs = page.locator('[data-component="tabs"][data-variant="pill"][data-scope="filetree"]') + await expect(treeTabs).toBeVisible() + + await treeTabs.locator('[data-slot="tabs-trigger"]').nth(1).click() + + const node = (name: string) => treeTabs.getByRole("button", { name, exact: true }) + + await expect(node("packages")).toBeVisible() + await node("packages").click() + + await expect(node("app")).toBeVisible() + await node("app").click() + + await expect(node("src")).toBeVisible() + await node("src").click() + + await expect(node("components")).toBeVisible() + await node("components").click() + + await expect(node("file-tree.tsx")).toBeVisible() + await node("file-tree.tsx").click() + + const tab = page.getByRole("tab", { name: "file-tree.tsx" }) + await expect(tab).toBeVisible() + await tab.click() + + const code = page.locator('[data-component="code"]').first() + await expect(code.getByText("export default function FileTree")).toBeVisible() +}) From bf463aee04bee55a1bf616be1ab1d14ed48f105f Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Mon, 26 Jan 2026 19:37:54 -0500 Subject: [PATCH 115/294] feat(release): add highlights template to draft releases (#10745) --- script/publish-start.ts | 30 ++++++++++++++++++++++++++++++ 1 file changed, 30 insertions(+) diff --git a/script/publish-start.ts b/script/publish-start.ts index 5d1b2fb6ab..644790f9dc 100755 --- a/script/publish-start.ts +++ b/script/publish-start.ts @@ -4,6 +4,35 @@ import { $ } from "bun" import { Script } from "@opencode-ai/script" import { buildNotes, getLatestRelease } from "./changelog" +const highlightsTemplate = `## Highlights + + + + + +` + let notes: string[] = [] console.log("=== publishing ===\n") @@ -11,6 +40,7 @@ console.log("=== publishing ===\n") if (!Script.preview) { const previous = await getLatestRelease() notes = await buildNotes(previous, "HEAD") + notes.unshift(highlightsTemplate) } const pkgjsons = await Array.fromAsync( From 64a3661a325e8331f767145971c1cce9f7257b00 Mon Sep 17 00:00:00 2001 From: Fabio Martino Date: Tue, 27 Jan 2026 01:45:05 +0100 Subject: [PATCH 116/294] docs: add Italian README (#10732) --- README.ar.md | 1 + README.br.md | 1 + README.da.md | 1 + README.de.md | 1 + README.es.md | 1 + README.fr.md | 1 + README.it.md | 134 ++++++++++++++++++++++++++++++++++++++++++++++++++ README.ja.md | 1 + README.ko.md | 1 + README.md | 1 + README.no.md | 1 + README.pl.md | 1 + README.ru.md | 1 + README.zh.md | 1 + README.zht.md | 1 + 15 files changed, 148 insertions(+) create mode 100644 README.it.md diff --git a/README.ar.md b/README.ar.md index 9ce00c67aa..2abceb300d 100644 --- a/README.ar.md +++ b/README.ar.md @@ -22,6 +22,7 @@
Deutsch |
Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.br.md b/README.br.md index efa7a9ea74..6a58241c98 100644 --- a/README.br.md +++ b/README.br.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.da.md b/README.da.md index a3cc7c4146..7e7dda42a8 100644 --- a/README.da.md +++ b/README.da.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.de.md b/README.de.md index 189171ea3c..c949dd00f4 100644 --- a/README.de.md +++ b/README.de.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.es.md b/README.es.md index d6530b1dd0..3e3797ed30 100644 --- a/README.es.md +++ b/README.es.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.fr.md b/README.fr.md index 520ed3a061..00133b1e9f 100644 --- a/README.fr.md +++ b/README.fr.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.it.md b/README.it.md new file mode 100644 index 0000000000..f3c92c2549 --- /dev/null +++ b/README.it.md @@ -0,0 +1,134 @@ +

+ + + + + Logo OpenCode + + +

+

L’agente di coding AI open source.

+

+ Discord + npm + Build status +

+ +

+ English | + 简体中文 | + 繁體中文 | + 한국어 | + Deutsch | + Español | + Français | + Italiano | + Dansk | + 日本語 | + Polski | + Русский | + العربية | + Norsk | + Português (Brasil) +

+ +[![OpenCode Terminal UI](packages/web/src/assets/lander/screenshot.png)](https://opencode.ai) + +--- + +### Installazione + +```bash +# YOLO +curl -fsSL https://opencode.ai/install | bash + +# Package manager +npm i -g opencode-ai@latest # oppure bun/pnpm/yarn +scoop install opencode # Windows +choco install opencode # Windows +brew install anomalyco/tap/opencode # macOS e Linux (consigliato, sempre aggiornato) +brew install opencode # macOS e Linux (formula brew ufficiale, aggiornata meno spesso) +paru -S opencode-bin # Arch Linux +mise use -g opencode # Qualsiasi OS +nix run nixpkgs#opencode # oppure github:anomalyco/opencode per l’ultima branch di sviluppo +``` + +> [!TIP] +> Rimuovi le versioni precedenti alla 0.1.x prima di installare. + +### App Desktop (BETA) + +OpenCode è disponibile anche come applicazione desktop. Puoi scaricarla direttamente dalla [pagina delle release](https://github.com/anomalyco/opencode/releases) oppure da [opencode.ai/download](https://opencode.ai/download). + +| Piattaforma | Download | +| --------------------- | ------------------------------------- | +| macOS (Apple Silicon) | `opencode-desktop-darwin-aarch64.dmg` | +| macOS (Intel) | `opencode-desktop-darwin-x64.dmg` | +| Windows | `opencode-desktop-windows-x64.exe` | +| Linux | `.deb`, `.rpm`, oppure AppImage | + +```bash +# macOS (Homebrew) +brew install --cask opencode-desktop +# Windows (Scoop) +scoop bucket add extras; scoop install extras/opencode-desktop +``` + +#### Directory di installazione + +Lo script di installazione rispetta il seguente ordine di priorità per il percorso di installazione: + +1. `$OPENCODE_INSTALL_DIR` – Directory di installazione personalizzata +2. `$XDG_BIN_DIR` – Percorso conforme alla XDG Base Directory Specification +3. `$HOME/bin` – Directory binaria standard dell’utente (se esiste o può essere creata) +4. `$HOME/.opencode/bin` – Fallback predefinito + +```bash +# Esempi +OPENCODE_INSTALL_DIR=/usr/local/bin curl -fsSL https://opencode.ai/install | bash +XDG_BIN_DIR=$HOME/.local/bin curl -fsSL https://opencode.ai/install | bash +``` + +### Agenti + +OpenCode include due agenti integrati tra cui puoi passare usando il tasto `Tab`. + +- **build** – Predefinito, agente con accesso completo per il lavoro di sviluppo +- **plan** – Agente in sola lettura per analisi ed esplorazione del codice + + - Nega le modifiche ai file per impostazione predefinita + - Chiede il permesso prima di eseguire comandi bash + - Ideale per esplorare codebase sconosciute o pianificare modifiche + +È inoltre incluso un sotto-agente **general** per ricerche complesse e attività multi-step. +Viene utilizzato internamente e può essere invocato usando `@general` nei messaggi. + +Scopri di più sugli [agenti](https://opencode.ai/docs/agents). + +### Documentazione + +Per maggiori informazioni su come configurare OpenCode, [**consulta la nostra documentazione**](https://opencode.ai/docs). + +### Contribuire + +Se sei interessato a contribuire a OpenCode, leggi la nostra [guida alla contribuzione](./CONTRIBUTING.md) prima di inviare una pull request. + +### Costruire su OpenCode + +Se stai lavorando a un progetto correlato a OpenCode e che utilizza “opencode” come parte del nome (ad esempio “opencode-dashboard” o “opencode-mobile”), aggiungi una nota nel tuo README per chiarire che non è sviluppato dal team OpenCode e che non è affiliato in alcun modo con noi. + +### FAQ + +#### In cosa è diverso da Claude Code? + +È molto simile a Claude Code in termini di funzionalità. Ecco le principali differenze: + +- 100% open source +- Non è legato a nessun provider. Anche se consigliamo i modelli forniti tramite [OpenCode Zen](https://opencode.ai/zen), OpenCode può essere utilizzato con Claude, OpenAI, Google o persino modelli locali. Con l’evoluzione dei modelli, le differenze tra di essi si ridurranno e i prezzi scenderanno, quindi essere indipendenti dal provider è importante. +- Supporto LSP pronto all’uso +- Forte attenzione alla TUI. OpenCode è sviluppato da utenti neovim e dai creatori di [terminal.shop](https://terminal.shop); spingeremo al limite ciò che è possibile fare nel terminale. +- Architettura client/server. Questo, ad esempio, permette a OpenCode di girare sul tuo computer mentre lo controlli da remoto tramite un’app mobile. La frontend TUI è quindi solo uno dei possibili client. + +--- + +**Unisciti alla nostra community** [Discord](https://discord.gg/opencode) | [X.com](https://x.com/opencode) diff --git a/README.ja.md b/README.ja.md index 271bcc4e1f..5f3a9e189e 100644 --- a/README.ja.md +++ b/README.ja.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.ko.md b/README.ko.md index a5e299400f..213f46bfe7 100644 --- a/README.ko.md +++ b/README.ko.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.md b/README.md index 1ee5f26975..d0acb758d9 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.no.md b/README.no.md index 43f23b8894..44371df5ed 100644 --- a/README.no.md +++ b/README.no.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.pl.md b/README.pl.md index 6465879b15..b183cd6245 100644 --- a/README.pl.md +++ b/README.pl.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.ru.md b/README.ru.md index 5ace29b849..c192036b54 100644 --- a/README.ru.md +++ b/README.ru.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.zh.md b/README.zh.md index 9908e8ffb1..9ebbe8ce93 100644 --- a/README.zh.md +++ b/README.zh.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | diff --git a/README.zht.md b/README.zht.md index e06d681aa8..298b5b35ac 100644 --- a/README.zht.md +++ b/README.zht.md @@ -22,6 +22,7 @@ Deutsch | Español | Français | + Italiano | Dansk | 日本語 | Polski | From e7c6267323816d93c149a541dfe4f8b1e968ca51 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 27 Jan 2026 00:45:43 +0000 Subject: [PATCH 117/294] chore: generate --- README.it.md | 1 - 1 file changed, 1 deletion(-) diff --git a/README.it.md b/README.it.md index f3c92c2549..89692a3668 100644 --- a/README.it.md +++ b/README.it.md @@ -95,7 +95,6 @@ OpenCode include due agenti integrati tra cui puoi passare usando il tasto `Tab` - **build** – Predefinito, agente con accesso completo per il lavoro di sviluppo - **plan** – Agente in sola lettura per analisi ed esplorazione del codice - - Nega le modifiche ai file per impostazione predefinita - Chiede il permesso prima di eseguire comandi bash - Ideale per esplorare codebase sconosciute o pianificare modifiche From 7655f51e101b318efe0e6a23e05052b344348a90 Mon Sep 17 00:00:00 2001 From: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Date: Tue, 27 Jan 2026 06:16:04 +0530 Subject: [PATCH 118/294] fix(app): add connect provier in model selector (#10706) --- .../src/components/dialog-select-model.tsx | 34 ++++++++++++++----- 1 file changed, 25 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index 2ee1d9db1a..c5a930a435 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -110,6 +110,11 @@ export function ModelSelectorPopover(props: { setStore("open", false) dialog.show(() => ) } + + const handleConnectProvider = () => { + setStore("open", false) + dialog.show(() => ) + } const language = useLanguage() createEffect(() => { @@ -207,15 +212,26 @@ export function ModelSelectorPopover(props: { onSelect={() => setStore("open", false)} class="p-1" action={ - +
+ + +
} /> From d9e8b2b65d9fba4b54cb6a7f7d677162335c8e3f Mon Sep 17 00:00:00 2001 From: Chris Yang <18487241+ysm-dev@users.noreply.github.com> Date: Mon, 26 Jan 2026 17:50:51 -0700 Subject: [PATCH 119/294] fix(desktop): disable magnification gestures on macOS (#10605) --- packages/desktop/src-tauri/Cargo.lock | 2 ++ packages/desktop/src-tauri/Cargo.toml | 4 ++++ packages/desktop/src-tauri/src/window_customizer.rs | 12 ++++++++++++ 3 files changed, 18 insertions(+) diff --git a/packages/desktop/src-tauri/Cargo.lock b/packages/desktop/src-tauri/Cargo.lock index a41739a697..294d7ad6ce 100644 --- a/packages/desktop/src-tauri/Cargo.lock +++ b/packages/desktop/src-tauri/Cargo.lock @@ -3028,6 +3028,8 @@ dependencies = [ "futures", "gtk", "listeners", + "objc2 0.6.3", + "objc2-web-kit", "reqwest", "semver", "serde", diff --git a/packages/desktop/src-tauri/Cargo.toml b/packages/desktop/src-tauri/Cargo.toml index 6296b8325f..b875f928b0 100644 --- a/packages/desktop/src-tauri/Cargo.toml +++ b/packages/desktop/src-tauri/Cargo.toml @@ -47,6 +47,10 @@ comrak = { version = "0.50", default-features = false } gtk = "0.18.2" webkit2gtk = "=2.0.1" +[target.'cfg(target_os = "macos")'.dependencies] +objc2 = "0.6" +objc2-web-kit = "0.3" + [target.'cfg(windows)'.dependencies] windows = { version = "0.61", features = [ "Win32_Foundation", diff --git a/packages/desktop/src-tauri/src/window_customizer.rs b/packages/desktop/src-tauri/src/window_customizer.rs index cd42fd0299..682f57f247 100644 --- a/packages/desktop/src-tauri/src/window_customizer.rs +++ b/packages/desktop/src-tauri/src/window_customizer.rs @@ -29,6 +29,18 @@ impl Plugin for PinchZoomDisablePlugin { gobject_ffi::g_signal_handlers_destroy(data.as_ptr().cast()); } } + + #[cfg(target_os = "macos")] + unsafe { + use objc2::rc::Retained; + use objc2_web_kit::WKWebView; + + // Get the WKWebView pointer and disable magnification gestures + // This prevents Cmd+Ctrl+scroll and pinch-to-zoom from changing the zoom level + let wk_webview: Retained = + Retained::retain(_webview.inner().cast()).unwrap(); + wk_webview.setAllowsMagnification(false); + } }); } } From b59aec6f043144963994809f667d034496e164e4 Mon Sep 17 00:00:00 2001 From: Ryan Vogel Date: Mon, 26 Jan 2026 20:50:44 -0500 Subject: [PATCH 120/294] feat: add /learn command to extract session learnings to scoped AGENTS.md files (#10717) --- .opencode/command/learn.md | 42 ++++++++++++++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) create mode 100644 .opencode/command/learn.md diff --git a/.opencode/command/learn.md b/.opencode/command/learn.md new file mode 100644 index 0000000000..fe4965a588 --- /dev/null +++ b/.opencode/command/learn.md @@ -0,0 +1,42 @@ +--- +description: Extract non-obvious learnings from session to AGENTS.md files to build codebase understanding +--- + +Analyze this session and extract non-obvious learnings to add to AGENTS.md files. + +AGENTS.md files can exist at any directory level, not just the project root. When an agent reads a file, any AGENTS.md in parent directories are automatically loaded into the context of the tool read. Place learnings as close to the relevant code as possible: + +- Project-wide learnings → root AGENTS.md +- Package/module-specific → packages/foo/AGENTS.md +- Feature-specific → src/auth/AGENTS.md + +What counts as a learning (non-obvious discoveries only): + +- Hidden relationships between files or modules +- Execution paths that differ from how code appears +- Non-obvious configuration, env vars, or flags +- Debugging breakthroughs when error messages were misleading +- API/tool quirks and workarounds +- Build/test commands not in README +- Architectural decisions and constraints +- Files that must change together + +What NOT to include: + +- Obvious facts from documentation +- Standard language/framework behavior +- Things already in an AGENTS.md +- Verbose explanations +- Session-specific details + +Process: + +1. Review session for discoveries, errors that took multiple attempts, unexpected connections +2. Determine scope - what directory does each learning apply to? +3. Read existing AGENTS.md files at relevant levels +4. Create or update AGENTS.md at the appropriate level +5. Keep entries to 1-3 lines per insight + +After updating, summarize which AGENTS.md files were created/updated and how many learnings per file. + +$ARGUMENTS From 6cf2c3e3db629757bcace9528b0c84f107bf73a7 Mon Sep 17 00:00:00 2001 From: Aiden Cline Date: Mon, 26 Jan 2026 20:57:42 -0500 Subject: [PATCH 121/294] fix: use Instance.directory instead of process.cwd() in read tool --- packages/opencode/bunfig.toml | 2 -- packages/opencode/src/tool/read.ts | 2 +- 2 files changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/bunfig.toml b/packages/opencode/bunfig.toml index c227328d5a..db64a09a98 100644 --- a/packages/opencode/bunfig.toml +++ b/packages/opencode/bunfig.toml @@ -3,5 +3,3 @@ preload = ["@opentui/solid/preload"] [test] preload = ["./test/preload.ts"] timeout = 10000 # 10 seconds (default is 5000ms) -# Enable code coverage -coverage = true diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 028a007ccc..93c1b751ff 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -24,7 +24,7 @@ export const ReadTool = Tool.define("read", { async execute(params, ctx) { let filepath = params.filePath if (!path.isAbsolute(filepath)) { - filepath = path.join(process.cwd(), filepath) + filepath = path.join(Instance.directory, filepath) } const title = path.relative(Instance.worktree, filepath) From a8c18dba8205d7707a46ec0859db672370be8963 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 19:57:34 -0600 Subject: [PATCH 122/294] fix(core): expose Instance.directory to custom tools --- packages/opencode/src/tool/read.ts | 2 +- packages/opencode/src/tool/registry.ts | 5 +++-- packages/plugin/src/tool.ts | 5 +++++ packages/web/src/content/docs/custom-tools.mdx | 10 ++++++---- 4 files changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/tool/read.ts b/packages/opencode/src/tool/read.ts index 93c1b751ff..746e0b173c 100644 --- a/packages/opencode/src/tool/read.ts +++ b/packages/opencode/src/tool/read.ts @@ -24,7 +24,7 @@ export const ReadTool = Tool.define("read", { async execute(params, ctx) { let filepath = params.filePath if (!path.isAbsolute(filepath)) { - filepath = path.join(Instance.directory, filepath) + filepath = path.resolve(Instance.directory, filepath) } const title = path.relative(Instance.worktree, filepath) diff --git a/packages/opencode/src/tool/registry.ts b/packages/opencode/src/tool/registry.ts index faa5f72bcc..2c862dfd88 100644 --- a/packages/opencode/src/tool/registry.ts +++ b/packages/opencode/src/tool/registry.ts @@ -16,7 +16,7 @@ import { Tool } from "./tool" import { Instance } from "../project/instance" import { Config } from "../config/config" import path from "path" -import { type ToolDefinition } from "@opencode-ai/plugin" +import { type ToolContext as PluginToolContext, type ToolDefinition } from "@opencode-ai/plugin" import z from "zod" import { Plugin } from "../plugin" import { WebSearchTool } from "./websearch" @@ -67,7 +67,8 @@ export namespace ToolRegistry { parameters: z.object(def.args), description: def.description, execute: async (args, ctx) => { - const result = await def.execute(args as any, ctx) + const pluginCtx = { ...ctx, directory: Instance.directory } as unknown as PluginToolContext + const result = await def.execute(args as any, pluginCtx) const out = await Truncate.output(result, {}, initCtx?.agent) return { title: "", diff --git a/packages/plugin/src/tool.ts b/packages/plugin/src/tool.ts index f759c07d2b..6b4f7f1d1e 100644 --- a/packages/plugin/src/tool.ts +++ b/packages/plugin/src/tool.ts @@ -4,6 +4,11 @@ export type ToolContext = { sessionID: string messageID: string agent: string + /** + * Current project directory for this session. + * Prefer this over process.cwd() when resolving relative paths. + */ + directory: string abort: AbortSignal metadata(input: { title?: string; metadata?: { [key: string]: any } }): void ask(input: AskInput): Promise diff --git a/packages/web/src/content/docs/custom-tools.mdx b/packages/web/src/content/docs/custom-tools.mdx index e089a035b4..ef93a64e2c 100644 --- a/packages/web/src/content/docs/custom-tools.mdx +++ b/packages/web/src/content/docs/custom-tools.mdx @@ -120,8 +120,8 @@ export default tool({ args: {}, async execute(args, context) { // Access context information - const { agent, sessionID, messageID } = context - return `Agent: ${agent}, Session: ${sessionID}, Message: ${messageID}` + const { agent, sessionID, messageID, directory } = context + return `Agent: ${agent}, Session: ${sessionID}, Message: ${messageID}, Directory: ${directory}` }, }) ``` @@ -148,6 +148,7 @@ Then create the tool definition that invokes it: ```ts title=".opencode/tools/python-add.ts" {10} import { tool } from "@opencode-ai/plugin" +import path from "path" export default tool({ description: "Add two numbers using Python", @@ -155,8 +156,9 @@ export default tool({ a: tool.schema.number().describe("First number"), b: tool.schema.number().describe("Second number"), }, - async execute(args) { - const result = await Bun.$`python3 .opencode/tools/add.py ${args.a} ${args.b}`.text() + async execute(args, context) { + const script = path.join(context.directory, ".opencode/tools/add.py") + const result = await Bun.$`python3 ${script} ${args.a} ${args.b}`.text() return result.trim() }, }) From 213c0e18ab6620cff4671e409b634b46a35e9471 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:01:16 -0600 Subject: [PATCH 123/294] fix(app): only show files in select dialog when clicking + tab --- .../app/src/components/dialog-select-file.tsx | 62 ++++++++++++++++--- packages/app/src/pages/session.tsx | 2 +- 2 files changed, 55 insertions(+), 9 deletions(-) diff --git a/packages/app/src/components/dialog-select-file.tsx b/packages/app/src/components/dialog-select-file.tsx index 8ba1ce6182..83baa7a5e2 100644 --- a/packages/app/src/components/dialog-select-file.tsx +++ b/packages/app/src/components/dialog-select-file.tsx @@ -24,13 +24,16 @@ type Entry = { path?: string } -export function DialogSelectFile() { +type DialogSelectFileMode = "all" | "files" + +export function DialogSelectFile(props: { mode?: DialogSelectFileMode }) { const command = useCommand() const language = useLanguage() const layout = useLayout() const file = useFile() const dialog = useDialog() const params = useParams() + const filesOnly = () => props.mode === "files" const sessionKey = createMemo(() => `${params.dir}${params.id ? "/" + params.id : ""}`) const tabs = createMemo(() => layout.tabs(sessionKey)) const view = createMemo(() => layout.view(sessionKey)) @@ -46,11 +49,12 @@ export function DialogSelectFile() { ] const limit = 5 - const allowed = createMemo(() => - command.options.filter( + const allowed = createMemo(() => { + if (filesOnly()) return [] + return command.options.filter( (option) => !option.disabled && !option.id.startsWith("suggested.") && option.id !== "file.open", - ), - ) + ) + }) const commandItem = (option: CommandOption): Entry => ({ id: "command:" + option.id, @@ -99,10 +103,50 @@ export function DialogSelectFile() { return items.slice(0, limit) }) - const items = async (filter: string) => { - const query = filter.trim() + const root = createMemo(() => { + const nodes = file.tree.children("") + const paths = nodes + .filter((node) => node.type === "file") + .map((node) => node.path) + .sort((a, b) => a.localeCompare(b)) + return paths.slice(0, limit).map(fileItem) + }) + + const unique = (items: Entry[]) => { + const seen = new Set() + const out: Entry[] = [] + for (const item of items) { + if (seen.has(item.id)) continue + seen.add(item.id) + out.push(item) + } + return out + } + + const items = async (text: string) => { + const query = text.trim() setGrouped(query.length > 0) + + if (!query && filesOnly()) { + const loaded = file.tree.state("")?.loaded + const pending = loaded ? Promise.resolve() : file.tree.list("") + const next = unique([...recent(), ...root()]) + + if (loaded || next.length > 0) { + void pending + return next + } + + await pending + return unique([...recent(), ...root()]) + } + if (!query) return [...picks(), ...recent()] + + if (filesOnly()) { + const files = await file.searchFiles(query) + return files.map(fileItem) + } const files = await file.searchFiles(query) const entries = files.map(fileItem) return [...list(), ...entries] @@ -146,7 +190,9 @@ export function DialogSelectFile() { dialog.show(() => )} + onClick={() => dialog.show(() => )} aria-label={language.t("command.file.open")} /> From dd1624e30e00c7931e03d337b473bc16429f34a5 Mon Sep 17 00:00:00 2001 From: Brendan Allan Date: Tue, 27 Jan 2026 17:48:43 +0800 Subject: [PATCH 124/294] desktop: deduplicate tauri configs --- .../desktop/src-tauri/tauri.prod.conf.json | 21 ------------------- 1 file changed, 21 deletions(-) diff --git a/packages/desktop/src-tauri/tauri.prod.conf.json b/packages/desktop/src-tauri/tauri.prod.conf.json index 282db5b26c..7ce4c78420 100644 --- a/packages/desktop/src-tauri/tauri.prod.conf.json +++ b/packages/desktop/src-tauri/tauri.prod.conf.json @@ -2,27 +2,6 @@ "$schema": "https://schema.tauri.app/config/2", "productName": "OpenCode", "identifier": "ai.opencode.desktop", - "app": { - "windows": [ - { - "label": "main", - "create": false, - "title": "OpenCode", - "url": "/", - "decorations": true, - "dragDropEnabled": false, - "zoomHotkeysEnabled": true, - "titleBarStyle": "Overlay", - "hiddenTitle": true, - "trafficLightPosition": { "x": 12.0, "y": 18.0 } - } - ], - "withGlobalTauri": true, - "security": { - "csp": null - }, - "macOSPrivateApi": true - }, "bundle": { "createUpdaterArtifacts": true, "icon": [ From ddffb34b99da90a40e2fdd28bff8221a2016b682 Mon Sep 17 00:00:00 2001 From: GitHub Action Date: Tue, 27 Jan 2026 12:05:48 +0000 Subject: [PATCH 125/294] ignore: update download stats 2026-01-27 --- STATS.md | 1 + 1 file changed, 1 insertion(+) diff --git a/STATS.md b/STATS.md index 4aa9a37b82..f00ebd72a3 100644 --- a/STATS.md +++ b/STATS.md @@ -212,3 +212,4 @@ | 2026-01-24 | 6,371,019 (+274,783) | 2,156,870 (+60,635) | 8,527,889 (+335,418) | | 2026-01-25 | 6,639,082 (+268,063) | 2,187,853 (+30,983) | 8,826,935 (+299,046) | | 2026-01-26 | 6,941,620 (+302,538) | 2,232,115 (+44,262) | 9,173,735 (+346,800) | +| 2026-01-27 | 7,208,093 (+266,473) | 2,280,762 (+48,647) | 9,488,855 (+315,120) | From b6565c606e4c3044fcbf98d1cce1b15537b035c0 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 20:30:49 -0600 Subject: [PATCH 126/294] fix(app): auto-scroll button sometimes sticks --- packages/app/src/pages/session.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 871d3b753d..39f5b057ea 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -1703,9 +1703,9 @@ export default function Page() { markScrollGesture(e.target) }} onScroll={(e) => { + autoScroll.handleScroll() if (!hasScrollGesture()) return markScrollGesture(e.target) - autoScroll.handleScroll() if (isDesktop()) scheduleScrollSpy(e.currentTarget) }} onClick={autoScroll.handleInteraction} From c0a5f853497de6778f1430b691e393caa54c59a7 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:59:32 -0600 Subject: [PATCH 127/294] chore(app): missing tooltips --- .../src/components/dialog-select-model.tsx | 38 ++++++++++--------- 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index c5a930a435..588527386e 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -213,24 +213,26 @@ export function ModelSelectorPopover(props: { class="p-1" action={
- - + + + + + +
} /> From 58b9b54600f12ba4ec1d80a2d1b7dee3879a0479 Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 22:58:29 -0600 Subject: [PATCH 128/294] feat(app): forward and back buttons --- packages/app/src/components/titlebar.tsx | 135 ++++++++++++++++++----- packages/app/src/context/platform.tsx | 6 + packages/app/src/entry.tsx | 6 + packages/app/src/i18n/en.ts | 1 + packages/desktop/src/index.tsx | 8 ++ packages/ui/src/components/icon.tsx | 1 + 6 files changed, 130 insertions(+), 27 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 159216a4f7..1c82560691 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,8 +1,10 @@ -import { createEffect, createMemo, Show } from "solid-js" +import { createEffect, createMemo, Show, untrack } from "solid-js" +import { createStore } from "solid-js/store" +import { useLocation, useNavigate } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" import { Icon } from "@opencode-ai/ui/icon" import { Button } from "@opencode-ai/ui/button" -import { TooltipKeybind } from "@opencode-ai/ui/tooltip" +import { Tooltip, TooltipKeybind } from "@opencode-ai/ui/tooltip" import { useTheme } from "@opencode-ai/ui/theme" import { useLayout } from "@/context/layout" @@ -16,11 +18,68 @@ export function Titlebar() { const command = useCommand() const language = useLanguage() const theme = useTheme() + const navigate = useNavigate() + const location = useLocation() const mac = createMemo(() => platform.platform === "desktop" && platform.os === "macos") const windows = createMemo(() => platform.platform === "desktop" && platform.os === "windows") const web = createMemo(() => platform.platform === "web") + const [history, setHistory] = createStore({ + stack: [] as string[], + index: 0, + action: undefined as "back" | "forward" | undefined, + }) + + const path = () => `${location.pathname}${location.search}${location.hash}` + + createEffect(() => { + 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 canBack = createMemo(() => history.index > 0) + 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 forward = () => { + if (!canForward()) return + const index = history.index + 1 + const to = history.stack[index] + if (!to) return + setHistory({ index, action: "forward" }) + navigate(to) + } + const getWin = () => { if (platform.platform !== "desktop") return @@ -106,34 +165,56 @@ export function Titlebar() { />
- - + + - - + +
+
+ /** Navigate back in history */ + back(): void + + /** Navigate forward in history */ + forward(): void + /** Send a system notification (optional deep link) */ notify(title: string, description?: string, href?: string): Promise diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index 7fe03bb6af..aa52fa1e7c 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -31,6 +31,12 @@ const platform: Platform = { openLink(url: string) { window.open(url, "_blank") }, + back() { + window.history.back() + }, + forward() { + window.history.forward() + }, restart: async () => { window.location.reload() }, diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index bca08275f7..abbe497dcf 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -167,6 +167,7 @@ export const dict = { "common.search.placeholder": "Search", "common.goBack": "Go back", + "common.goForward": "Go forward", "common.loading": "Loading", "common.loading.ellipsis": "...", "common.cancel": "Cancel", diff --git a/packages/desktop/src/index.tsx b/packages/desktop/src/index.tsx index fe9e3f92e2..b19adfeda5 100644 --- a/packages/desktop/src/index.tsx +++ b/packages/desktop/src/index.tsx @@ -80,6 +80,14 @@ const createPlatform = (password: Accessor): Platform => ({ void shellOpen(url).catch(() => undefined) }, + back() { + window.history.back() + }, + + forward() { + window.history.forward() + }, + storage: (() => { type StoreLike = { get(key: string): Promise diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index ddedddb36e..544c6abdd2 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -4,6 +4,7 @@ const icons = { "align-right": ``, "arrow-up": ``, "arrow-left": ``, + "arrow-right": ``, archive: ``, "bubble-5": ``, brain: ``, From 2180be2f3f6f414e0b40f983a0727e8d610f05ea Mon Sep 17 00:00:00 2001 From: adamelmore <2363879+adamdottv@users.noreply.github.com> Date: Mon, 26 Jan 2026 23:00:36 -0600 Subject: [PATCH 129/294] chore: cleanup --- packages/app/src/components/titlebar.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 1c82560691..7a08456c7b 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -196,18 +196,20 @@ export function Titlebar() {