From 130038eb63a94d832b0aea0d3ea9c83511db3d4d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:16:51 +1000 Subject: [PATCH 01/48] fix(app): defer unavailable notification state (#38186) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .../app/src/pages/layout/project-avatar-state.ts | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/layout/project-avatar-state.ts b/packages/app/src/pages/layout/project-avatar-state.ts index 8e5dc38d67..236f6bd405 100644 --- a/packages/app/src/pages/layout/project-avatar-state.ts +++ b/packages/app/src/pages/layout/project-avatar-state.ts @@ -13,7 +13,6 @@ export function useSessionTabAvatarState( const global = useGlobal() const notification = useNotification() const permission = usePermission() - const permissionState = createMemo(() => permission.ensureServerState(server())) const connection = createMemo(() => global.servers.list().find((item) => ServerConnection.key(item) === server())) const sync = createMemo(() => { const conn = connection() @@ -22,9 +21,10 @@ export function useSessionTabAvatarState( const hasPermissions = createMemo(() => { const serverSync = sync() if (!serverSync) return false + const permissionState = permission.ensureServerState(server()) const [store] = serverSync.child(directory(), { bootstrap: false }) return !!sessionPermissionRequest(store.session, serverSync.session.data.permission, sessionId(), (item) => { - return !permissionState().autoResponds(item, directory()) + return !permissionState.autoResponds(item, directory()) }) }) const hasQuestions = createMemo(() => { @@ -34,9 +34,11 @@ export function useSessionTabAvatarState( return !!sessionQuestionRequest(store.session, serverSync.session.data.question, sessionId()) }) const needsAttention = createMemo(() => hasPermissions() || hasQuestions()) - const unread = createMemo( - () => needsAttention() || notification.ensureServerState(server()).session.unseenCount(sessionId()) > 0, - ) + const notificationState = createMemo(() => { + if (!connection()) return + return notification.ensureServerState(server()) + }) + const unread = createMemo(() => needsAttention() || (notificationState()?.session.unseenCount(sessionId()) ?? 0) > 0) const loading = createMemo(() => { const serverSync = sync() if (!serverSync) return false From c9db6e9a1fe181fad2259689ef4ad9a5e89fbd5b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 22 Jul 2026 11:47:42 +1000 Subject: [PATCH 02/48] fix(app): show running shell command (#38080) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .../session-timeline-lifecycle-state.spec.ts | 17 +++++++++++++++++ .../session-ui/src/components/basic-tool.tsx | 5 +++-- .../session-ui/src/components/message-part.tsx | 3 ++- 3 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts index 3e2b171bca..b303071c87 100644 --- a/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts +++ b/packages/app/e2e/regression/session-timeline-lifecycle-state.spec.ts @@ -32,6 +32,23 @@ for (const expanded of [false, true]) { }) } +test("shows and expands a running shell command without shimmering it", async ({ page }) => { + const id = "prt_shell_running_command" + const command = "sleep 10 && echo done" + await setupTimeline(page, { + messages: [userMessage(), assistantMessage([shell(id, "running", "still running", command)], { completed: false })], + settings: { shellToolPartsExpanded: false }, + }) + + const tool = page.locator(`[data-timeline-part-id="${id}"]`) + await expect(tool.locator('[data-component="text-shimmer"]')).toHaveAttribute("data-active", "true") + await expect(tool.locator('[data-component="shell-submessage"]')).toHaveText(command) + await expect(tool.locator('[data-component="shell-submessage"] [data-component="text-shimmer"]')).toHaveCount(0) + await tool.locator('[data-slot="collapsible-trigger"]').click() + await expect(tool.locator('[data-slot="collapsible-trigger"]')).toHaveAttribute("aria-expanded", "true") + await expect(tool.locator('[data-slot="bash-pre"]')).toContainText("still running") +}) + test("transitions thinking and hidden reasoning through busy to idle", async ({ page }) => { const reasoningID = "prt_reasoning_hidden" const assistant = assistantMessage([reasoningPart(reasoningID, "## Inspecting stability")], { completed: false }) diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index a3ce5c13b5..2b73db24ed 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -32,6 +32,7 @@ export interface BasicToolProps { open?: boolean onOpenChange?: (open: boolean) => void forceOpen?: boolean + allowOpenWhilePending?: boolean defer?: boolean locked?: boolean animated?: boolean @@ -176,7 +177,7 @@ export function BasicTool(props: BasicToolProps) { }) const handleOpenChange = (value: boolean) => { - if (pending()) return + if (pending() && !props.allowOpenWhilePending) return if (props.locked && !value) return setOpen(value) } @@ -247,7 +248,7 @@ export function BasicTool(props: BasicToolProps) { - + diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 601d7839ef..ce2f7b25c3 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -2126,13 +2126,14 @@ ToolRegistry.register({ (
- +
From 0a601cf334b9a83cc2854108a2b860f25e6e7e8e Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 22 Jul 2026 12:42:44 +0800 Subject: [PATCH 03/48] fix(docs): correct Kimi K2.7 Code request limits (#38248) --- packages/web/src/content/docs/ar/go.mdx | 2 +- packages/web/src/content/docs/bs/go.mdx | 2 +- packages/web/src/content/docs/da/go.mdx | 2 +- packages/web/src/content/docs/de/go.mdx | 2 +- packages/web/src/content/docs/es/go.mdx | 2 +- packages/web/src/content/docs/fr/go.mdx | 2 +- packages/web/src/content/docs/go.mdx | 2 +- packages/web/src/content/docs/it/go.mdx | 2 +- packages/web/src/content/docs/ja/go.mdx | 2 +- packages/web/src/content/docs/ko/go.mdx | 2 +- packages/web/src/content/docs/nb/go.mdx | 2 +- packages/web/src/content/docs/pl/go.mdx | 2 +- packages/web/src/content/docs/pt-br/go.mdx | 2 +- packages/web/src/content/docs/ru/go.mdx | 2 +- packages/web/src/content/docs/th/go.mdx | 2 +- packages/web/src/content/docs/tr/go.mdx | 2 +- packages/web/src/content/docs/zh-cn/go.mdx | 2 +- packages/web/src/content/docs/zh-tw/go.mdx | 2 +- 18 files changed, 18 insertions(+), 18 deletions(-) diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index 0597f3adf1..dee282b3d5 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -87,7 +87,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index 26b5575a01..b60f94fa1c 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -97,7 +97,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index 280891deee..7c7eba628b 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -97,7 +97,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 2bb3f0b742..553781ea70 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -89,7 +89,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 4aa1ca46e3..3ae730f9ec 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -97,7 +97,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 95849616b0..186ed33012 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -87,7 +87,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index 8c46464086..1e38745b62 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -97,7 +97,7 @@ The table below provides an estimated request count based on typical Go usage pa | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 65369e1e86..7edec6662f 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -95,7 +95,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index 8bb9139e83..ca9bb3fe12 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -87,7 +87,7 @@ OpenCode Goには以下の制限が含まれています: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index 02f88d2828..eafd6ae31d 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -87,7 +87,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index 60be1cc7bb..499fbde3a8 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -97,7 +97,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index 4a867bf0e7..a1de423ff3 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -91,7 +91,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 96c1addfcc..892055dd57 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -97,7 +97,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 62305fbdb6..14882e8db3 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -97,7 +97,7 @@ OpenCode Go включает следующие лимиты: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 36036426da..4cfbce73cd 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -87,7 +87,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index e24ead2959..afb80de220 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -87,7 +87,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 604b910159..0225c533a1 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -87,7 +87,7 @@ OpenCode Go 包含以下限制: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 6434504a3e..08a0bf43f6 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -87,7 +87,7 @@ OpenCode Go 包含以下限制: | GLM-5.2 | 880 | 2,150 | 4,300 | | GLM-5.1 | 880 | 2,150 | 4,300 | | Kimi K3 | 110 | 250 | 490 | -| Kimi K2.7 Code | 1,350 | 4,630 | 9,250 | +| Kimi K2.7 Code | 1,350 | 3,380 | 6,750 | | Kimi K2.6 | 1,150 | 2,880 | 5,750 | | MiMo-V2.5 | 30,100 | 75,200 | 150,400 | | MiMo-V2.5-Pro | 3,250 | 8,150 | 16,300 | From 50eee1f5a4b8580ef01a152ab21937ac12dc6ccc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Jul 2026 10:38:23 -0500 Subject: [PATCH 04/48] fix(provider): correct MiniMax M3 thinking variants (#38330) --- packages/opencode/src/provider/transform.ts | 6 ++++++ .../opencode/test/provider/transform.test.ts | 16 ++++++++++++++++ 2 files changed, 22 insertions(+) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index f0a47d8b0e..25dc78bdd0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -693,6 +693,12 @@ export function variants(model: Provider.Model): Record { }) }) + test.each(["nvidia", "lilac"])("%s minimax m3 returns chat template thinking toggles", (providerID) => { + const model = createMockModel({ + id: `${providerID}/minimaxai/minimax-m3`, + providerID, + api: { + id: "minimaxai/minimax-m3", + url: "https://api.example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + }) + expect(ProviderTransform.variants(model)).toEqual({ + none: { chat_template_kwargs: { thinking_mode: "disabled" } }, + thinking: { chat_template_kwargs: { thinking_mode: "enabled" } }, + }) + }) + test("glm returns empty object", () => { const model = createMockModel({ id: "glm/glm-4", From 411eff73f026d4950c07947c4d983788cb615baa Mon Sep 17 00:00:00 2001 From: Jack Date: Thu, 23 Jul 2026 00:41:48 +0800 Subject: [PATCH 05/48] feat(go): add Hy3 to Go model lineup (#38349) Co-authored-by: opencode --- packages/console/app/src/i18n/ar.ts | 8 ++++---- packages/console/app/src/i18n/br.ts | 8 ++++---- packages/console/app/src/i18n/da.ts | 8 ++++---- packages/console/app/src/i18n/de.ts | 8 ++++---- packages/console/app/src/i18n/en.ts | 8 ++++---- packages/console/app/src/i18n/es.ts | 8 ++++---- packages/console/app/src/i18n/fr.ts | 8 ++++---- packages/console/app/src/i18n/it.ts | 8 ++++---- packages/console/app/src/i18n/ja.ts | 8 ++++---- packages/console/app/src/i18n/ko.ts | 8 ++++---- packages/console/app/src/i18n/no.ts | 8 ++++---- packages/console/app/src/i18n/pl.ts | 8 ++++---- packages/console/app/src/i18n/ru.ts | 8 ++++---- packages/console/app/src/i18n/th.ts | 8 ++++---- packages/console/app/src/i18n/tr.ts | 8 ++++---- packages/console/app/src/i18n/uk.ts | 8 ++++---- packages/console/app/src/i18n/zh.ts | 8 ++++---- packages/console/app/src/i18n/zht.ts | 8 ++++---- packages/console/app/src/routes/go/index.tsx | 2 ++ .../app/src/routes/workspace/[id]/go/lite-section.tsx | 1 + packages/web/src/content/docs/ar/go.mdx | 5 +++++ packages/web/src/content/docs/bs/go.mdx | 5 +++++ packages/web/src/content/docs/da/go.mdx | 5 +++++ packages/web/src/content/docs/de/go.mdx | 5 +++++ packages/web/src/content/docs/es/go.mdx | 5 +++++ packages/web/src/content/docs/fr/go.mdx | 5 +++++ packages/web/src/content/docs/go.mdx | 5 +++++ packages/web/src/content/docs/it/go.mdx | 5 +++++ packages/web/src/content/docs/ja/go.mdx | 5 +++++ packages/web/src/content/docs/ko/go.mdx | 5 +++++ packages/web/src/content/docs/nb/go.mdx | 5 +++++ packages/web/src/content/docs/pl/go.mdx | 5 +++++ packages/web/src/content/docs/pt-br/go.mdx | 5 +++++ packages/web/src/content/docs/ru/go.mdx | 5 +++++ packages/web/src/content/docs/th/go.mdx | 5 +++++ packages/web/src/content/docs/tr/go.mdx | 5 +++++ packages/web/src/content/docs/zh-cn/go.mdx | 5 +++++ packages/web/src/content/docs/zh-tw/go.mdx | 5 +++++ 38 files changed, 165 insertions(+), 72 deletions(-) diff --git a/packages/console/app/src/i18n/ar.ts b/packages/console/app/src/i18n/ar.ts index 991f7fb2d3..082e211e0b 100644 --- a/packages/console/app/src/i18n/ar.ts +++ b/packages/console/app/src/i18n/ar.ts @@ -254,7 +254,7 @@ export const dict = { "go.title": "OpenCode Go | نماذج برمجة منخفضة التكلفة للجميع", "go.banner.text": "يحصل Kimi K3 على حدود استخدام مضاعفة لفترة محدودة", "go.meta.description": - "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود طلب سخية لمدة 5 ساعات لـ Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3.", "go.hero.title": "نماذج برمجة منخفضة التكلفة للجميع", "go.hero.body": "يجلب Go البرمجة الوكيلة للمبرمجين حول العالم. يوفر حدودًا سخية ووصولًا موثوقًا إلى أقوى النماذج مفتوحة المصدر، حتى تتمكن من البناء باستخدام وكلاء أقوياء دون القلق بشأن التكلفة أو التوفر.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "حدود سخية ووصول موثوق", "go.problem.item3": "مصمم لأكبر عدد ممكن من المبرمجين", "go.problem.item4": - "يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash", + "يتضمن Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3", "go.how.title": "كيف يعمل Go", "go.how.body": "يبدأ Go من $5 للشهر الأول، ثم $10/شهر. يمكنك استخدامه مع OpenCode أو أي وكيل.", "go.how.step1.title": "أنشئ حسابًا", @@ -326,7 +326,7 @@ export const dict = { "go.faq.a2": "يتضمن Go النماذج المدرجة أدناه، مع حدود سخية وإتاحة موثوقة.", "go.faq.q3": "هل Go هو نفسه Zen؟", "go.faq.a3": - "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash.", + "لا. Zen هو الدفع حسب الاستخدام، بينما يبدأ Go من $5 للشهر الأول، ثم $10/شهر، مع حدود سخية ووصول موثوق إلى نماذج المصدر المفتوح Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3.", "go.faq.q4": "كم تكلفة Go؟", "go.faq.a4.p1.beforePricing": "تكلفة Go", "go.faq.a4.p1.pricingLink": "$5 للشهر الأول", @@ -349,7 +349,7 @@ export const dict = { "go.faq.q9": "ما الفرق بين النماذج المجانية وGo؟", "go.faq.a9": - "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", + "تشمل النماذج المجانية Big Pickle بالإضافة إلى النماذج الترويجية المتاحة في ذلك الوقت، مع حصة 200 طلب/يوم. يتضمن Go نماذج Grok 4.5 وGLM-5.2 وGLM-5.1 وKimi K3 وKimi K2.7 Code وKimi K2.6 وMiMo-V2.5-Pro وMiMo-V2.5 وQwen3.7 Max وQwen3.7 Plus وQwen3.6 Plus وMiniMax M2.7 وMiniMax M3 وDeepSeek V4 Pro وDeepSeek V4 Flash وHy3 مع حصص طلبات أعلى مطبقة عبر نوافذ متجددة (5 ساعات، أسبوعيًا، وشهريًا)، تعادل تقريبًا 12 دولارًا كل 5 ساعات، و30 دولارًا في الأسبوع، و60 دولارًا في الشهر (تختلف أعداد الطلبات الفعلية حسب النموذج والاستخدام).", "zen.api.error.rateLimitExceeded": "تم تجاوز حد الطلبات. يرجى المحاولة مرة أخرى لاحقًا.", "zen.api.error.modelNotSupported": "النموذج {{model}} غير مدعوم", diff --git a/packages/console/app/src/i18n/br.ts b/packages/console/app/src/i18n/br.ts index 9bef420e85..69979b0a44 100644 --- a/packages/console/app/src/i18n/br.ts +++ b/packages/console/app/src/i18n/br.ts @@ -258,7 +258,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de codificação de baixo custo para todos", "go.banner.text": "Kimi K3 tem limites de uso 2x maiores por tempo limitado", "go.meta.description": - "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "O Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos de solicitação de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.hero.title": "Modelos de codificação de baixo custo para todos", "go.hero.body": "O Go traz a codificação com agentes para programadores em todo o mundo. Oferecendo limites generosos e acesso confiável aos modelos de código aberto mais capazes, para que você possa construir com agentes poderosos sem se preocupar com custos ou disponibilidade.", @@ -307,7 +307,7 @@ export const dict = { "go.problem.item2": "Limites generosos e acesso confiável", "go.problem.item3": "Feito para o maior número possível de programadores", "go.problem.item4": - "Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3", "go.how.title": "Como o Go funciona", "go.how.body": "O Go começa em $5 no primeiro mês, depois $10/mês. Você pode usá-lo com o OpenCode ou qualquer agente.", @@ -333,7 +333,7 @@ export const dict = { "go.faq.a2": "O Go inclui os modelos listados abaixo, com limites generosos e acesso confiável.", "go.faq.q3": "O Go é o mesmo que o Zen?", "go.faq.a3": - "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Não. Zen é pay-as-you-go, enquanto o Go começa em $5 no primeiro mês, depois $10/mês, com limites generosos e acesso confiável aos modelos open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.faq.q4": "Quanto custa o Go?", "go.faq.a4.p1.beforePricing": "O Go custa", "go.faq.a4.p1.pricingLink": "$5 no primeiro mês", @@ -357,7 +357,7 @@ export const dict = { "go.faq.q9": "Qual a diferença entre os modelos gratuitos e o Go?", "go.faq.a9": - "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", + "Os modelos gratuitos incluem Big Pickle e modelos promocionais disponíveis no momento, com uma cota de 200 requisições/dia. O Go inclui Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 com cotas de requisição mais altas aplicadas em janelas móveis (5 horas, semanal e mensal), aproximadamente equivalentes a $12 por 5 horas, $30 por semana e $60 por mês (as contagens reais de requisições variam de acordo com o modelo e o uso).", "zen.api.error.rateLimitExceeded": "Limite de taxa excedido. Por favor, tente novamente mais tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} não suportado", diff --git a/packages/console/app/src/i18n/da.ts b/packages/console/app/src/i18n/da.ts index e7c8c8feaf..43d8d51abb 100644 --- a/packages/console/app/src/i18n/da.ts +++ b/packages/console/app/src/i18n/da.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Kodningsmodeller til lav pris for alle", "go.banner.text": "Kimi K3 får fordoblet brugsgrænse i en begrænset periode", "go.meta.description": - "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter ved $5 for den første måned, derefter $10/måned, med generøse 5-timers anmodningsgrænser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.hero.title": "Kodningsmodeller til lav pris for alle", "go.hero.body": "Go bringer agentisk kodning til programmører over hele verden. Med generøse grænser og pålidelig adgang til de mest kapable open source-modeller, så du kan bygge med kraftfulde agenter uden at bekymre dig om omkostninger eller tilgængelighed.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Generøse grænser og pålidelig adgang", "go.problem.item3": "Bygget til så mange programmører som muligt", "go.problem.item4": - "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3", "go.how.title": "Hvordan Go virker", "go.how.body": "Go starter ved $5 for den første måned, derefter $10/måned. Du kan bruge det med OpenCode eller enhver agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellerne nedenfor med generøse grænser og pålidelig adgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nej. Zen er pay-as-you-go, mens Go starter ved $5 for den første måned, derefter $10/måned, med generøse grænser og pålidelig adgang til open source-modellerne Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.faq.q4": "Hvad koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Hvad er forskellen på gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", + "Gratis modeller inkluderer Big Pickle plus salgsfremmende modeller tilgængelige på det tidspunkt, med en kvote på 200 forespørgsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 med højere anmodningskvoter håndhævet over rullende vinduer (5-timers, ugentlig og månedlig), nogenlunde svarende til $12 pr. 5 timer, $30 pr. uge og $60 pr. måned (faktiske anmodningstal varierer efter model og brug).", "zen.api.error.rateLimitExceeded": "Hastighedsgrænse overskredet. Prøv venligst igen senere.", "zen.api.error.modelNotSupported": "Model {{model}} understøttes ikke", diff --git a/packages/console/app/src/i18n/de.ts b/packages/console/app/src/i18n/de.ts index 465b24568f..99446d92b0 100644 --- a/packages/console/app/src/i18n/de.ts +++ b/packages/console/app/src/i18n/de.ts @@ -258,7 +258,7 @@ export const dict = { "go.title": "OpenCode Go | Kostengünstige Coding-Modelle für alle", "go.banner.text": "Kimi K3 erhält für begrenzte Zeit 2x Nutzungslimits", "go.meta.description": - "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Go beginnt bei $5 für den ersten Monat, danach $10/Monat, mit großzügigen 5-Stunden-Anfragelimits für Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3.", "go.hero.title": "Kostengünstige Coding-Modelle für alle", "go.hero.body": "Go bringt Agentic Coding zu Programmierern auf der ganzen Welt. Mit großzügigen Limits und zuverlässigem Zugang zu den leistungsfähigsten Open-Source-Modellen, damit du mit leistungsstarken Agenten entwickeln kannst, ohne dir Gedanken über Kosten oder Verfügbarkeit zu machen.", @@ -306,7 +306,7 @@ export const dict = { "go.problem.item2": "Großzügige Limits und zuverlässiger Zugang", "go.problem.item3": "Für so viele Programmierer wie möglich gebaut", "go.problem.item4": - "Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash", + "Beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3", "go.how.title": "Wie Go funktioniert", "go.how.body": "Go beginnt bei $5 für den ersten Monat, danach $10/Monat. Du kannst es mit OpenCode oder jedem Agenten nutzen.", @@ -332,7 +332,7 @@ export const dict = { "go.faq.a2": "Go umfasst die unten aufgeführten Modelle mit großzügigen Limits und zuverlässigem Zugriff.", "go.faq.q3": "Ist Go dasselbe wie Zen?", "go.faq.a3": - "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash.", + "Nein. Zen ist Pay-as-you-go, während Go bei $5 für den ersten Monat beginnt, danach $10/Monat, mit großzügigen Limits und zuverlässigem Zugang zu den Open-Source-Modellen Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3.", "go.faq.q4": "Wie viel kostet Go?", "go.faq.a4.p1.beforePricing": "Go kostet", "go.faq.a4.p1.pricingLink": "$5 im ersten Monat", @@ -356,7 +356,7 @@ export const dict = { "go.faq.q9": "Was ist der Unterschied zwischen kostenlosen Modellen und Go?", "go.faq.a9": - "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro und DeepSeek V4 Flash mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", + "Kostenlose Modelle beinhalten Big Pickle sowie Werbemodelle, die zum jeweiligen Zeitpunkt verfügbar sind, mit einem Kontingent von 200 Anfragen/Tag. Go beinhaltet Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash und Hy3 mit höheren Anfragekontingenten, die über rollierende Zeitfenster (5 Stunden, wöchentlich und monatlich) durchgesetzt werden, grob äquivalent zu $12 pro 5 Stunden, $30 pro Woche und $60 pro Monat (tatsächliche Anfragezahlen variieren je nach Modell und Nutzung).", "zen.api.error.rateLimitExceeded": "Ratenlimit überschritten. Bitte versuche es später erneut.", "zen.api.error.modelNotSupported": "Modell {{model}} wird nicht unterstützt", diff --git a/packages/console/app/src/i18n/en.ts b/packages/console/app/src/i18n/en.ts index 7d0531e6f0..690658c657 100644 --- a/packages/console/app/src/i18n/en.ts +++ b/packages/console/app/src/i18n/en.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | Low cost coding models for everyone", "go.banner.text": "Kimi K3 gets 2× usage limits for a limited time", "go.meta.description": - "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "Go starts at $5 for your first month, then $10/month, with generous 5-hour request limits for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3.", "go.hero.title": "Low cost coding models for everyone", "go.hero.body": "Go brings agentic coding to programmers around the world. Offering generous limits and reliable access to the most capable open-source models, so you can build with powerful agents without worrying about cost or availability.", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "Generous limits and reliable access", "go.problem.item3": "Built for as many programmers as possible", "go.problem.item4": - "Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash", + "Includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3", "go.how.title": "How Go works", "go.how.body": "Go starts at $5 for your first month, then $10/month. You can use it with OpenCode or any agent.", "go.how.step1.title": "Create an account", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go includes the models listed below, with generous limits and reliable access.", "go.faq.q3": "Is Go the same as Zen?", "go.faq.a3": - "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash.", + "No. Zen is pay-as-you-go, while Go starts at $5 for your first month, then $10/month, with generous limits and reliable access to open-source models Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3.", "go.faq.q4": "How much does Go cost?", "go.faq.a4.p1.beforePricing": "Go costs", "go.faq.a4.p1.pricingLink": "$5 first month", @@ -351,7 +351,7 @@ export const dict = { "go.faq.q9": "What is the difference between free models and Go?", "go.faq.a9": - "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, and DeepSeek V4 Flash with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", + "Free models include Big Pickle plus promotional models available at the time, with a quota of 200 requests/day. Go includes Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, and Hy3 with higher request quotas enforced across rolling windows (5-hour, weekly, and monthly), roughly equivalent to $12 per 5 hours, $30 per week, and $60 per month (actual request counts vary by model and usage).", "zen.api.error.rateLimitExceeded": "Rate limit exceeded. Please try again later.", "zen.api.error.modelNotSupported": "Model {{model}} is not supported", diff --git a/packages/console/app/src/i18n/es.ts b/packages/console/app/src/i18n/es.ts index 08d30aef68..bb1a44138f 100644 --- a/packages/console/app/src/i18n/es.ts +++ b/packages/console/app/src/i18n/es.ts @@ -259,7 +259,7 @@ export const dict = { "go.title": "OpenCode Go | Modelos de programación de bajo coste para todos", "go.banner.text": "Kimi K3 tiene límites de uso 2x mayores por tiempo limitado", "go.meta.description": - "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "Go comienza en $5 el primer mes, luego 10 $/mes, con generosos límites de solicitudes de 5 horas para Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3.", "go.hero.title": "Modelos de programación de bajo coste para todos", "go.hero.body": "Go lleva la programación agéntica a programadores de todo el mundo. Ofrece límites generosos y acceso fiable a los modelos de código abierto más capaces, para que puedas crear con agentes potentes sin preocuparte por el coste o la disponibilidad.", @@ -308,7 +308,7 @@ export const dict = { "go.problem.item2": "Límites generosos y acceso fiable", "go.problem.item3": "Creado para tantos programadores como sea posible", "go.problem.item4": - "Incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash", + "Incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3", "go.how.title": "Cómo funciona Go", "go.how.body": "Go comienza en $5 el primer mes, luego 10 $/mes. Puedes usarlo con OpenCode o cualquier agente.", "go.how.step1.title": "Crear una cuenta", @@ -333,7 +333,7 @@ export const dict = { "go.faq.a2": "Go incluye los modelos que se indican abajo, con límites generosos y acceso confiable.", "go.faq.q3": "¿Es Go lo mismo que Zen?", "go.faq.a3": - "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash.", + "No. Zen es pago por uso, mientras que Go comienza en $5 el primer mes, luego 10 $/mes, con límites generosos y acceso fiable a los modelos de código abierto Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3.", "go.faq.q4": "¿Cuánto cuesta Go?", "go.faq.a4.p1.beforePricing": "Go cuesta", "go.faq.a4.p1.pricingLink": "$5 el primer mes", @@ -357,7 +357,7 @@ export const dict = { "go.faq.q9": "¿Cuál es la diferencia entre los modelos gratuitos y Go?", "go.faq.a9": - "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro y DeepSeek V4 Flash con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", + "Los modelos gratuitos incluyen Big Pickle más modelos promocionales disponibles en el momento, con una cuota de 200 solicitudes/día. Go incluye Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash y Hy3 con cuotas de solicitud más altas aplicadas a través de ventanas móviles (5 horas, semanal y mensual), aproximadamente equivalente a 12 $ por 5 horas, 30 $ por semana y 60 $ por mes (los recuentos reales de solicitudes varían según el modelo y el uso).", "zen.api.error.rateLimitExceeded": "Límite de tasa excedido. Por favor, inténtalo de nuevo más tarde.", "zen.api.error.modelNotSupported": "Modelo {{model}} no soportado", diff --git a/packages/console/app/src/i18n/fr.ts b/packages/console/app/src/i18n/fr.ts index a0b8444195..4d0ff288d6 100644 --- a/packages/console/app/src/i18n/fr.ts +++ b/packages/console/app/src/i18n/fr.ts @@ -260,7 +260,7 @@ export const dict = { "go.title": "OpenCode Go | Modèles de code à faible coût pour tous", "go.banner.text": "Kimi K3 bénéficie de limites d’utilisation 2x supérieures pour une durée limitée", "go.meta.description": - "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites de requêtes généreuses sur 5 heures pour Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3.", "go.hero.title": "Modèles de code à faible coût pour tous", "go.hero.body": "Go apporte le codage agentique aux programmeurs du monde entier. Offrant des limites généreuses et un accès fiable aux modèles open source les plus capables, pour que vous puissiez construire avec des agents puissants sans vous soucier du coût ou de la disponibilité.", @@ -308,7 +308,7 @@ export const dict = { "go.problem.item2": "Limites généreuses et accès fiable", "go.problem.item3": "Conçu pour autant de programmeurs que possible", "go.problem.item4": - "Inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash", + "Inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3", "go.how.title": "Comment fonctionne Go", "go.how.body": "Go commence à $5 pour le premier mois, puis 10 $/mois. Vous pouvez l'utiliser avec OpenCode ou n'importe quel agent.", @@ -334,7 +334,7 @@ export const dict = { "go.faq.a2": "Go inclut les modèles ci-dessous, avec des limites généreuses et un accès fiable.", "go.faq.q3": "Est-ce que Go est la même chose que Zen ?", "go.faq.a3": - "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash.", + "Non. Zen est un paiement à l'utilisation, tandis que Go commence à $5 pour le premier mois, puis 10 $/mois, avec des limites généreuses et un accès fiable aux modèles open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3.", "go.faq.q4": "Combien coûte Go ?", "go.faq.a4.p1.beforePricing": "Go coûte", "go.faq.a4.p1.pricingLink": "$5 le premier mois", @@ -357,7 +357,7 @@ export const dict = { "Oui, vous pouvez utiliser Go avec n'importe quel agent. Suivez les instructions de configuration dans votre agent de code préféré.", "go.faq.q9": "Quelle est la différence entre les modèles gratuits et Go ?", "go.faq.a9": - "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro et DeepSeek V4 Flash avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", + "Les modèles gratuits incluent Big Pickle ainsi que des modèles promotionnels disponibles à ce moment-là, avec un quota de 200 requêtes/jour. Go inclut Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash et Hy3 avec des quotas de requêtes plus élevés appliqués sur des fenêtres glissantes (5 heures, hebdomadaire et mensuelle), à peu près équivalent à 12 $ par 5 heures, 30 $ par semaine et 60 $ par mois (le nombre réel de requêtes varie selon le modèle et l'utilisation).", "zen.api.error.rateLimitExceeded": "Limite de débit dépassée. Veuillez réessayer plus tard.", "zen.api.error.modelNotSupported": "Modèle {{model}} non pris en charge", diff --git a/packages/console/app/src/i18n/it.ts b/packages/console/app/src/i18n/it.ts index a5e37dfc60..effeb1fdb4 100644 --- a/packages/console/app/src/i18n/it.ts +++ b/packages/console/app/src/i18n/it.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Modelli di coding a basso costo per tutti", "go.banner.text": "Kimi K3 offre limiti di utilizzo 2x superiori per un periodo limitato", "go.meta.description": - "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "Go inizia a $5 per il primo mese, poi $10/mese, con generosi limiti di richiesta di 5 ore per Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.hero.title": "Modelli di coding a basso costo per tutti", "go.hero.body": "Go porta il coding agentico ai programmatori di tutto il mondo. Offrendo limiti generosi e un accesso affidabile ai modelli open source più capaci, in modo da poter costruire con agenti potenti senza preoccuparsi dei costi o della disponibilità.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Limiti generosi e accesso affidabile", "go.problem.item3": "Costruito per il maggior numero possibile di programmatori", "go.problem.item4": - "Include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash", + "Include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3", "go.how.title": "Come funziona Go", "go.how.body": "Go inizia a $5 per il primo mese, poi $10/mese. Puoi usarlo con OpenCode o qualsiasi agente.", "go.how.step1.title": "Crea un account", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go include i modelli elencati di seguito, con limiti generosi e accesso affidabile.", "go.faq.q3": "Go è lo stesso di Zen?", "go.faq.a3": - "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash.", + "No. Zen è a consumo, mentre Go inizia a $5 per il primo mese, poi $10/mese, con limiti generosi e accesso affidabile ai modelli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3.", "go.faq.q4": "Quanto costa Go?", "go.faq.a4.p1.beforePricing": "Go costa", "go.faq.a4.p1.pricingLink": "$5 il primo mese", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "Qual è la differenza tra i modelli gratuiti e Go?", "go.faq.a9": - "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro e DeepSeek V4 Flash con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", + "I modelli gratuiti includono Big Pickle più modelli promozionali disponibili al momento, con una quota di 200 richieste/giorno. Go include Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash e Hy3 con quote di richiesta più elevate applicate su finestre mobili (5 ore, settimanale e mensile), approssimativamente equivalenti a $12 ogni 5 ore, $30 a settimana e $60 al mese (il conteggio effettivo delle richieste varia in base al modello e all'utilizzo).", "zen.api.error.rateLimitExceeded": "Limite di richieste superato. Riprova più tardi.", "zen.api.error.modelNotSupported": "Modello {{model}} non supportato", diff --git a/packages/console/app/src/i18n/ja.ts b/packages/console/app/src/i18n/ja.ts index aca480b719..6dfd750c6a 100644 --- a/packages/console/app/src/i18n/ja.ts +++ b/packages/console/app/src/i18n/ja.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | すべての人のための低価格なコーディングモデル", "go.banner.text": "Kimi K3の利用上限が期間限定で2倍に", "go.meta.description": - "Goは最初の月$5、その後$10/月で、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashに対して5時間のゆとりあるリクエスト上限があります。", + "Goは最初の月$5、その後$10/月で、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3に対して5時間のゆとりあるリクエスト上限があります。", "go.hero.title": "すべての人のための低価格なコーディングモデル", "go.hero.body": "Goは、世界中のプログラマーにエージェント型コーディングをもたらします。最も高性能なオープンソースモデルへの十分な制限と安定したアクセスを提供し、コストや可用性を気にすることなく強力なエージェントで構築できます。", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "十分な制限と安定したアクセス", "go.problem.item3": "できるだけ多くのプログラマーのために構築", "go.problem.item4": - "Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashを含む", + "Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3を含む", "go.how.title": "Goの仕組み", "go.how.body": "Goは最初の月$5、その後$10/月で始まります。OpenCodeまたは任意のエージェントで使えます。", "go.how.step1.title": "アカウントを作成", @@ -329,7 +329,7 @@ export const dict = { "go.faq.a2": "Go には、十分な利用上限と安定したアクセスを備えた、以下のモデルが含まれます。", "go.faq.q3": "GoはZenと同じですか?", "go.faq.a3": - "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashのオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", + "いいえ。Zenは従量課金制ですが、Goは最初の月$5、その後$10/月で始まり、Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3のオープンソースモデルに対して、ゆとりある上限と信頼できるアクセスを提供します。", "go.faq.q4": "Goの料金は?", "go.faq.a4.p1.beforePricing": "Goは", "go.faq.a4.p1.pricingLink": "最初の月$5", @@ -353,7 +353,7 @@ export const dict = { "go.faq.q9": "無料モデルとGoの違いは何ですか?", "go.faq.a9": - "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGrok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flashが含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", + "無料モデルにはBig Pickleと、その時点で利用可能なプロモーションモデルが含まれ、1日200リクエストの制限があります。GoにはGrok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash、Hy3が含まれ、ローリングウィンドウ(5時間、週間、月間)全体でより高いリクエスト制限が適用されます。これは概算で5時間あたり$12、週間$30、月間$60相当です(実際のリクエスト数はモデルと使用状況により異なります)。", "zen.api.error.rateLimitExceeded": "レート制限を超えました。後でもう一度お試しください。", "zen.api.error.modelNotSupported": "モデル {{model}} はサポートされていません", diff --git a/packages/console/app/src/i18n/ko.ts b/packages/console/app/src/i18n/ko.ts index f1e2235d7e..a24e988d71 100644 --- a/packages/console/app/src/i18n/ko.ts +++ b/packages/console/app/src/i18n/ko.ts @@ -252,7 +252,7 @@ export const dict = { "go.title": "OpenCode Go | 모두를 위한 저비용 코딩 모델", "go.banner.text": "Kimi K3 사용 한도가 한시적으로 2배 확대됩니다", "go.meta.description": - "Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash에 대해 넉넉한 5시간 요청 한도를 제공합니다.", + "Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3에 대해 넉넉한 5시간 요청 한도를 제공합니다.", "go.hero.title": "모두를 위한 저비용 코딩 모델", "go.hero.body": "Go는 전 세계 프로그래머들에게 에이전트 코딩을 제공합니다. 가장 유능한 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공하므로, 비용이나 가용성 걱정 없이 강력한 에이전트로 빌드할 수 있습니다.", @@ -301,7 +301,7 @@ export const dict = { "go.problem.item2": "넉넉한 한도와 안정적인 액세스", "go.problem.item3": "가능한 한 많은 프로그래머를 위해 제작됨", "go.problem.item4": - "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 포함", + "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 포함", "go.how.title": "Go 작동 방식", "go.how.body": "Go는 첫 달 $5, 이후 $10/월로 시작합니다. OpenCode 또는 어떤 에이전트와도 함께 사용할 수 있습니다.", "go.how.step1.title": "계정 생성", @@ -325,7 +325,7 @@ export const dict = { "go.faq.a2": "Go에는 넉넉한 한도와 안정적인 액세스를 제공하는 아래 모델이 포함됩니다.", "go.faq.q3": "Go는 Zen과 같은가요?", "go.faq.a3": - "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", + "아니요. Zen은 종량제인 반면, Go는 첫 달 $5, 이후 $10/월로 시작하며, Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3 오픈 소스 모델에 대한 넉넉한 한도와 안정적인 액세스를 제공합니다.", "go.faq.q4": "Go 비용은 얼마인가요?", "go.faq.a4.p1.beforePricing": "Go 비용은", "go.faq.a4.p1.pricingLink": "첫 달 $5", @@ -348,7 +348,7 @@ export const dict = { "go.faq.q9": "무료 모델과 Go의 차이점은 무엇인가요?", "go.faq.a9": - "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", + "무료 모델에는 Big Pickle과 당시 사용 가능한 프로모션 모델이 포함되며, 하루 200회 요청 할당량이 적용됩니다. Go는 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash, Hy3를 포함하며, 롤링 윈도우(5시간, 주간, 월간)에 걸쳐 더 높은 요청 할당량을 적용합니다. 이는 대략 5시간당 $12, 주당 $30, 월 $60에 해당합니다(실제 요청 수는 모델 및 사용량에 따라 다름).", "zen.api.error.rateLimitExceeded": "속도 제한을 초과했습니다. 나중에 다시 시도해 주세요.", "zen.api.error.modelNotSupported": "{{model}} 모델은 지원되지 않습니다", diff --git a/packages/console/app/src/i18n/no.ts b/packages/console/app/src/i18n/no.ts index bec0e0ce5e..b5ceff412c 100644 --- a/packages/console/app/src/i18n/no.ts +++ b/packages/console/app/src/i18n/no.ts @@ -256,7 +256,7 @@ export const dict = { "go.title": "OpenCode Go | Rimelige kodemodeller for alle", "go.banner.text": "Kimi K3 får 2x bruksgrense i en begrenset periode", "go.meta.description": - "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse 5-timers forespørselsgrenser for Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.hero.title": "Rimelige kodemodeller for alle", "go.hero.body": "Go bringer agent-koding til programmerere over hele verden. Med rause grenser og pålitelig tilgang til de mest kapable åpen kildekode-modellene, kan du bygge med kraftige agenter uten å bekymre deg for kostnader eller tilgjengelighet.", @@ -304,7 +304,7 @@ export const dict = { "go.problem.item2": "Rause grenser og pålitelig tilgang", "go.problem.item3": "Bygget for så mange programmerere som mulig", "go.problem.item4": - "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash", + "Inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3", "go.how.title": "Hvordan Go fungerer", "go.how.body": "Go starter på $5 for den første måneden, deretter $10/måned. Du kan bruke det med OpenCode eller hvilken som helst agent.", @@ -330,7 +330,7 @@ export const dict = { "go.faq.a2": "Go inkluderer modellene nedenfor, med høye grenser og pålitelig tilgang.", "go.faq.q3": "Er Go det samme som Zen?", "go.faq.a3": - "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash.", + "Nei. Zen er betaling etter bruk, mens Go starter på $5 for den første måneden, deretter $10/måned, med sjenerøse grenser og pålitelig tilgang til åpen kildekode-modellene Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3.", "go.faq.q4": "Hva koster Go?", "go.faq.a4.p1.beforePricing": "Go koster", "go.faq.a4.p1.pricingLink": "$5 første måned", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "Hva er forskjellen mellom gratis modeller og Go?", "go.faq.a9": - "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro og DeepSeek V4 Flash med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", + "Gratis modeller inkluderer Big Pickle pluss kampanjemodeller tilgjengelig på det tidspunktet, med en kvote på 200 forespørsler/dag. Go inkluderer Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash og Hy3 med høyere kvoter håndhevet over rullerende vinduer (5 timer, ukentlig og månedlig), omtrent tilsvarende $12 per 5 timer, $30 per uke og $60 per måned (faktiske forespørselsantall varierer etter modell og bruk).", "zen.api.error.rateLimitExceeded": "Rate limit overskredet. Vennligst prøv igjen senere.", "zen.api.error.modelNotSupported": "Modell {{model}} støttes ikke", diff --git a/packages/console/app/src/i18n/pl.ts b/packages/console/app/src/i18n/pl.ts index 8be53855f3..3199606a8b 100644 --- a/packages/console/app/src/i18n/pl.ts +++ b/packages/console/app/src/i18n/pl.ts @@ -257,7 +257,7 @@ export const dict = { "go.title": "OpenCode Go | Niskokosztowe modele do kodowania dla każdego", "go.banner.text": "Kimi K3 oferuje 2x wyższe limity użycia przez ograniczony czas", "go.meta.description": - "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi 5-godzinnymi limitami zapytań dla Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3.", "go.hero.title": "Niskokosztowe modele do kodowania dla każdego", "go.hero.body": "Go udostępnia programowanie z agentami programistom na całym świecie. Oferuje hojne limity i niezawodny dostęp do najzdolniejszych modeli open source, dzięki czemu możesz budować za pomocą potężnych agentów, nie martwiąc się o koszty czy dostępność.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Hojne limity i niezawodny dostęp", "go.problem.item3": "Stworzony dla jak największej liczby programistów", "go.problem.item4": - "Zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash", + "Zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3", "go.how.title": "Jak działa Go", "go.how.body": "Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc. Możesz go używać z OpenCode lub dowolnym agentem.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go obejmuje poniższe modele z wysokimi limitami i niezawodnym dostępem.", "go.faq.q3": "Czy Go to to samo co Zen?", "go.faq.a3": - "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash.", + "Nie. Zen to model płatności za użycie, podczas gdy Go zaczyna się od $5 za pierwszy miesiąc, potem $10/miesiąc, z hojnymi limitami i niezawodnym dostępem do modeli open source Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3.", "go.faq.q4": "Ile kosztuje Go?", "go.faq.a4.p1.beforePricing": "Go kosztuje", "go.faq.a4.p1.pricingLink": "$5 za pierwszy miesiąc", @@ -355,7 +355,7 @@ export const dict = { "go.faq.q9": "Jaka jest różnica między darmowymi modelami a Go?", "go.faq.a9": - "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro i DeepSeek V4 Flash z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", + "Darmowe modele obejmują Big Pickle oraz modele promocyjne dostępne w danym momencie, z limitem 200 zapytań/dzień. Go zawiera Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash i Hy3 z wyższymi limitami zapytań egzekwowanymi w oknach kroczących (5-godzinnych, tygodniowych i miesięcznych), w przybliżeniu równoważnymi $12 na 5 godzin, $30 tygodniowo i $60 miesięcznie (rzeczywista liczba zapytań zależy od modelu i użycia).", "zen.api.error.rateLimitExceeded": "Przekroczono limit zapytań. Spróbuj ponownie później.", "zen.api.error.modelNotSupported": "Model {{model}} nie jest obsługiwany", diff --git a/packages/console/app/src/i18n/ru.ts b/packages/console/app/src/i18n/ru.ts index abe56bbb03..821ed70e98 100644 --- a/packages/console/app/src/i18n/ru.ts +++ b/packages/console/app/src/i18n/ru.ts @@ -260,7 +260,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогие модели для кодинга для всех", "go.banner.text": "Kimi K3 получает 2x лимиты использования на ограниченное время", "go.meta.description": - "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами запросов за 5 часов для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3.", "go.hero.title": "Недорогие модели для кодинга для всех", "go.hero.body": "Go открывает доступ к агентам-программистам разработчикам по всему миру. Предлагая щедрые лимиты и надежный доступ к наиболее способным моделям с открытым исходным кодом, вы можете создавать проекты с мощными агентами, не беспокоясь о затратах или доступности.", @@ -309,7 +309,7 @@ export const dict = { "go.problem.item2": "Щедрые лимиты и надежный доступ", "go.problem.item3": "Создан для максимального числа программистов", "go.problem.item4": - "Включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash", + "Включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3", "go.how.title": "Как работает Go", "go.how.body": "Go начинается с $5 за первый месяц, затем $10/месяц. Вы можете использовать его с OpenCode или любым агентом.", @@ -335,7 +335,7 @@ export const dict = { "go.faq.a2": "Go включает перечисленные ниже модели с щедрыми лимитами и надежным доступом.", "go.faq.q3": "Go — это то же самое, что и Zen?", "go.faq.a3": - "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash.", + "Нет. Zen - это оплата по мере использования, в то время как Go начинается с $5 за первый месяц, затем $10/месяц, с щедрыми лимитами и надежным доступом к моделям с открытым исходным кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3.", "go.faq.q4": "Сколько стоит Go?", "go.faq.a4.p1.beforePricing": "Go стоит", "go.faq.a4.p1.pricingLink": "$5 за первый месяц", @@ -359,7 +359,7 @@ export const dict = { "go.faq.q9": "В чем разница между бесплатными моделями и Go?", "go.faq.a9": - "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro и DeepSeek V4 Flash с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", + "Бесплатные модели включают Big Pickle плюс промо-модели, доступные на данный момент, с квотой 200 запросов/день. Go включает Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash и Hy3 с более высокими квотами запросов, применяемыми в скользящих окнах (5 часов, неделя и месяц), что примерно эквивалентно $12 за 5 часов, $30 в неделю и $60 в месяц (фактическое количество запросов зависит от модели и использования).", "zen.api.error.rateLimitExceeded": "Превышен лимит запросов. Пожалуйста, попробуйте позже.", "zen.api.error.modelNotSupported": "Модель {{model}} не поддерживается", diff --git a/packages/console/app/src/i18n/th.ts b/packages/console/app/src/i18n/th.ts index a6069a1bed..2a68e94c0a 100644 --- a/packages/console/app/src/i18n/th.ts +++ b/packages/console/app/src/i18n/th.ts @@ -255,7 +255,7 @@ export const dict = { "go.title": "OpenCode Go | โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.banner.text": "Kimi K3 เพิ่มโควตาการใช้งานเป็น 2 เท่าในช่วงเวลาจำกัด", "go.meta.description": - "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดคำขอ 5 ชั่วโมงที่เอื้อเฟื้อสำหรับ Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3", "go.hero.title": "โมเดลเขียนโค้ดราคาประหยัดสำหรับทุกคน", "go.hero.body": "Go นำการเขียนโค้ดแบบเอเจนต์มาสู่นักเขียนโปรแกรมทั่วโลก เสนอขีดจำกัดที่กว้างขวางและการเข้าถึงโมเดลโอเพนซอร์สที่มีความสามารถสูงสุดได้อย่างน่าเชื่อถือ เพื่อให้คุณสามารถสร้างสรรค์ด้วยเอเจนต์ที่ทรงพลังโดยไม่ต้องกังวลเรื่องค่าใช้จ่ายหรือความพร้อมใช้งาน", @@ -302,7 +302,7 @@ export const dict = { "go.problem.item2": "ขีดจำกัดที่กว้างขวางและการเข้าถึงที่เชื่อถือได้", "go.problem.item3": "สร้างขึ้นเพื่อโปรแกรมเมอร์จำนวนมากที่สุดเท่าที่จะเป็นไปได้", "go.problem.item4": - "รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash", + "รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3", "go.how.title": "Go ทำงานอย่างไร", "go.how.body": "Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน คุณสามารถใช้กับ OpenCode หรือเอเจนต์ใดก็ได้", "go.how.step1.title": "สร้างบัญชี", @@ -327,7 +327,7 @@ export const dict = { "go.faq.a2": "Go รวมโมเดลด้านล่างนี้ พร้อมขีดจำกัดที่มากและการเข้าถึงที่เชื่อถือได้", "go.faq.q3": "Go เหมือนกับ Zen หรือไม่?", "go.faq.a3": - "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash อย่างเชื่อถือได้", + "ไม่ Zen เป็นแบบจ่ายตามการใช้งาน ในขณะที่ Go เริ่มต้นที่ $5 สำหรับเดือนแรก จากนั้น $10/เดือน พร้อมขีดจำกัดที่เอื้อเฟื้อและการเข้าถึงโมเดลโอเพนซอร์ส Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 อย่างเชื่อถือได้", "go.faq.q4": "Go ราคาเท่าไหร่?", "go.faq.a4.p1.beforePricing": "Go ราคา", "go.faq.a4.p1.pricingLink": "$5 เดือนแรก", @@ -350,7 +350,7 @@ export const dict = { "go.faq.q9": "ความแตกต่างระหว่างโมเดลฟรีและ Go คืออะไร?", "go.faq.a9": - "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro และ DeepSeek V4 Flash ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", + "โมเดลฟรีรวมถึง Big Pickle บวกกับโมเดลโปรโมชั่นที่มีให้ในขณะนั้น ด้วยโควต้า 200 คำขอ/วัน Go รวมถึง Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash และ Hy3 ที่มีโควต้าคำขอสูงกว่า ซึ่งบังคับใช้ผ่านช่วงเวลาหมุนเวียน (5 ชั่วโมง, รายสัปดาห์ และรายเดือน) เทียบเท่าประมาณ $12 ต่อ 5 ชั่วโมง, $30 ต่อสัปดาห์ และ $60 ต่อเดือน (จำนวนคำขอจริงจะแตกต่างกันไปตามโมเดลและการใช้งาน)", "zen.api.error.rateLimitExceeded": "เกินขีดจำกัดอัตราการใช้งาน กรุณาลองใหม่ในภายหลัง", "zen.api.error.modelNotSupported": "ไม่รองรับโมเดล {{model}}", diff --git a/packages/console/app/src/i18n/tr.ts b/packages/console/app/src/i18n/tr.ts index 7d8bc49f50..9bdcfeaeb4 100644 --- a/packages/console/app/src/i18n/tr.ts +++ b/packages/console/app/src/i18n/tr.ts @@ -258,7 +258,7 @@ export const dict = { "go.title": "OpenCode Go | Herkes için düşük maliyetli kodlama modelleri", "go.banner.text": "Kimi K3 sınırlı bir süre için 2x kullanım limiti sunuyor", "go.meta.description": - "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash için cömert 5 saatlik istek limitleri sunar.", + "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 için cömert 5 saatlik istek limitleri sunar.", "go.hero.title": "Herkes için düşük maliyetli kodlama modelleri", "go.hero.body": "Go, dünya çapındaki programcılara ajan tabanlı kodlama getiriyor. En yetenekli açık kaynaklı modellere cömert limitler ve güvenilir erişim sunarak, maliyet veya erişilebilirlik konusunda endişelenmeden güçlü ajanlarla geliştirme yapmanızı sağlar.", @@ -307,7 +307,7 @@ export const dict = { "go.problem.item2": "Cömert limitler ve güvenilir erişim", "go.problem.item3": "Mümkün olduğunca çok programcı için geliştirildi", "go.problem.item4": - "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash içerir", + "Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 içerir", "go.how.title": "Go nasıl çalışır?", "go.how.body": "Go ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar. OpenCode veya herhangi bir ajanla kullanabilirsiniz.", @@ -333,7 +333,7 @@ export const dict = { "go.faq.a2": "Go, aşağıda listelenen modelleri cömert limitler ve güvenilir erişimle sunar.", "go.faq.q3": "Go, Zen ile aynı mı?", "go.faq.a3": - "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", + "Hayır. Zen kullandıkça öde modelidir, Go ise ilk ay $5, sonrasında ayda 10$ fiyatıyla başlar; Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 açık kaynak modellerine cömert limitler ve güvenilir erişim sunar.", "go.faq.q4": "Go ne kadar?", "go.faq.a4.p1.beforePricing": "Go'nun maliyeti", "go.faq.a4.p1.pricingLink": "İlk ay $5", @@ -357,7 +357,7 @@ export const dict = { "go.faq.q9": "Ücretsiz modeller ve Go arasındaki fark nedir?", "go.faq.a9": - "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro ve DeepSeek V4 Flash modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", + "Ücretsiz modeller, günlük 200 istek kotası ile Big Pickle ve o sırada mevcut olan promosyonel modelleri içerir. Go ise Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash ve Hy3 modellerini; yuvarlanan pencereler (5 saatlik, haftalık ve aylık) üzerinden uygulanan daha yüksek istek kotalarıyla içerir. Bu kotalar kabaca her 5 saatte 12$, haftada 30$ ve ayda 60$ değerine eşdeğerdir (gerçek istek sayıları modele ve kullanıma göre değişir).", "zen.api.error.rateLimitExceeded": "İstek limiti aşıldı. Lütfen daha sonra tekrar deneyin.", "zen.api.error.modelNotSupported": "{{model}} modeli desteklenmiyor", diff --git a/packages/console/app/src/i18n/uk.ts b/packages/console/app/src/i18n/uk.ts index ee2405b65f..1dbb0be8af 100644 --- a/packages/console/app/src/i18n/uk.ts +++ b/packages/console/app/src/i18n/uk.ts @@ -257,7 +257,7 @@ export const dict = { "go.title": "OpenCode Go | Недорогі моделі кодування для всіх", "go.banner.text": "Kimi K3 отримує 2x ліміти використання протягом обмеженого часу", "go.meta.description": - "Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими 5-годинними лімітами запитів для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Go починається від $5 за перший місяць, потім $10/місяць, зі щедрими 5-годинними лімітами запитів для Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3.", "go.hero.title": "Недорогі моделі кодування для всіх", "go.hero.body": "Go надає агентне програмування програмістам у всьому світі, пропонуючи щедрі ліміти та надійний доступ до найкращих моделей з відкритим кодом.", @@ -305,7 +305,7 @@ export const dict = { "go.problem.item2": "Щедрі ліміти та надійний доступ", "go.problem.item3": "Створено для якомога більшої кількості програмістів", "go.problem.item4": - "Включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash", + "Включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3", "go.how.title": "Як працює Go", "go.how.body": "Go починається від $5 за перший місяць, потім $10/місяць. Використовуйте з OpenCode або будь-яким агентом.", @@ -331,7 +331,7 @@ export const dict = { "go.faq.a2": "Go включає моделі, перелічені нижче, із щедрими лімітами та надійним доступом.", "go.faq.q3": "Чи Go те саме, що Zen?", "go.faq.a3": - "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash.", + "Ні. Zen — це плата за використання, тоді як Go починається від $5 за перший місяць, потім $10/місяць, із щедрими лімітами та надійним доступом до моделей з відкритим кодом Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3.", "go.faq.q4": "Скільки коштує Go?", "go.faq.a4.p1.beforePricing": "Go коштує", "go.faq.a4.p1.pricingLink": "$5 за перший місяць", @@ -354,7 +354,7 @@ export const dict = { "go.faq.q9": "Яка різниця між безкоштовними моделями та Go?", "go.faq.a9": - "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro та DeepSeek V4 Flash із вищими лімітами.", + "Безкоштовні моделі включають Big Pickle та акційні моделі з лімітом 200 запитів/день. Go включає Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code, Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7, MiniMax M3, DeepSeek V4 Pro, DeepSeek V4 Flash та Hy3 із вищими лімітами.", "zen.api.error.rateLimitExceeded": "Перевищено ліміт запитів. Спробуйте пізніше.", "zen.api.error.modelNotSupported": "Модель {{model}} не підтримується", diff --git a/packages/console/app/src/i18n/zh.ts b/packages/console/app/src/i18n/zh.ts index cc8b6326f7..47e5ee8361 100644 --- a/packages/console/app/src/i18n/zh.ts +++ b/packages/console/app/src/i18n/zh.ts @@ -246,7 +246,7 @@ export const dict = { "go.title": "OpenCode Go | 人人可用的低成本编程模型", "go.banner.text": "Kimi K3 限时享受 2 倍使用额度", "go.meta.description": - "Go 首月 $5,之后 $10/月,提供对 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小时充裕请求额度。", + "Go 首月 $5,之后 $10/月,提供对 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 的 5 小时充裕请求额度。", "go.hero.title": "人人可用的低成本编程模型", "go.hero.body": "Go 将代理编程带给全世界的程序员。提供充裕的限额和对最强大的开源模型的可靠访问,让您可以利用强大的代理进行构建,而无需担心成本或可用性。", @@ -293,7 +293,7 @@ export const dict = { "go.problem.item2": "充裕的限额和可靠的访问", "go.problem.item3": "为尽可能多的程序员打造", "go.problem.item4": - "包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash", + "包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3", "go.how.title": "Go 如何工作", "go.how.body": "Go 起价为首月 $5,之后 $10/月。您可以将其与 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "创建账户", @@ -315,7 +315,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的限额和可靠的访问。", "go.faq.q3": "Go 和 Zen 一样吗?", "go.faq.a3": - "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等开源模型。", + "不。Zen 是按量付费,而 Go 首月 $5,之后 $10/月,提供充裕的额度,并可可靠地访问 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 等开源模型。", "go.faq.q4": "Go 多少钱?", "go.faq.a4.p1.beforePricing": "Go 费用为", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -337,7 +337,7 @@ export const dict = { "go.faq.q9": "免费模型和 Go 之间的区别是什么?", "go.faq.a9": - "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", + "免费模型包含 Big Pickle 加上当时可用的促销模型,每天有 200 次请求的配额。Go 包含 Grok 4.5, GLM-5.2, GLM-5.1, Kimi K3, Kimi K2.7 Code、Kimi K2.6, MiMo-V2.5-Pro, MiMo-V2.5, Qwen3.7 Max, Qwen3.7 Plus, Qwen3.6 Plus, MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3,并在滚动窗口(5 小时、每周和每月)内执行更高的请求配额,大致相当于每 5 小时 $12、每周 $30 和每月 $60(实际请求计数因模型和使用情况而异)。", "zen.api.error.rateLimitExceeded": "超出速率限制。请稍后重试。", "zen.api.error.modelNotSupported": "不支持模型 {{model}}", diff --git a/packages/console/app/src/i18n/zht.ts b/packages/console/app/src/i18n/zht.ts index 8612bb8dac..77c0e8e918 100644 --- a/packages/console/app/src/i18n/zht.ts +++ b/packages/console/app/src/i18n/zht.ts @@ -246,7 +246,7 @@ export const dict = { "go.title": "OpenCode Go | 低成本全民編碼模型", "go.banner.text": "Kimi K3 限時享有 2 倍使用額度", "go.meta.description": - "Go 首月 $5,之後 $10/月,提供對 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 的 5 小時充裕請求額度。", + "Go 首月 $5,之後 $10/月,提供對 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 的 5 小時充裕請求額度。", "go.hero.title": "低成本全民編碼模型", "go.hero.body": "Go 將代理編碼帶給全世界的程式設計師。提供寬裕的限額以及對最強大開源模型的穩定存取,讓你可以使用強大的代理進行構建,而無需擔心成本或可用性。", @@ -293,7 +293,7 @@ export const dict = { "go.problem.item2": "寬裕的限額與穩定存取", "go.problem.item3": "專為盡可能多的程式設計師打造", "go.problem.item4": - "包含 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash", + "包含 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 與 Hy3", "go.how.title": "Go 如何運作", "go.how.body": "Go 起價為首月 $5,之後 $10/月。您可以將其與 OpenCode 或任何代理搭配使用。", "go.how.step1.title": "建立帳號", @@ -315,7 +315,7 @@ export const dict = { "go.faq.a2": "Go 包含下方列出的模型,提供充足的額度與穩定的存取。", "go.faq.q3": "Go 與 Zen 一樣嗎?", "go.faq.a3": - "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 和 DeepSeek V4 Flash 等開源模型。", + "不。Zen 是按量付費,而 Go 首月 $5,之後 $10/月,提供充裕的額度,並可可靠地存取 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 和 Hy3 等開源模型。", "go.faq.q4": "Go 費用是多少?", "go.faq.a4.p1.beforePricing": "Go 費用為", "go.faq.a4.p1.pricingLink": "首月 $5", @@ -337,7 +337,7 @@ export const dict = { "go.faq.q9": "免費模型與 Go 有什麼區別?", "go.faq.a9": - "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro 與 DeepSeek V4 Flash,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", + "免費模型包括 Big Pickle 以及當時可用的促銷模型,配額為 200 次請求/天。Go 包括 Grok 4.5、GLM-5.2、GLM-5.1、Kimi K3、Kimi K2.7 Code、Kimi K2.6、MiMo-V2.5-Pro、MiMo-V2.5、Qwen3.7 Max、Qwen3.7 Plus、Qwen3.6 Plus、MiniMax M2.7、MiniMax M3、DeepSeek V4 Pro、DeepSeek V4 Flash 與 Hy3,並在滾動視窗(5 小時、每週和每月)內執行更高的請求配額,大約相當於每 5 小時 $12、每週 $30 和每月 $60(實際請求數因模型和使用情況而異)。", "zen.api.error.rateLimitExceeded": "超出頻率限制。請稍後再試。", "zen.api.error.modelNotSupported": "不支援模型 {{model}}", diff --git a/packages/console/app/src/routes/go/index.tsx b/packages/console/app/src/routes/go/index.tsx index 9dedcdcbb4..2742f49ef4 100644 --- a/packages/console/app/src/routes/go/index.tsx +++ b/packages/console/app/src/routes/go/index.tsx @@ -38,6 +38,7 @@ const models = [ "MiniMax M2.7", "DeepSeek V4 Pro", "DeepSeek V4 Flash", + "Hy3", ] function LimitsGraph(props: { href: string }) { @@ -72,6 +73,7 @@ function LimitsGraph(props: { href: string }) { { id: "mimo-v2.5-pro", name: "MiMo-V2.5-Pro", req: 3250, d: "240ms" }, { id: "deepseek-v4-pro", name: "DeepSeek V4 Pro", req: 3450, d: "270ms" }, { id: "qwen3.7-plus", name: "Qwen3.7 Plus", req: 4300, d: "300ms" }, + { id: "hy3", name: "Hy3", req: 4300, d: "320ms" }, { id: "mimo-v2.5", name: "MiMo-V2.5", req: 30100, d: "340ms" }, { id: "deepseek-v4-flash", name: "DeepSeek V4 Flash", req: 31650, d: "340ms" }, ] diff --git a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx index 6704f92608..88141656f3 100644 --- a/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx +++ b/packages/console/app/src/routes/workspace/[id]/go/lite-section.tsx @@ -321,6 +321,7 @@ export function LiteSection(props: { lite: LiteSubscription | undefined }) {
  • DeepSeek V4 Flash
  • MiMo-V2.5
  • MiMo-V2.5-Pro
  • +
  • Hy3
  • {i18n.t("workspace.lite.promo.footer")}

    diff --git a/packages/web/src/content/docs/ar/go.mdx b/packages/web/src/content/docs/ar/go.mdx index dee282b3d5..5698d42724 100644 --- a/packages/web/src/content/docs/ar/go.mdx +++ b/packages/web/src/content/docs/ar/go.mdx @@ -64,6 +64,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** قد تتغير قائمة النماذج مع استمرارنا في اختبار نماذج جديدة وإضافتها. @@ -98,6 +99,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | تستند التقديرات إلى أنماط الطلبات المرصودة: @@ -112,6 +114,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال - Qwen3.7 Max — ‏420 input، و66,000 cached، و200 output tokens لكل طلب - Qwen3.7 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب - Qwen3.6 Plus — ‏500 input، و57,000 cached، و190 output tokens لكل طلب +- Hy3 — ‏830 input، و71,500 cached، و295 output tokens لكل طلب - MiMo-V2.5 — ‏830 input، و71,500 cached، و295 output tokens لكل طلب - MiMo-V2.5-Pro — ‏790 input، و86,000 cached، و305 output tokens لكل طلب @@ -137,6 +140,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | يمكنك تتبّع استخدامك الحالي في **console**. @@ -188,6 +192,7 @@ OpenCode Go هو اشتراك منخفض التكلفة — **$5 للشهر ال | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | يستخدم [model id](/docs/config/#models) في إعدادات OpenCode لديك التنسيق `opencode-go/`. على سبيل المثال، بالنسبة إلى Kimi K3، ستستخدم `opencode-go/kimi-k3` في إعداداتك. diff --git a/packages/web/src/content/docs/bs/go.mdx b/packages/web/src/content/docs/bs/go.mdx index b60f94fa1c..c9ea860d35 100644 --- a/packages/web/src/content/docs/bs/go.mdx +++ b/packages/web/src/content/docs/bs/go.mdx @@ -74,6 +74,7 @@ Trenutna lista modela uključuje: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Lista modela se može mijenjati dok testiramo i dodajemo nove. @@ -108,6 +109,7 @@ Tabela ispod pruža procijenjeni broj zahtjeva na osnovu tipičnih obrazaca kori | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Procjene se zasnivaju na zapaženim obrascima zahtjeva: @@ -122,6 +124,7 @@ Procjene se zasnivaju na zapaženim obrascima zahtjeva: - Qwen3.7 Max — 420 ulaznih, 66,000 keširanih, 200 izlaznih tokena po zahtjevu - Qwen3.7 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu - Qwen3.6 Plus — 500 ulaznih, 57,000 keširanih, 190 izlaznih tokena po zahtjevu +- Hy3 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu - MiMo-V2.5 — 830 ulaznih, 71,500 keširanih, 295 izlaznih tokena po zahtjevu - MiMo-V2.5-Pro — 790 ulaznih, 86,000 keširanih, 305 izlaznih tokena po zahtjevu @@ -147,6 +150,7 @@ Procjene se također zasnivaju na sljedećim cijenama po 1M tokena i mjesečnoj | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Svoju trenutnu potrošnju možete pratiti u **konzoli**. @@ -200,6 +204,7 @@ Također možete pristupiti Go modelima putem sljedećih API endpointa. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Model id](/docs/config/#models) u vašoj OpenCode konfiguraciji koristi format `opencode-go/`. Na primjer, za Kimi K3, koristili biste diff --git a/packages/web/src/content/docs/da/go.mdx b/packages/web/src/content/docs/da/go.mdx index 7c7eba628b..4256f4afad 100644 --- a/packages/web/src/content/docs/da/go.mdx +++ b/packages/web/src/content/docs/da/go.mdx @@ -74,6 +74,7 @@ Den nuværende liste over modeller inkluderer: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Listen over modeller kan ændre sig, efterhånden som vi tester og tilføjer nye. @@ -108,6 +109,7 @@ Tabellen nedenfor giver et estimeret antal anmodninger baseret på typiske Go-fo | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Estimaterne er baseret på observerede anmodningsmønstre: @@ -122,6 +124,7 @@ Estimaterne er baseret på observerede anmodningsmønstre: - Qwen3.7 Max — 420 input, 66.000 cachelagrede, 200 output-tokens pr. anmodning - Qwen3.7 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning - Qwen3.6 Plus — 500 input, 57.000 cachelagrede, 190 output-tokens pr. anmodning +- Hy3 — 830 input, 71.500 cachelagrede, 295 output-tokens pr. anmodning - MiMo-V2.5 — 830 input, 71.500 cachelagrede, 295 output-tokens pr. anmodning - MiMo-V2.5-Pro — 790 input, 86.000 cachelagrede, 305 output-tokens pr. anmodning @@ -147,6 +150,7 @@ Estimaterne er også baseret på følgende priser pr. 1M tokens og det månedlig | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Du kan spore dit nuværende forbrug i **konsollen**. @@ -200,6 +204,7 @@ Du kan også få adgang til Go-modeller gennem følgende API-endpoints. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Dit [model id](/docs/config/#models) i din OpenCode config bruger formatet `opencode-go/`. For eksempel for Kimi K3, vil du diff --git a/packages/web/src/content/docs/de/go.mdx b/packages/web/src/content/docs/de/go.mdx index 553781ea70..3de5f5f786 100644 --- a/packages/web/src/content/docs/de/go.mdx +++ b/packages/web/src/content/docs/de/go.mdx @@ -66,6 +66,7 @@ Die aktuelle Liste der Modelle umfasst: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Die Liste der Modelle kann sich ändern, während wir neue testen und hinzufügen. @@ -100,6 +101,7 @@ Die folgende Tabelle zeigt eine geschätzte Anzahl von Anfragen basierend auf ty | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Die Schätzungen basieren auf beobachteten Anfragemustern: @@ -114,6 +116,7 @@ Die Schätzungen basieren auf beobachteten Anfragemustern: - Qwen3.7 Max — 420 Input-, 66.000 Cached-, 200 Output-Tokens pro Anfrage - Qwen3.7 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage - Qwen3.6 Plus — 500 Input-, 57.000 Cached-, 190 Output-Tokens pro Anfrage +- Hy3 — 830 Input-, 71.500 Cached-, 295 Output-Tokens pro Anfrage - MiMo-V2.5 — 830 Input-, 71.500 Cached-, 295 Output-Tokens pro Anfrage - MiMo-V2.5-Pro — 790 Input-, 86.000 Cached-, 305 Output-Tokens pro Anfrage @@ -139,6 +142,7 @@ Die Schätzungen basieren außerdem auf den folgenden Preisen pro 1M Tokens und | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Du kannst deine aktuelle Nutzung in der **Console** verfolgen. @@ -190,6 +194,7 @@ Du kannst auf die Go-Modelle auch über die folgenden API-Endpunkte zugreifen. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Die [Modell-ID](/docs/config/#models) in deiner OpenCode Config verwendet das Format `opencode-go/`. Für Kimi K3 würdest du beispielsweise `opencode-go/kimi-k3` in deiner Config verwenden. diff --git a/packages/web/src/content/docs/es/go.mdx b/packages/web/src/content/docs/es/go.mdx index 3ae730f9ec..de02803362 100644 --- a/packages/web/src/content/docs/es/go.mdx +++ b/packages/web/src/content/docs/es/go.mdx @@ -74,6 +74,7 @@ La lista actual de modelos incluye: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** La lista de modelos puede cambiar a medida que probamos y agregamos otros nuevos. @@ -108,6 +109,7 @@ La siguiente tabla proporciona una cantidad estimada de peticiones basada en los | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Las estimaciones se basan en los patrones de peticiones observados: @@ -122,6 +124,7 @@ Las estimaciones se basan en los patrones de peticiones observados: - Qwen3.7 Max — 420 tokens de entrada, 66,000 en caché, 200 tokens de salida por petición - Qwen3.7 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición - Qwen3.6 Plus — 500 tokens de entrada, 57,000 en caché, 190 tokens de salida por petición +- Hy3 — 830 tokens de entrada, 71,500 en caché, 295 tokens de salida por petición - MiMo-V2.5 — 830 tokens de entrada, 71,500 en caché, 295 tokens de salida por petición - MiMo-V2.5-Pro — 790 tokens de entrada, 86,000 en caché, 305 tokens de salida por petición @@ -147,6 +150,7 @@ Las estimaciones también se basan en los siguientes precios por 1M tokens y en | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Puedes realizar un seguimiento de tu uso actual en la **consola**. @@ -200,6 +204,7 @@ También puedes acceder a los modelos de Go a través de los siguientes endpoint | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | El [ID del modelo](/docs/config/#models) en tu configuración de OpenCode usa el formato `opencode-go/`. Por ejemplo, para Kimi K3, usarías diff --git a/packages/web/src/content/docs/fr/go.mdx b/packages/web/src/content/docs/fr/go.mdx index 186ed33012..fe1139d389 100644 --- a/packages/web/src/content/docs/fr/go.mdx +++ b/packages/web/src/content/docs/fr/go.mdx @@ -64,6 +64,7 @@ La liste actuelle des modèles comprend : - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** La liste des modèles peut changer au fur et à mesure que nous en testons et en ajoutons de nouveaux. @@ -98,6 +99,7 @@ Le tableau ci-dessous fournit une estimation du nombre de requêtes basée sur d | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Les estimations sont basées sur les schémas de requêtes observés : @@ -112,6 +114,7 @@ Les estimations sont basées sur les schémas de requêtes observés : - Qwen3.7 Max — 420 tokens en entrée, 66,000 en cache, 200 tokens en sortie par requête - Qwen3.7 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête - Qwen3.6 Plus — 500 tokens en entrée, 57,000 en cache, 190 tokens en sortie par requête +- Hy3 — 830 tokens en entrée, 71,500 en cache, 295 tokens en sortie par requête - MiMo-V2.5 — 830 tokens en entrée, 71,500 en cache, 295 tokens en sortie par requête - MiMo-V2.5-Pro — 790 tokens en entrée, 86,000 en cache, 305 tokens en sortie par requête @@ -137,6 +140,7 @@ Les estimations sont également basées sur les prix suivants par 1M tokens et s | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Vous pouvez suivre votre utilisation actuelle dans la **console**. @@ -188,6 +192,7 @@ Vous pouvez également accéder aux modèles Go via les points de terminaison d' | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | L'[ID de modèle](/docs/config/#models) dans votre configuration OpenCode utilise le format `opencode-go/`. Par exemple, pour Kimi K3, vous utiliseriez `opencode-go/kimi-k3` dans votre configuration. diff --git a/packages/web/src/content/docs/go.mdx b/packages/web/src/content/docs/go.mdx index 1e38745b62..bbd4225b9f 100644 --- a/packages/web/src/content/docs/go.mdx +++ b/packages/web/src/content/docs/go.mdx @@ -74,6 +74,7 @@ The current list of models includes: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** The list of models may change as we test and add new ones. @@ -108,6 +109,7 @@ The table below provides an estimated request count based on typical Go usage pa | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | The estimates are based on observed request patterns: @@ -124,6 +126,7 @@ The estimates are based on observed request patterns: - Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens per request - Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens per request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens per request +- Hy3 — 830 input, 71,500 cached, 295 output tokens per request The estimates are also based on the following prices per 1M tokens and the monthly usage included with each model: @@ -147,6 +150,7 @@ The estimates are also based on the following prices per 1M tokens and the month | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | You can track your current usage in the **console**. @@ -200,6 +204,7 @@ You can also access Go models through the following API endpoints. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | The [model id](/docs/config/#models) in your OpenCode config uses the format `opencode-go/`. For example, for Kimi K3, you would diff --git a/packages/web/src/content/docs/it/go.mdx b/packages/web/src/content/docs/it/go.mdx index 7edec6662f..26c459f456 100644 --- a/packages/web/src/content/docs/it/go.mdx +++ b/packages/web/src/content/docs/it/go.mdx @@ -72,6 +72,7 @@ L'elenco attuale dei modelli include: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** L'elenco dei modelli potrebbe cambiare man mano che ne testiamo e aggiungiamo di nuovi. @@ -106,6 +107,7 @@ La tabella seguente fornisce una stima del conteggio delle richieste in base a p | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Le stime si basano sui pattern di richieste osservati: @@ -120,6 +122,7 @@ Le stime si basano sui pattern di richieste osservati: - Qwen3.7 Max — 420 di input, 66.000 in cache, 200 token di output per richiesta - Qwen3.7 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta - Qwen3.6 Plus — 500 di input, 57.000 in cache, 190 token di output per richiesta +- Hy3 — 830 di input, 71.500 in cache, 295 token di output per richiesta - MiMo-V2.5 — 830 di input, 71.500 in cache, 295 token di output per richiesta - MiMo-V2.5-Pro — 790 di input, 86.000 in cache, 305 token di output per richiesta @@ -145,6 +148,7 @@ Le stime si basano anche sui seguenti prezzi per 1M token e sull'utilizzo mensil | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Puoi monitorare il tuo utilizzo attuale nella **console**. @@ -198,6 +202,7 @@ Puoi anche accedere ai modelli Go tramite i seguenti endpoint API. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | Il [model id](/docs/config/#models) nella tua OpenCode config utilizza il formato `opencode-go/`. Ad esempio, per Kimi K3, useresti diff --git a/packages/web/src/content/docs/ja/go.mdx b/packages/web/src/content/docs/ja/go.mdx index ca9bb3fe12..f2e95659a6 100644 --- a/packages/web/src/content/docs/ja/go.mdx +++ b/packages/web/src/content/docs/ja/go.mdx @@ -64,6 +64,7 @@ OpenCode Goをサブスクライブできるのは、1つのワークスペー - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 新しいモデルをテストして追加するにつれて、モデルのリストは変更される場合があります。 @@ -98,6 +99,7 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 推定値は、観測されたリクエストパターンに基づいています: @@ -112,6 +114,7 @@ OpenCode Goには以下の制限が含まれています: - Qwen3.7 Max — リクエストあたり 入力 420トークン、キャッシュ 66,000トークン、出力 200トークン - Qwen3.7 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン - Qwen3.6 Plus — リクエストあたり 入力 500トークン、キャッシュ 57,000トークン、出力 190トークン +- Hy3 — リクエストあたり 入力 830トークン、キャッシュ 71,500トークン、出力 295トークン - MiMo-V2.5 — リクエストあたり 入力 830トークン、キャッシュ 71,500トークン、出力 295トークン - MiMo-V2.5-Pro — リクエストあたり 入力 790トークン、キャッシュ 86,000トークン、出力 305トークン @@ -137,6 +140,7 @@ OpenCode Goには以下の制限が含まれています: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 現在の利用状況は**コンソール**で追跡できます。 @@ -188,6 +192,7 @@ Goでは月額$10を支払い、その6倍の利用枠を提供することを | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode設定の[model id](/docs/config/#models)は、`opencode-go/`という形式を使用します。たとえば、Kimi K3の場合は、設定で`opencode-go/kimi-k3`を使用します。 diff --git a/packages/web/src/content/docs/ko/go.mdx b/packages/web/src/content/docs/ko/go.mdx index eafd6ae31d..d03198ce4f 100644 --- a/packages/web/src/content/docs/ko/go.mdx +++ b/packages/web/src/content/docs/ko/go.mdx @@ -64,6 +64,7 @@ workspace당 한 명의 멤버만 OpenCode Go를 구독할 수 있습니다. - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 새로운 모델을 테스트하고 추가함에 따라 이 목록은 변경될 수 있습니다. @@ -98,6 +99,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 이 예상치는 관찰된 요청 패턴을 기준으로 합니다. @@ -112,6 +114,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. - Qwen3.7 Max — 요청당 입력 420, 캐시 66,000, 출력 토큰 200 - Qwen3.7 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 - Qwen3.6 Plus — 요청당 입력 500, 캐시 57,000, 출력 토큰 190 +- Hy3 — 요청당 입력 830, 캐시 71,500, 출력 토큰 295 - MiMo-V2.5 — 요청당 입력 830, 캐시 71,500, 출력 토큰 295 - MiMo-V2.5-Pro — 요청당 입력 790, 캐시 86,000, 출력 토큰 305 @@ -137,6 +140,7 @@ OpenCode Go에는 다음과 같은 한도가 포함됩니다. | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 현재 사용량은 **console**에서 확인할 수 있습니다. @@ -188,6 +192,7 @@ Go에서는 월 $10를 지불하며, 저희는 그 6배의 사용량을 제공 | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode config의 [model id](/docs/config/#models)는 `opencode-go/` 형식을 사용합니다. 예를 들어 Kimi K3의 경우 config에서 `opencode-go/kimi-k3`를 사용하면 됩니다. diff --git a/packages/web/src/content/docs/nb/go.mdx b/packages/web/src/content/docs/nb/go.mdx index 499fbde3a8..c63d007a80 100644 --- a/packages/web/src/content/docs/nb/go.mdx +++ b/packages/web/src/content/docs/nb/go.mdx @@ -74,6 +74,7 @@ Den nåværende listen over modeller inkluderer: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Listen over modeller kan endres etter hvert som vi tester og legger til nye. @@ -108,6 +109,7 @@ Tabellen nedenfor gir et estimert antall forespørsler basert på typiske bruksm | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Estimatene er basert på observerte forespørselsmønstre: @@ -122,6 +124,7 @@ Estimatene er basert på observerte forespørselsmønstre: - Qwen3.7 Max — 420 input, 66 000 bufret, 200 output-tokens per forespørsel - Qwen3.7 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel - Qwen3.6 Plus — 500 input, 57 000 bufret, 190 output-tokens per forespørsel +- Hy3 — 830 input, 71 500 bufret, 295 output-tokens per forespørsel - MiMo-V2.5 — 830 input, 71 500 bufret, 295 output-tokens per forespørsel - MiMo-V2.5-Pro — 790 input, 86 000 bufret, 305 output-tokens per forespørsel @@ -147,6 +150,7 @@ Estimatene er også basert på følgende priser per 1M tokens og den månedlige | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Du kan spore din nåværende bruk i **konsollen**. @@ -200,6 +204,7 @@ Du kan også få tilgang til Go-modeller gjennom følgende API-endepunkter. | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [Modell-ID-en](/docs/config/#models) i din OpenCode-konfigurasjon bruker formatet `opencode-go/`. For eksempel, for Kimi K3, vil du diff --git a/packages/web/src/content/docs/pl/go.mdx b/packages/web/src/content/docs/pl/go.mdx index a1de423ff3..3f542a924e 100644 --- a/packages/web/src/content/docs/pl/go.mdx +++ b/packages/web/src/content/docs/pl/go.mdx @@ -68,6 +68,7 @@ Obecna lista modeli obejmuje: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Lista modeli może ulec zmianie w miarę testowania i dodawania nowych. @@ -102,6 +103,7 @@ Poniższa tabela przedstawia szacunkową liczbę żądań na podstawie typowych | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Szacunki te opierają się na zaobserwowanych wzorcach żądań: @@ -116,6 +118,7 @@ Szacunki te opierają się na zaobserwowanych wzorcach żądań: - Qwen3.7 Max — 420 tokenów wejściowych, 66 000 w pamięci podręcznej, 200 tokenów wyjściowych na żądanie - Qwen3.7 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie - Qwen3.6 Plus — 500 tokenów wejściowych, 57 000 w pamięci podręcznej, 190 tokenów wyjściowych na żądanie +- Hy3 — 830 tokenów wejściowych, 71 500 w pamięci podręcznej, 295 tokenów wyjściowych na żądanie - MiMo-V2.5 — 830 tokenów wejściowych, 71 500 w pamięci podręcznej, 295 tokenów wyjściowych na żądanie - MiMo-V2.5-Pro — 790 tokenów wejściowych, 86 000 w pamięci podręcznej, 305 tokenów wyjściowych na żądanie @@ -141,6 +144,7 @@ Szacunki opierają się również na następujących cenach za 1M tokenów oraz | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Możesz śledzić swoje bieżące zużycie w **konsoli**. @@ -192,6 +196,7 @@ Możesz również uzyskać dostęp do modeli Go za pośrednictwem następującyc | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID modelu](/docs/config/#models) w Twojej konfiguracji OpenCode używa formatu `opencode-go/`. Na przykład dla Kimi K3 należy użyć diff --git a/packages/web/src/content/docs/pt-br/go.mdx b/packages/web/src/content/docs/pt-br/go.mdx index 892055dd57..def6efd471 100644 --- a/packages/web/src/content/docs/pt-br/go.mdx +++ b/packages/web/src/content/docs/pt-br/go.mdx @@ -74,6 +74,7 @@ A lista atual de modelos inclui: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** A lista de modelos pode mudar conforme testamos e adicionamos novos. @@ -108,6 +109,7 @@ A tabela abaixo fornece uma contagem estimada de requisições com base nos padr | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | As estimativas se baseiam nos padrões de requisições observados: @@ -122,6 +124,7 @@ As estimativas se baseiam nos padrões de requisições observados: - Qwen3.7 Max — 420 tokens de entrada, 66.000 em cache, 200 tokens de saída por requisição - Qwen3.7 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição - Qwen3.6 Plus — 500 tokens de entrada, 57.000 em cache, 190 tokens de saída por requisição +- Hy3 — 830 tokens de entrada, 71.500 em cache, 295 tokens de saída por requisição - MiMo-V2.5 — 830 tokens de entrada, 71.500 em cache, 295 tokens de saída por requisição - MiMo-V2.5-Pro — 790 tokens de entrada, 86.000 em cache, 305 tokens de saída por requisição @@ -147,6 +150,7 @@ As estimativas também se baseiam nos seguintes preços por 1M tokens e no uso m | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Você pode acompanhar o seu uso atual no **console**. @@ -200,6 +204,7 @@ Você também pode acessar os modelos do Go através dos seguintes endpoints de | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | O [ID do modelo](/docs/config/#models) na sua configuração do OpenCode usa o formato `opencode-go/`. Por exemplo, para o Kimi K3, você usaria diff --git a/packages/web/src/content/docs/ru/go.mdx b/packages/web/src/content/docs/ru/go.mdx index 14882e8db3..0bbd43369b 100644 --- a/packages/web/src/content/docs/ru/go.mdx +++ b/packages/web/src/content/docs/ru/go.mdx @@ -74,6 +74,7 @@ OpenCode Go работает так же, как и любой другой пр - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Список моделей может меняться по мере того, как мы тестируем и добавляем новые. @@ -108,6 +109,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Эти оценки основаны на наблюдаемых показателях запросов: @@ -122,6 +124,7 @@ OpenCode Go включает следующие лимиты: - Qwen3.7 Max — 420 входных, 66,000 кешированных, 200 выходных токенов на запрос - Qwen3.7 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос - Qwen3.6 Plus — 500 входных, 57,000 кешированных, 190 выходных токенов на запрос +- Hy3 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос - MiMo-V2.5 — 830 входных, 71,500 кешированных, 295 выходных токенов на запрос - MiMo-V2.5-Pro — 790 входных, 86,000 кешированных, 305 выходных токенов на запрос @@ -147,6 +150,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Вы можете отслеживать текущее использование в **консоли**. @@ -200,6 +204,7 @@ OpenCode Go включает следующие лимиты: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [ID модели](/docs/config/#models) в вашем конфиге OpenCode использует формат `opencode-go/`. Например, для Kimi K3 вам нужно diff --git a/packages/web/src/content/docs/th/go.mdx b/packages/web/src/content/docs/th/go.mdx index 4cfbce73cd..48b0c05bf1 100644 --- a/packages/web/src/content/docs/th/go.mdx +++ b/packages/web/src/content/docs/th/go.mdx @@ -64,6 +64,7 @@ OpenCode Go ทำงานเหมือนกับผู้ให้บร - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** รายชื่อโมเดลอาจมีการเปลี่ยนแปลงเมื่อเราทำการทดสอบและเพิ่มโมเดลใหม่ๆ @@ -98,6 +99,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | การประมาณการนี้อ้างอิงจากรูปแบบการใช้งาน request ที่สังเกตพบ: @@ -112,6 +114,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: - Qwen3.7 Max — 420 input, 66,000 cached, 200 output tokens ต่อ request - Qwen3.7 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request - Qwen3.6 Plus — 500 input, 57,000 cached, 190 output tokens ต่อ request +- Hy3 — 830 input, 71,500 cached, 295 output tokens ต่อ request - MiMo-V2.5 — 830 input, 71,500 cached, 295 output tokens ต่อ request - MiMo-V2.5-Pro — 790 input, 86,000 cached, 305 output tokens ต่อ request @@ -137,6 +140,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | คุณสามารถติดตามการใช้งานปัจจุบันของคุณได้ใน **console** @@ -188,6 +192,7 @@ OpenCode Go มีขีดจำกัดดังต่อไปนี้: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | [model id](/docs/config/#models) ใน OpenCode config ของคุณจะใช้รูปแบบ `opencode-go/` ตัวอย่างเช่น สำหรับ Kimi K3 คุณจะใช้ `opencode-go/kimi-k3` ใน config ของคุณ diff --git a/packages/web/src/content/docs/tr/go.mdx b/packages/web/src/content/docs/tr/go.mdx index afb80de220..0611ae31b7 100644 --- a/packages/web/src/content/docs/tr/go.mdx +++ b/packages/web/src/content/docs/tr/go.mdx @@ -64,6 +64,7 @@ Mevcut model listesi şunları içerir: - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** Test edip yenilerini ekledikçe model listesi değişebilir. @@ -98,6 +99,7 @@ Aşağıdaki tablo, tipik Go kullanım modellerine dayalı tahmini bir istek say | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | Tahminler, gözlemlenen istek modellerine dayanır: @@ -112,6 +114,7 @@ Tahminler, gözlemlenen istek modellerine dayanır: - Qwen3.7 Max — İstek başına 420 girdi, 66.000 önbelleğe alınmış, 200 çıktı token'ı - Qwen3.7 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı - Qwen3.6 Plus — İstek başına 500 girdi, 57.000 önbelleğe alınmış, 190 çıktı token'ı +- Hy3 — İstek başına 830 girdi, 71.500 önbelleğe alınmış, 295 çıktı token'ı - MiMo-V2.5 — İstek başına 830 girdi, 71.500 önbelleğe alınmış, 295 çıktı token'ı - MiMo-V2.5-Pro — İstek başına 790 girdi, 86.000 önbelleğe alınmış, 305 çıktı token'ı @@ -137,6 +140,7 @@ Tahminler ayrıca 1M token başına aşağıdaki fiyatlara ve her modelle birlik | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | Mevcut kullanımınızı **konsoldan** takip edebilirsiniz. @@ -188,6 +192,7 @@ Go modellerine aşağıdaki API uç noktaları aracılığıyla da erişebilirsi | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | OpenCode yapılandırmanızdaki [model id](/docs/config/#models) formatı `opencode-go/` şeklindedir. Örneğin, Kimi K3 için yapılandırmanızda `opencode-go/kimi-k3` kullanmalısınız. diff --git a/packages/web/src/content/docs/zh-cn/go.mdx b/packages/web/src/content/docs/zh-cn/go.mdx index 0225c533a1..873b3a0224 100644 --- a/packages/web/src/content/docs/zh-cn/go.mdx +++ b/packages/web/src/content/docs/zh-cn/go.mdx @@ -64,6 +64,7 @@ OpenCode Go 的工作方式与 OpenCode 中的其他提供商一样。 - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 随着我们进行测试和添加新模型,该列表可能会发生变化。 @@ -98,6 +99,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 预估值基于观察到的请求模式: @@ -114,6 +116,7 @@ OpenCode Go 包含以下限制: - Qwen3.7 Max — 每次请求 420 个输入 token,66,000 个缓存 token,200 个输出 token - Qwen3.7 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token - Qwen3.6 Plus — 每次请求 500 个输入 token,57,000 个缓存 token,190 个输出 token +- Hy3 — 每次请求 830 个输入 token,71,500 个缓存 token,295 个输出 token 预估值还基于以下每 1M tokens 的价格以及每个模型包含的每月使用额度: @@ -137,6 +140,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 你可以在 **控制台** 中跟踪你当前的使用情况。 @@ -188,6 +192,7 @@ OpenCode Go 包含以下限制: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 你的 OpenCode 配置中的 [模型 ID](/docs/config/#models) 使用 `opencode-go/` 格式。例如,对于 Kimi K3,你将在配置中使用 `opencode-go/kimi-k3`。 diff --git a/packages/web/src/content/docs/zh-tw/go.mdx b/packages/web/src/content/docs/zh-tw/go.mdx index 08a0bf43f6..691abaa2a9 100644 --- a/packages/web/src/content/docs/zh-tw/go.mdx +++ b/packages/web/src/content/docs/zh-tw/go.mdx @@ -64,6 +64,7 @@ OpenCode Go 的運作方式與 OpenCode 中的任何其他供應商相同。 - **Qwen3.6 Plus** - **DeepSeek V4 Pro** - **DeepSeek V4 Flash** +- **Hy3** 隨著我們測試並加入新模型,模型清單可能會有所變動。 @@ -98,6 +99,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus | 3,300 | 8,200 | 16,300 | | DeepSeek V4 Pro | 3,450 | 8,550 | 17,150 | | DeepSeek V4 Flash | 31,650 | 79,050 | 158,150 | +| Hy3 | 4,300 | 10,750 | 21,500 | 這些預估值是基於觀察到的請求模式: @@ -112,6 +114,7 @@ OpenCode Go 包含以下限制: - Qwen3.7 Max — 每次請求 420 個輸入 token、66,000 個快取 token、200 個輸出 token - Qwen3.7 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token - Qwen3.6 Plus — 每次請求 500 個輸入 token、57,000 個快取 token、190 個輸出 token +- Hy3 — 每次請求 830 個輸入 token、71,500 個快取 token、295 個輸出 token - MiMo-V2.5 — 每次請求 830 個輸入 token、71,500 個快取 token、295 個輸出 token - MiMo-V2.5-Pro — 每次請求 790 個輸入 token、86,000 個快取 token、305 個輸出 token @@ -137,6 +140,7 @@ OpenCode Go 包含以下限制: | Qwen3.6 Plus (> 256K tokens) | $2.00 | $6.00 | $0.20 | $2.50 | $60 | | DeepSeek V4 Pro | $0.435 | $0.87 | $0.003625 | - | $15 | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.0028 | - | $60 | +| Hy3 | $0.14 | $0.58 | $0.035 | - | $60 | 您可以在 **console** 中追蹤您目前的使用量。 @@ -188,6 +192,7 @@ OpenCode Go 包含以下限制: | Qwen3.7 Max | qwen3.7-max | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.7 Plus | qwen3.7-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | | Qwen3.6 Plus | qwen3.6-plus | `https://opencode.ai/zen/go/v1/messages` | `@ai-sdk/anthropic` | +| Hy3 | hy3 | `https://opencode.ai/zen/go/v1/chat/completions` | `@ai-sdk/openai-compatible` | 您的 OpenCode 設定中的 [model id](/docs/config/#models) 使用 `opencode-go/` 格式。例如,Kimi K3 在設定中應使用 `opencode-go/kimi-k3`。 From 542ba88602767490772efa423350f57622b68601 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:37:22 -0500 Subject: [PATCH 06/48] fix(provider): select prompt cache keys by SDK (#38424) --- packages/opencode/src/provider/transform.ts | 73 +++++++---- .../opencode/test/provider/transform.test.ts | 115 ++++++++++++++++++ 2 files changed, 162 insertions(+), 26 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 25dc78bdd0..81759160bf 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -58,6 +58,28 @@ function sdkKey(npm: string): string | undefined { return "vertex" case "@ai-sdk/google": return "google" + case "@ai-sdk/alibaba": + return "alibaba" + case "@ai-sdk/cerebras": + return "cerebras" + case "@ai-sdk/cohere": + return "cohere" + case "@ai-sdk/deepinfra": + return "deepinfra" + case "@ai-sdk/groq": + return "groq" + case "@ai-sdk/mistral": + return "mistral" + case "@ai-sdk/perplexity": + return "perplexity" + case "@ai-sdk/togetherai": + return "togetherai" + case "@ai-sdk/vercel": + return "vercel" + case "@ai-sdk/xai": + return "xai" + case "venice-ai-sdk-provider": + return "venice" case "@ai-sdk/gateway": return "gateway" case "@openrouter/ai-sdk-provider": @@ -442,6 +464,9 @@ function mapProviderOptions( export function message(msgs: ModelMessage[], model: Provider.Model, options: Record) { msgs = unsupportedParts(msgs, model) msgs = normalizeMessages(msgs, model, options) + const usesAnthropicAutomaticCaching = + options.cacheControl !== undefined && + (model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/google-vertex/anthropic") if ( (model.providerID === "anthropic" || model.providerID === "google-vertex-anthropic" || @@ -451,7 +476,8 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re model.id.includes("claude") || model.api.npm === "@ai-sdk/anthropic" || model.api.npm === "@ai-sdk/alibaba") && - model.api.npm !== "@ai-sdk/gateway" + model.api.npm !== "@ai-sdk/gateway" && + !usesAnthropicAutomaticCaching ) { msgs = applyCaching(msgs, model) } @@ -1137,7 +1163,6 @@ export function options(input: { if (input.model.api.npm === "@ai-sdk/azure") { result["store"] = false - result["promptCacheKey"] = input.sessionID } if (input.model.api.npm === "@openrouter/ai-sdk-provider" || input.model.api.npm === "@llmgateway/ai-sdk-provider") { @@ -1166,16 +1191,6 @@ export function options(input: { } } - if ( - input.providerOptions?.setCacheKey !== false && - (input.model.providerID === "openai" || - input.model.api.npm === "@ai-sdk/openai" || - input.model.api.npm === "@ai-sdk/xai" || - input.providerOptions?.setCacheKey) - ) { - result["promptCacheKey"] = input.sessionID - } - if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") { result["reasoningSummary"] = "auto" result["include"] = INCLUDE_ENCRYPTED_REASONING @@ -1224,6 +1239,25 @@ export function options(input: { result["enable_thinking"] = true } + if (input.providerOptions?.setCacheKey !== false) { + if (input.model.api.npm === "@ai-sdk/deepinfra" || input.model.api.npm === "@ai-sdk/cerebras") { + result["prompt_cache_key"] = input.sessionID + } else if ( + input.model.api.npm === "@ai-sdk/openai" || + input.model.api.npm === "@ai-sdk/azure" || + input.model.api.npm === "@ai-sdk/xai" || + input.model.api.npm === "@ai-sdk/mistral" || + input.model.api.npm === "venice-ai-sdk-provider" || + input.providerOptions?.setCacheKey === true + ) { + result["promptCacheKey"] = input.sessionID + } + } + + if (input.model.api.npm === "@ai-sdk/gateway") { + result["gateway"] = { caching: "auto" } + } + if (input.model.api.npm === "@ai-sdk/azure" && input.model.api.id.includes("gpt-5.5")) { result["reasoningSummary"] = "auto" return result @@ -1256,26 +1290,13 @@ export function options(input: { result["textVerbosity"] = "low" } - if (input.model.providerID.startsWith("opencode")) { + if (input.model.providerID.startsWith("opencode") && input.providerOptions?.setCacheKey !== false) { result["promptCacheKey"] = input.sessionID result["include"] = INCLUDE_ENCRYPTED_REASONING result["reasoningSummary"] = "auto" } } - if (input.model.providerID === "venice") { - result["promptCacheKey"] = input.sessionID - } - - if (input.model.providerID === "openrouter") { - result["prompt_cache_key"] = input.sessionID - } - if (input.model.api.npm === "@ai-sdk/gateway") { - result["gateway"] = { - caching: "auto", - } - } - return result } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 68da820ee6..ef2b275035 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -88,6 +88,32 @@ describe("ProviderTransform.options - setCacheKey", () => { expect(result.promptCacheKey).toBe(sessionID) }) + test("should set promptCacheKey for the OpenAI SDK regardless of provider ID", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "custom-openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not set promptCacheKey for the OpenAI-compatible SDK by provider name", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openai", + api: { id: "gpt-5", url: "https://example.com", npm: "@ai-sdk/openai-compatible" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBeUndefined() + }) + test("should not set promptCacheKey for openai when explicitly disabled", () => { const openaiModel = { ...mockModel, @@ -209,6 +235,70 @@ describe("ProviderTransform.options - setCacheKey", () => { providerOptions: {}, }) expect(result.store).toBe(false) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should disable the Azure cache key without disabling store=false", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: { setCacheKey: false }, + }) + expect(result.store).toBe(false) + expect(result.promptCacheKey).toBeUndefined() + }) + + test("should keep the Azure cache key for gpt-5.5 early return", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "azure", + api: { id: "gpt-5.5", url: "https://azure.com", npm: "@ai-sdk/azure" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.store).toBe(false) + expect(result.reasoningSummary).toBe("auto") + expect(result.promptCacheKey).toBe(sessionID) + }) + + for (const npm of ["@ai-sdk/deepinfra", "@ai-sdk/cerebras"]) { + test(`should set the snake-case cache key for ${npm}`, () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm } }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBe(sessionID) + expect(result.promptCacheKey).toBeUndefined() + }) + } + + test("should set promptCacheKey for the Mistral SDK", () => { + const result = ProviderTransform.options({ + model: { ...mockModel, providerID: "custom", api: { ...mockModel.api, npm: "@ai-sdk/mistral" } }, + sessionID, + providerOptions: {}, + }) + expect(result.promptCacheKey).toBe(sessionID) + }) + + test("should not send an undocumented OpenRouter prompt_cache_key", () => { + const result = ProviderTransform.options({ + model: { + ...mockModel, + providerID: "openrouter", + api: { ...mockModel.api, npm: "@openrouter/ai-sdk-provider" }, + }, + sessionID, + providerOptions: {}, + }) + expect(result.prompt_cache_key).toBeUndefined() }) }) @@ -722,6 +812,17 @@ describe("ProviderTransform.providerOptions", () => { }) }) + test("uses canonical sdk key for custom xAI models", () => { + const model = createModel({ + providerID: "my-xai", + api: { id: "grok-4", url: "https://api.x.ai", npm: "@ai-sdk/xai" }, + }) + + expect(ProviderTransform.providerOptions(model, { promptCacheKey: "session" })).toEqual({ + xai: { promptCacheKey: "session" }, + }) + }) + test("forces reasoning for explicit effort even when model is not marked reasoning-capable", () => { const model = createModel({ capabilities: { @@ -3011,6 +3112,20 @@ describe("ProviderTransform.message - cache control on gateway", () => { }) }) + test("does not add explicit breakpoints when Anthropic automatic caching is enabled", () => { + const model = createModel({ + providerID: "anthropic", + api: { id: "claude-sonnet-4", url: "https://api.anthropic.com", npm: "@ai-sdk/anthropic" }, + }) + const msgs = [ + { role: "system", content: "You are a helpful assistant" }, + { role: "user", content: "Hello" }, + ] as any[] + + const result = ProviderTransform.message(msgs, model, { cacheControl: { type: "ephemeral" } }) as any[] + expect(result.every((message) => message.providerOptions === undefined)).toBe(true) + }) + test("google-vertex-anthropic applies cache control", () => { const model = createModel({ providerID: "google-vertex-anthropic", From fada1a538f4eb11d617229f15f23aaa8cfbd2d2a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 22 Jul 2026 22:53:51 -0500 Subject: [PATCH 07/48] fix(provider): serialize Mistral prompt cache keys (#38448) --- bun.lock | 17 ++++- package.json | 1 + packages/core/package.json | 2 +- packages/core/test/provider-mistral.test.ts | 28 +++++++ packages/opencode/package.json | 2 +- patches/@ai-sdk%2Fmistral@3.0.34.patch | 84 +++++++++++++++++++++ 6 files changed, 128 insertions(+), 6 deletions(-) create mode 100644 packages/core/test/provider-mistral.test.ts create mode 100644 patches/@ai-sdk%2Fmistral@3.0.34.patch diff --git a/bun.lock b/bun.lock index 5c7a792abc..5114ec200d 100644 --- a/bun.lock +++ b/bun.lock @@ -302,7 +302,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.34", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -577,7 +577,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.34", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -1079,6 +1079,7 @@ "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", + "@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1199,7 +1200,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.34", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HpK28sWGdIfg1vTSScJNtzVdvNRfA4mfCmPmPR+j/MGJ0oAuEJMqxWkL96ZnGPdhZt5KdW09aKovdIe+q2zQ7A=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -5699,7 +5700,9 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], @@ -6173,6 +6176,8 @@ "ai-gateway-provider/@ai-sdk/deepseek": ["@ai-sdk/deepseek@2.0.35", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.27" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-9DhYurbAvcurOEGN6u2myYDybrrzGfcrkG8hwmFjwTrePW6KCMggm0YxP7e8RkLYcQKqCEMgFlyEB4BM6EmiKg=="], + "ai-gateway-provider/@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ZXe7nZQgliDdjz5ufH5RKpHWxbN72AzmzzKGbF/z+0K9GN5tUCnftrQRvTRFHA5jAzTapcm2BEevmGLVbMkW+A=="], + "ai-gateway-provider/@ai-sdk/openai": ["@ai-sdk/openai@3.0.53", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-Wld+Rbc05KaUn08uBt06eEuwcgalcIFtIl32Yp+GxuZXUQwOb6YeAuq+C6da4ch6BurFoqEaLemJVwjBb7x+PQ=="], "ai-gateway-provider/@ai-sdk/xai": ["@ai-sdk/xai@3.0.82", "", { "dependencies": { "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-A0VFMufnVf4wODcT3SPQUUzvYXiIO1VhFuXj9r6z/vP4rlo+QRDPw3WSTchcz93ROQWSfBE3I6Szqz342OHi5w=="], @@ -6989,6 +6994,8 @@ "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.27", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ubkAJ+xODouwtmN1tYlvTPphH1hPOBfZaEQe8U7skGvFAnIRs9PPpsq57bC2+Ky/MB4yzhd6YOsxTAx9sGpazw=="], + "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], + "ai-gateway-provider/@ai-sdk/xai/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], "ajv-keywords/ajv/json-schema-traverse": ["json-schema-traverse@0.4.1", "", {}, "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg=="], @@ -7415,6 +7422,8 @@ "ai-gateway-provider/@ai-sdk/deepseek/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ai-gateway-provider/@ai-sdk/mistral/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], + "ansi-align/string-width/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], "app-builder-lib/@electron/get/fs-extra/universalify": ["universalify@0.1.2", "", {}, "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg=="], diff --git a/package.json b/package.json index 332d7520a6..372335d724 100644 --- a/package.json +++ b/package.json @@ -149,6 +149,7 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", diff --git a/packages/core/package.json b/packages/core/package.json index 17708d2955..e0445e616f 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,7 +72,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.34", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", diff --git a/packages/core/test/provider-mistral.test.ts b/packages/core/test/provider-mistral.test.ts new file mode 100644 index 0000000000..58904ad3b1 --- /dev/null +++ b/packages/core/test/provider-mistral.test.ts @@ -0,0 +1,28 @@ +import { createMistral } from "@ai-sdk/mistral" +import { expect, test } from "bun:test" + +test("Mistral sends promptCacheKey as prompt_cache_key", async () => { + let body: Record | undefined + const mockFetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + body = JSON.parse(String(init?.body)) + return Response.json({ + id: "response-1", + created: 0, + model: "mistral-large-latest", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: "Hello" }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-large-latest") + + await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + providerOptions: { mistral: { promptCacheKey: "session-123" } }, + }) + + expect(body?.prompt_cache_key).toBe("session-123") +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index e702acf8cf..6781bf488e 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -66,7 +66,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.27", + "@ai-sdk/mistral": "3.0.34", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", diff --git a/patches/@ai-sdk%2Fmistral@3.0.34.patch b/patches/@ai-sdk%2Fmistral@3.0.34.patch new file mode 100644 index 0000000000..1d771f4fd9 --- /dev/null +++ b/patches/@ai-sdk%2Fmistral@3.0.34.patch @@ -0,0 +1,84 @@ +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 1ca9113bed2728a616db773a8e08d8d6957447d7..15408ec429dc210b5fa43589d81b69c93bf27b2d 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.js b/dist/index.js +index 45735e524aaff54ea058c99c729c5ffd3c507058..6aca5f6f13da0054ede31c1f1a692e4eaed37d34 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -268,7 +268,8 @@ var mistralLanguageModelOptions = import_v4.z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: import_v4.z.enum(["high", "none"]).optional() ++ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(), ++ promptCacheKey: import_v4.z.string().optional() + }); + + // src/mistral-error.ts +@@ -413,6 +414,7 @@ var MistralChatLanguageModel = class { + top_p: topP, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +diff --git a/dist/index.mjs b/dist/index.mjs +index 4c22df1cd78a1ba81309c8a86ceecefef4ba4aea..30cd3b1f503860109b7fa2107cd1eb17b70c96be 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -256,7 +256,8 @@ var mistralLanguageModelOptions = z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: z.enum(["high", "none"]).optional() ++ reasoningEffort: z.enum(["high", "none"]).optional(), ++ promptCacheKey: z.string().optional() + }); + + // src/mistral-error.ts +@@ -403,6 +404,7 @@ var MistralChatLanguageModel = class { + top_p: topP, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts +index 480c472d534bedbe8897979673453bd1c29a70b7..e46496da94f7d4af9822897202ca6baae67dae3a 100644 +--- a/src/mistral-chat-language-model.ts ++++ b/src/mistral-chat-language-model.ts +@@ -129,6 +129,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + top_p: topP, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + + // response format: + response_format: +diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts +index 80fff45fba2c378fa06962f071946bcd2b882a0b..b4fdfa51f3bf11a4220e010c8aca92482cb0c3db 100644 +--- a/src/mistral-chat-options.ts ++++ b/src/mistral-chat-options.ts +@@ -62,6 +62,11 @@ export const mistralLanguageModelOptions = z.object({ + * - `'none'`: Disable reasoning + */ + reasoningEffort: z.enum(['high', 'none']).optional(), ++ ++ /** ++ * A stable identifier used to route requests with shared prompt prefixes. ++ */ ++ promptCacheKey: z.string().optional(), + }); + + export type MistralLanguageModelOptions = z.infer< From 92cede0541305a99579b0575b79297089d37e6da Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 04:06:08 +0000 Subject: [PATCH 08/48] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 963d46ecf4..1b662e8236 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-qt11SKmOjq0KU542QFbs+u7YyJicn4drCcwCdg325yk=", - "aarch64-linux": "sha256-z68doReXTrWS7HeiAjc0btIjAsvzeZZ7hXAlHr0c77Q=", - "aarch64-darwin": "sha256-PILYH1Pi8XBvSkuZ+1sNnUTao5kba+m5Z8iJKx6YXPo=", - "x86_64-darwin": "sha256-KpcJzP4m0SUavu/WaSffgzOxrHq8ljdy0GOzs9p16lo=" + "x86_64-linux": "sha256-L741oedvozk0cIVnaZnujvwWrK+WXINv9KiKxYRfVwQ=", + "aarch64-linux": "sha256-ThzQ4nCLbaLiKA7cBHI7OMAlXb+8Hchm3HojGnIAEz0=", + "aarch64-darwin": "sha256-YdXOgFgYRu4tKw90+7F1reCihO+JC33dGk51J8NTRIk=", + "x86_64-darwin": "sha256-Ea5X2mYHGch3JyA6wC0uH3zBEzQcFT/adqJ1+7LtRdQ=" } } From e45210c6d218e368b1ddbd14fad378f5c1322741 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 23 Jul 2026 14:48:33 +0800 Subject: [PATCH 09/48] chore(app): vendor v2 promise client (#38467) --- bun.lock | 6 ++++++ packages/app/package.json | 1 + .../app/vendor/opencode-ai-client-1.17.13.tgz | Bin 0 -> 75585 bytes packages/session-ui/package.json | 1 + 4 files changed, 8 insertions(+) create mode 100644 packages/app/vendor/opencode-ai-client-1.17.13.tgz diff --git a/bun.lock b/bun.lock index 5114ec200d..e37adbe9ae 100644 --- a/bun.lock +++ b/bun.lock @@ -37,6 +37,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -798,6 +799,7 @@ "version": "1.18.4", "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -6028,6 +6030,8 @@ "@openauthjs/openauth/jose": ["jose@5.9.6", "", {}, "sha512-AMlnetc9+CV9asI19zHmrgS/WYsWUwCn2R7RzlbJWD7F9eWYUTGyBmU9o6PxngtLGOiDGPRu+Uc4fhKzbpteZQ=="], + "@opencode-ai/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/core/@ai-sdk/openai": ["@ai-sdk/openai@3.0.84", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.38" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-cmgbeJL0bbY0yTJH4/AdmP5E7MjWRL9G8UdhIi0JlV/So03o82ORJofW8OzwCZPTORVQblFbpZXYGDcUd9NdUQ=="], "@opencode-ai/core/@ai-sdk/openai-compatible": ["@ai-sdk/openai-compatible@2.0.41", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.23" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-kNAGINk71AlOXx10Dq/PXw4t/9XjdK8uxfpVElRwtSFMdeSiLVt58p9TPx4/FJD+hxZuVhvxYj9r42osxWq79g=="], @@ -6046,6 +6050,8 @@ "@opencode-ai/script/semver": ["semver@7.8.5", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA=="], + "@opencode-ai/session-ui/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13.tgz", {}, "sha512-1cYJikTCrXNhnS2qQ3P3rtdbGqhvJKksswG0amJNqgaeUfz3xDlKEDx+YoIosT7Cqk/AtO18jtip0lYL3TVdHQ=="], + "@opencode-ai/session-ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], "@opencode-ai/ui/@solid-primitives/resize-observer": ["@solid-primitives/resize-observer@2.1.3", "", { "dependencies": { "@solid-primitives/event-listener": "^2.4.3", "@solid-primitives/rootless": "^1.5.2", "@solid-primitives/static-store": "^0.1.2", "@solid-primitives/utils": "^6.3.2" }, "peerDependencies": { "solid-js": "^1.6.12" } }, "sha512-zBLje5E06TgOg93S7rGPldmhDnouNGhvfZVKOp+oG2XU8snA+GoCSSCz1M+jpNAg5Ek2EakU5UVQqL152WmdXQ=="], diff --git a/packages/app/package.json b/packages/app/package.json index faf0194461..f6a1bf9d2e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,6 +53,7 @@ "@dnd-kit/helpers": "0.5.0", "@dnd-kit/solid": "0.5.0", "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/vendor/opencode-ai-client-1.17.13.tgz b/packages/app/vendor/opencode-ai-client-1.17.13.tgz new file mode 100644 index 0000000000000000000000000000000000000000..5939f2cb39c27da205f1f37e9971009a669b83c2 GIT binary patch literal 75585 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 z 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

    $=Nes$vsW~pOCtUnEt6JQ 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 literal 0 HcmV?d00001 diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 96611da331..3cf74b043b 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -39,6 +39,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", From 84c79c13991ec9df5a80954d324964e7816536d7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 07:08:44 +0000 Subject: [PATCH 10/48] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index 1b662e8236..de8b61cd62 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-L741oedvozk0cIVnaZnujvwWrK+WXINv9KiKxYRfVwQ=", - "aarch64-linux": "sha256-ThzQ4nCLbaLiKA7cBHI7OMAlXb+8Hchm3HojGnIAEz0=", - "aarch64-darwin": "sha256-YdXOgFgYRu4tKw90+7F1reCihO+JC33dGk51J8NTRIk=", - "x86_64-darwin": "sha256-Ea5X2mYHGch3JyA6wC0uH3zBEzQcFT/adqJ1+7LtRdQ=" + "x86_64-linux": "sha256-P6Y+qaho1njCsiRdH9ej+Wyd+BuDJ60w/tcS4koUrLo=", + "aarch64-linux": "sha256-cjOYq60xL1xGGg5PugnOGX3DAYZAetP/BmCbkd5cqtQ=", + "aarch64-darwin": "sha256-L95qDP53TDoHPlJDBztqTCDiFJ9mxmX4lS8h60hnZ54=", + "x86_64-darwin": "sha256-OeMS5Z8LO+GCzQqLeFxBiQEGWUxVerTwctDi+0SiFb0=" } } From d03e0c5e547f2bc7ae44e60eb21bfb24dad623fd Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 23 Jul 2026 15:59:38 +0800 Subject: [PATCH 11/48] feat(app): add dual-server compatibility (#38462) --- packages/app/src/utils/server-compat.test.ts | 95 ++++ packages/app/src/utils/server-compat.ts | 496 ++++++++++++++++++ packages/app/src/utils/server-health.test.ts | 38 +- packages/app/src/utils/server-health.ts | 28 +- .../app/src/utils/server-protocol.test.ts | 40 ++ packages/app/src/utils/server-protocol.ts | 35 ++ packages/app/src/utils/server.ts | 21 + packages/desktop/src/main/server.ts | 23 +- 8 files changed, 756 insertions(+), 20 deletions(-) create mode 100644 packages/app/src/utils/server-compat.test.ts create mode 100644 packages/app/src/utils/server-compat.ts create mode 100644 packages/app/src/utils/server-protocol.test.ts create mode 100644 packages/app/src/utils/server-protocol.ts diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts new file mode 100644 index 0000000000..eca83effa0 --- /dev/null +++ b/packages/app/src/utils/server-compat.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, test } from "bun:test" +import { createApiForServer, createSdkForServer } from "./server" +import { createCompatibleApi } from "./server-compat" + +function setup(protocol: "v1" | "v2") { + const requests: Request[] = [] + const fetcher = Object.assign( + async (input: string | URL | Request, init?: RequestInit) => { + const request = new Request(input, init) + requests.push(request) + if (request.method === "PATCH") { + return Response.json({ + id: "ses_1", + slug: "ses_1", + projectID: "project", + directory: "/repo", + title: "Session", + version: "1", + time: { created: 1, updated: 1 }, + }) + } + if (request.method === "POST" && request.url.endsWith("/prompt_async")) + return new Response(undefined, { status: 204 }) + if (request.method === "POST" && request.url.endsWith("/prompt")) { + return Response.json({ + admittedSeq: 1, + id: "msg_1", + sessionID: "ses_1", + timeCreated: 1, + type: "user", + data: { text: "hello" }, + delivery: "steer", + }) + } + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) + }, + { preconnect: globalThis.fetch.preconnect }, + ) + const server = { url: "http://localhost:4096" } + const api = createCompatibleApi({ + protocol: Promise.resolve(protocol), + current: createApiForServer({ server, fetch: fetcher }), + legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), + directory: "/repo", + }) + return { api, requests } +} + +describe("createCompatibleApi", () => { + test("routes V1 archive through the legacy session update", async () => { + const { api, requests } = setup("v1") + await api.session.archive({ sessionID: "ses_1", directory: "/repo" }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/session/ses_1") + expect(requests[0]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[0]!.method).toBe("PATCH") + expect(await requests[0]!.json()).toMatchObject({ time: { archived: expect.any(Number) } }) + }) + + test("converts current prompts to the V1 prompt contract", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "hello", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") + expect(await requests[0]!.json()).toMatchObject({ + messageID: "msg_1", + agent: "build", + model: { providerID: "provider", modelID: "model" }, + parts: [{ type: "text", text: "hello" }], + }) + }) + + test("keeps V2 session actions on the current API", async () => { + const { api, requests } = setup("v2") + await api.session.archive({ sessionID: "ses_1" }) + + expect(new URL(requests[0]!.url).pathname).toBe("/api/session/ses_1/archive") + expect(requests[0]!.method).toBe("POST") + }) + + test("uses the global V1 session search endpoint", async () => { + const { api, requests } = setup("v1") + await api.session.list({ parentID: null, search: "session", limit: 50 }) + + expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") + }) +}) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts new file mode 100644 index 0000000000..95854d03b2 --- /dev/null +++ b/packages/app/src/utils/server-compat.ts @@ -0,0 +1,496 @@ +import type { ServerApi } from "./server" +import type { ServerProtocol } from "./server-protocol" +import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" +import type { + Project, + ProjectCurrent, + SessionApi, + SessionCommandInput, + SessionCommandOutput, + SessionCompactInput, + SessionCompactOutput, + SessionInfo, + SessionPromptInput, + SessionPromptOutput, + SessionShellInput, + SessionShellOutput, +} from "@opencode-ai/client/promise" + +type LegacyClient = OpencodeClient +type LegacyFor = (directory?: string) => LegacyClient +type CompatibleSessionApi = Omit< + SessionApi, + "prompt" | "command" | "shell" | "compact" | "rename" | "archive" | "remove" +> & { + prompt: (input: SessionPromptInput & LegacyPrompt) => Promise + command: (input: SessionCommandInput) => Promise + shell: (input: SessionShellInput & LegacyPrompt) => Promise + compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise + rename: (input: Parameters[0] & LegacyLocation) => ReturnType + archive: (input: Parameters[0] & LegacyLocation) => ReturnType + remove: (input: Parameters[0] & LegacyLocation) => ReturnType +} +export type CompatibleApi = Omit & { readonly session: CompatibleSessionApi } +type LegacyPrompt = { + agent?: string + model?: { providerID: string; modelID: string } + variant?: string +} +type LegacyLocation = { directory?: string } + +function mime(uri: string) { + const match = /^data:([^;,]+)/.exec(uri) + return match?.[1] ?? "application/octet-stream" +} + +function sessionInfo(session: Session): SessionInfo { + return { + id: session.id, + parentID: session.parentID, + projectID: session.projectID, + agent: session.agent, + model: session.model && { + id: session.model.id, + providerID: session.model.providerID, + variant: session.model.variant, + }, + cost: session.cost ?? 0, + tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: session.time, + title: session.title, + location: { directory: session.directory, workspaceID: session.workspaceID }, + subpath: session.path, + revert: session.revert && { + messageID: session.revert.messageID, + partID: session.revert.partID, + snapshot: session.revert.snapshot, + }, + } +} + +export function createCompatibleApi(input: { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +}): CompatibleApi { + const directory = (location?: { directory?: string }) => location?.directory ?? input.directory + const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) + const isV1 = () => input.protocol.then((protocol) => protocol === "v1") + const located = (data: T, value?: { directory?: string }) => ({ + location: { + directory: directory(value) ?? "", + project: { id: "", directory: directory(value) ?? "" }, + }, + data, + }) + + return { + ...input.current, + session: { + ...input.current.session, + async list( + value?: Parameters[0], + options?: Parameters[1], + ) { + if (!(await isV1())) return input.current.session.list(value, options) + if (!value?.directory && value?.search !== undefined) { + const result = await legacy().experimental.session.list( + { + roots: value.parentID === null ? true : undefined, + search: value.search, + limit: value.limit, + }, + options, + ) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + } + const result = await legacy({ directory: value?.directory }).session.list({ + directory: value?.directory, + roots: value?.parentID === null ? true : undefined, + search: value?.search, + limit: value?.limit, + }) + return { data: (result.data ?? []).map(sessionInfo), cursor: {} } + }, + async create(value?: Parameters[0]) { + if (!(await isV1())) return input.current.session.create(value) + const result = await legacy(value?.location ?? undefined).session.create({ + directory: directory(value?.location ?? undefined), + }) + if (!result.data) throw new Error("Failed to create session") + return sessionInfo(result.data) + }, + async get(value: Parameters[0]) { + if (!(await isV1())) return input.current.session.get(value) + const result = await legacy().session.get(value) + if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) + return sessionInfo(result.data) + }, + async active() { + if (!(await isV1())) return input.current.session.active() + const result = await legacy().session.status() + return Object.fromEntries( + Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + }, + async rename(value: Parameters[0] & LegacyLocation) { + if (!(await isV1())) return input.current.session.rename(value) + await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) + }, + async archive(value: Parameters[0] & LegacyLocation) { + if (!(await isV1())) return input.current.session.archive(value) + await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + }, + async remove(value: Parameters[0] & LegacyLocation) { + if (!(await isV1())) return input.current.session.remove(value) + await legacy(value).session.delete(value) + }, + async fork(value: Parameters[0]) { + if (!(await isV1())) return input.current.session.fork(value) + const result = await legacy().session.fork(value) + if (!result.data) throw new Error("Failed to fork session") + return sessionInfo(result.data) + }, + async interrupt(value: Parameters[0]) { + if (!(await isV1())) return input.current.session.interrupt(value) + await legacy().session.abort(value) + }, + async prompt(value: SessionPromptInput & LegacyPrompt) { + if (!(await isV1())) return input.current.session.prompt(value) + await legacy().session.promptAsync({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + agent: value.agent, + model: value.model, + variant: value.variant, + parts: [ + { type: "text", text: value.text }, + ...(value.files ?? []).map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + ...(value.agents ?? []).map((agent) => ({ + type: "agent" as const, + name: agent.name, + source: agent.mention + ? { value: agent.mention.text, start: agent.mention.start, end: agent.mention.end } + : undefined, + })), + ], + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: value.text }, + delivery: value.delivery ?? "steer", + } + }, + async command(value: SessionCommandInput) { + if (!(await isV1())) return input.current.session.command(value) + await legacy().session.command({ + sessionID: value.sessionID, + messageID: value.id ?? undefined, + command: value.command, + arguments: value.arguments ?? "", + agent: value.agent ?? undefined, + model: value.model ? `${value.model.providerID}/${value.model.id}` : undefined, + variant: value.model?.variant, + parts: value.files?.map((file) => ({ + type: "file" as const, + mime: mime(file.uri), + url: file.uri, + filename: file.name, + })), + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "user", + data: { text: `/${value.command} ${value.arguments ?? ""}`.trim() }, + delivery: value.delivery ?? "steer", + } + }, + async shell(value: SessionShellInput & LegacyPrompt) { + if (!(await isV1())) return input.current.session.shell(value) + await legacy().session.shell({ + sessionID: value.sessionID, + command: value.command, + agent: value.agent, + model: value.model, + }) + }, + compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { + if (!(await isV1())) return input.current.session.compact(value) + if (!value.model) throw new Error("A model is required to compact a V1 session") + await legacy().session.summarize({ + sessionID: value.sessionID, + providerID: value.model.providerID, + modelID: value.model.modelID, + }) + return { + admittedSeq: 0, + id: value.id ?? "", + sessionID: value.sessionID, + timeCreated: Date.now(), + type: "compaction", + } + }, + revert: { + stage: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.session.revert.stage(value) + await legacy().session.revert(value) + return { messageID: value.messageID } + }, + clear: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.session.revert.clear(value) + await legacy().session.unrevert(value) + }, + commit: input.current.session.revert.commit, + }, + }, + project: { + ...input.current.project, + async list() { + if (!(await isV1())) return input.current.project.list() + return ((await legacy().project.list()).data ?? []) as Project[] + }, + async current(value?: Parameters[0]) { + if (!(await isV1())) return input.current.project.current(value) + const result = await legacy(value?.location).project.current() + if (!result.data) throw new Error("Project not found") + return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent + }, + async update(value: Parameters[0]) { + if (!(await isV1())) return input.current.project.update(value) + const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) + const result = await legacy({ directory: project?.worktree }).project.update({ + ...value, + directory: project?.worktree, + }) + if (!result.data) throw new Error(`Project not found: ${value.projectID}`) + return result.data as Project + }, + async directories(value: Parameters[0]) { + if (!(await isV1())) return input.current.project.directories(value) + const result = await legacy(value.location).worktree.list() + return (result.data ?? []).map((item) => ({ directory: item })) + }, + }, + path: { + ...input.current.path, + async get(value?: Parameters[0]) { + if (!(await isV1())) return input.current.path.get(value) + const result = await legacy(value?.location).path.get() + if (!result.data) throw new Error("Path unavailable") + return result.data + }, + }, + vcs: { + ...input.current.vcs, + async get(value?: Parameters[0]) { + if (!(await isV1())) return input.current.vcs.get(value) + const result = await legacy(value?.location).vcs.get() + return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) + }, + async status(value?: Parameters[0]) { + if (!(await isV1())) return input.current.vcs.status(value) + const result = await legacy(value?.location).vcs.status() + return located(result.data ?? [], value?.location) + }, + async diff(value: Parameters[0]) { + if (!(await isV1())) return input.current.vcs.diff(value) + const result = await legacy(value.location).vcs.diff({ + mode: value.mode === "working" ? "git" : value.mode, + context: value.context, + }) + return located( + (result.data ?? []).map((file) => ({ + file: file.file, + patch: file.patch ?? "", + additions: file.additions, + deletions: file.deletions, + status: file.status ?? "modified", + })), + value.location, + ) + }, + }, + file: { + ...input.current.file, + async list(value?: Parameters[0]) { + if (!(await isV1())) return input.current.file.list(value) + const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) + return located(result.data ?? [], value?.location) + }, + async find(value: Parameters[0]) { + if (!(await isV1())) return input.current.file.find(value) + const result = await legacy(value.location).find.files({ + query: value.query, + type: value.type, + limit: value.limit, + }) + return located( + (result.data ?? []).map((path) => ({ path, type: value.type ?? "file" })), + value.location, + ) + }, + }, + integration: { + ...input.current.integration, + async get(value: Parameters[0]) { + if (!(await isV1())) return input.current.integration.get(value) + const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( + (method, index) => + method.type === "api" + ? { type: "key" as const, label: method.label } + : { type: "oauth" as const, id: String(index), label: method.label, prompts: method.prompts }, + ) + return located( + { + id: value.integrationID, + name: value.integrationID, + methods, + connections: [], + }, + value.location, + ) + }, + connect: { + ...input.current.integration.connect, + key: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.connect.key(value) + await legacy(value.location).auth.set({ + providerID: value.integrationID, + auth: { type: "api", key: value.key }, + }) + }, + }, + oauth: { + ...input.current.integration.oauth, + connect: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.oauth.connect(value) + const method = Number(value.methodID) + const result = await legacy(value.location).provider.oauth.authorize( + { providerID: value.integrationID, method, inputs: value.inputs }, + { throwOnError: true }, + ) + if (!result.data) throw new Error("Failed to start OAuth authorization") + return located( + { + attemptID: `${value.integrationID}:${method}`, + url: result.data.url, + instructions: result.data.instructions, + mode: result.data.method, + time: { created: Date.now(), expires: Date.now() + 10 * 60 * 1000 }, + }, + value.location, + ) + }, + complete: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.oauth.complete(value) + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method, code: value.code }, + { throwOnError: true }, + ) + }, + status: async (value: Parameters[0]) => { + if (!(await isV1())) return input.current.integration.oauth.status(value) + const method = Number(value.attemptID.split(":").at(-1)) + await legacy(value.location).provider.oauth.callback( + { providerID: value.integrationID, method }, + { throwOnError: true }, + ) + return located( + { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, + value.location, + ) + }, + }, + }, + pty: { + ...input.current.pty, + async shells(value?: Parameters[0]) { + if (!(await isV1())) return input.current.pty.shells(value) + return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + }, + async list(value?: Parameters[0]) { + if (!(await isV1())) return input.current.pty.list(value) + return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) + }, + async create(value?: Parameters[0]) { + if (!(await isV1())) return input.current.pty.create(value) + const result = await legacy(value?.location).pty.create({ + command: value?.command, + args: value?.args ? [...value.args] : undefined, + cwd: value?.cwd, + title: value?.title, + env: value?.env, + }) + if (!result.data) throw new Error("Failed to create terminal") + return located(result.data, value?.location) + }, + async get(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.get(value) + const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async update(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.update(value) + const result = await legacy(value.location).pty.update({ + ptyID: value.ptyID, + title: value.title, + size: value.size, + }) + if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) + return located(result.data, value.location) + }, + async remove(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.remove(value) + await legacy(value.location).pty.remove({ ptyID: value.ptyID }) + }, + async connectToken(value: Parameters[0]) { + if (!(await isV1())) return input.current.pty.connectToken(value) + const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) + if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) + return located(result.data, value.location) + }, + }, + permission: { + ...input.current.permission, + async reply(value: Parameters[0]) { + if (!(await isV1())) return input.current.permission.reply(value) + await legacy().permission.respond({ + sessionID: value.sessionID, + permissionID: value.requestID, + response: value.reply, + }) + }, + }, + question: { + ...input.current.question, + async reply(value: Parameters[0]) { + if (!(await isV1())) return input.current.question.reply(value) + await legacy().question.reply({ + requestID: value.requestID, + answers: value.answers.map((answer) => [...answer]), + }) + }, + async reject(value: Parameters[0]) { + if (!(await isV1())) return input.current.question.reject(value) + await legacy().question.reject({ requestID: value.requestID }) + }, + }, + } +} diff --git a/packages/app/src/utils/server-health.test.ts b/packages/app/src/utils/server-health.test.ts index b1c8f2c7e2..69a8c7b3be 100644 --- a/packages/app/src/utils/server-health.test.ts +++ b/packages/app/src/utils/server-health.test.ts @@ -14,15 +14,45 @@ function abortFromInput(input: RequestInfo | URL, init?: RequestInit) { describe("checkServerHealth", () => { test("returns healthy response with version", async () => { - const fetch = (async () => - new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { + let request: URL | undefined + const fetch = (async (input: RequestInfo | URL) => { + request = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + return new Response(JSON.stringify({ healthy: true, version: "1.2.3" }), { status: 200, headers: { "content-type": "application/json" }, - })) as unknown as typeof globalThis.fetch + }) + }) as unknown as typeof globalThis.fetch const result = await checkServerHealth(server, fetch) expect(result).toEqual({ healthy: true, version: "1.2.3" }) + expect(request?.pathname).toBe("/api/health") + }) + + test("falls back to the V1 health endpoint", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return new Response(undefined, { status: 404 }) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) + }) + + test("falls back when the current health response is malformed", async () => { + const paths: string[] = [] + const fetch = (async (input: RequestInfo | URL) => { + const url = input instanceof URL ? input : new URL(input instanceof Request ? input.url : input) + paths.push(url.pathname) + if (url.pathname === "/api/health") return Response.json({}) + return Response.json({ healthy: true, version: "1.18.4" }) + }) as unknown as typeof globalThis.fetch + + expect(await checkServerHealth(server, fetch)).toEqual({ healthy: true, version: "1.18.4" }) + expect(paths).toEqual(["/api/health", "/global/health"]) }) test("allows slow servers thirty seconds by default", async () => { @@ -142,7 +172,7 @@ describe("checkServerHealth", () => { retryDelayMs: 1, }) - expect(count).toBe(3) + expect(count).toBe(6) expect(result).toEqual({ healthy: false }) }) }) diff --git a/packages/app/src/utils/server-health.ts b/packages/app/src/utils/server-health.ts index 1b684d9af7..1d7d9e4b2e 100644 --- a/packages/app/src/utils/server-health.ts +++ b/packages/app/src/utils/server-health.ts @@ -1,6 +1,7 @@ import { usePlatform } from "@/context/platform" import { ServerConnection } from "@/context/server" -import { createSdkForServer } from "./server" +import { authTokenFromCredentials, createSdkForServer } from "./server" +import { ClientError, OpenCode } from "@opencode-ai/client" import { Accessor, createEffect, onCleanup } from "solid-js" import { createStore, reconcile } from "solid-js/store" @@ -61,6 +62,7 @@ function wait(ms: number, signal?: AbortSignal) { function retryable(error: unknown, signal?: AbortSignal) { if (signal?.aborted) return false + if (error instanceof ClientError) return error.reason === "Transport" if (!(error instanceof Error)) return false if (error.name === "AbortError" || error.name === "TimeoutError") return false if (error instanceof TypeError) return true @@ -82,15 +84,31 @@ export async function checkServerHealth( .then(() => attempt(count + 1)) .catch(() => ({ healthy: false })) } - const attempt = (count: number): Promise => - createSdkForServer({ - server, + const attempt = async (count: number): Promise => { + const current = await OpenCode.make({ + baseUrl: server.url, fetch, - signal, + headers: server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } + : undefined, }) + .health.get({ signal }) + .then((x) => + typeof x.healthy === "boolean" + ? { data: { healthy: x.healthy, version: x.version } } + : { error: new Error("Invalid health response") }, + ) + .catch((error) => ({ error })) + if ("data" in current && current.data) return current.data + if (signal?.aborted) return { healthy: false } + + return createSdkForServer({ server, fetch, signal }) .global.health() .then((x) => (x.error ? next(count, x.error) : { healthy: x.data?.healthy === true, version: x.data?.version })) .catch((error) => next(count, error)) + } return attempt(0).finally(() => timeout?.clear?.()) } diff --git a/packages/app/src/utils/server-protocol.test.ts b/packages/app/src/utils/server-protocol.test.ts new file mode 100644 index 0000000000..2130a968c4 --- /dev/null +++ b/packages/app/src/utils/server-protocol.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, test } from "bun:test" +import { detectServerProtocol } from "./server-protocol" + +const server = { url: "http://localhost:4096" } +const json = (value: unknown, status = 200) => + new Response(JSON.stringify(value), { status, headers: { "content-type": "application/json" } }) +const mockFetch = (run: (input: string | URL | Request) => Promise) => + Object.assign(run, { preconnect: globalThis.fetch.preconnect }) + +describe("detectServerProtocol", () => { + test("prefers the legacy health endpoint when both API generations exist", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({ healthy: true, version: "1.18.4" })) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) + + test("recognizes V2 health by its process identifier", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true, version: "2.0.0", pid: 123 })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v2") + }) + + test("recognizes the transitional V1 API health response", async () => { + const fetcher = mockFetch((input) => { + const path = new URL(input instanceof Request ? input.url : input).pathname + if (path === "/global/health") return Promise.resolve(json({}, 404)) + return Promise.resolve(json({ healthy: true })) + }) + + expect(await detectServerProtocol(server, fetcher)).toBe("v1") + }) +}) diff --git a/packages/app/src/utils/server-protocol.ts b/packages/app/src/utils/server-protocol.ts new file mode 100644 index 0000000000..27b8dc208e --- /dev/null +++ b/packages/app/src/utils/server-protocol.ts @@ -0,0 +1,35 @@ +import type { ServerConnection } from "@/context/server" +import { authTokenFromCredentials } from "./server" + +export type ServerProtocol = "v1" | "v2" + +function headers(server: ServerConnection.HttpBase) { + if (!server.password) return + return { + Authorization: `Basic ${authTokenFromCredentials({ username: server.username, password: server.password })}`, + } +} + +async function probe(server: ServerConnection.HttpBase, fetch: typeof globalThis.fetch, path: string) { + const response = await fetch(new URL(path, server.url), { + headers: headers(server), + signal: AbortSignal.timeout(5_000), + }) + if (!response.ok || !response.headers.get("content-type")?.includes("application/json")) return + const value: unknown = await response.json() + if (!value || typeof value !== "object") return + return value +} + +export async function detectServerProtocol( + server: ServerConnection.HttpBase, + fetch: typeof globalThis.fetch, +): Promise { + const legacy = await probe(server, fetch, "/global/health").catch(() => undefined) + if (legacy && "healthy" in legacy && legacy.healthy === true) return "v1" + + const current = await probe(server, fetch, "/api/health").catch(() => undefined) + if (current && "pid" in current && typeof current.pid === "number") return "v2" + if (current && "healthy" in current && current.healthy === true) return "v1" + return "v2" +} diff --git a/packages/app/src/utils/server.ts b/packages/app/src/utils/server.ts index 603784e4d4..1c8292ca9d 100644 --- a/packages/app/src/utils/server.ts +++ b/packages/app/src/utils/server.ts @@ -1,4 +1,5 @@ import { createOpencodeClient } from "@opencode-ai/sdk/v2/client" +import { OpenCode, type OpenCodeClient } from "@opencode-ai/client/promise" import type { ServerConnection } from "@/context/server" import { decode64 } from "@/utils/base64" @@ -39,3 +40,23 @@ export function createSdkForServer({ baseUrl: server.url, }) } + +export function createApiForServer(input: { + server: ServerConnection.HttpBase + fetch?: typeof globalThis.fetch +}): OpenCodeClient { + return OpenCode.make({ + baseUrl: input.server.url, + fetch: input.fetch, + headers: input.server.password + ? { + Authorization: `Basic ${authTokenFromCredentials({ + username: input.server.username, + password: input.server.password, + })}`, + } + : undefined, + }) +} + +export type ServerApi = OpenCodeClient diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index b213dbc82a..0f2d9d6ad1 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -182,9 +182,9 @@ export async function spawnLocalServer( } export async function checkHealth(url: string, password?: string | null): Promise { - let healthUrl: URL + let healthUrls: URL[] try { - healthUrl = new URL("/global/health", url) + healthUrls = [new URL("/api/health", url), new URL("/global/health", url)] } catch { return false } @@ -195,16 +195,17 @@ export async function checkHealth(url: string, password?: string | null): Promis headers.set("authorization", `Basic ${auth}`) } - try { - const res = await fetch(healthUrl, { - method: "GET", - headers, - signal: AbortSignal.timeout(3000), - }) - return res.ok - } catch { - return false + for (const healthUrl of healthUrls) { + try { + const res = await fetch(healthUrl, { + method: "GET", + headers, + signal: AbortSignal.timeout(3000), + }) + if (res.ok) return true + } catch {} } + return false } function createSidecarEnv(): Record { From 347510a73b3ed5fa98504dd7122c15ea16c2d340 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 08:01:01 +0000 Subject: [PATCH 12/48] chore: generate --- packages/app/src/utils/server-compat.test.ts | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index eca83effa0..df86e76bfa 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -21,7 +21,7 @@ function setup(protocol: "v1" | "v2") { } if (request.method === "POST" && request.url.endsWith("/prompt_async")) return new Response(undefined, { status: 204 }) - if (request.method === "POST" && request.url.endsWith("/prompt")) { + if (request.method === "POST" && request.url.endsWith("/prompt")) { return Response.json({ admittedSeq: 1, id: "msg_1", @@ -30,10 +30,10 @@ function setup(protocol: "v1" | "v2") { type: "user", data: { text: "hello" }, delivery: "steer", - }) - } - if (request.method === "GET") return Response.json([]) - return new Response(undefined, { status: 204 }) + }) + } + if (request.method === "GET") return Response.json([]) + return new Response(undefined, { status: 204 }) }, { preconnect: globalThis.fetch.preconnect }, ) From e59ba24b801b41d7bb0cabe868c496c61e8ad8c6 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Thu, 23 Jul 2026 17:19:44 +0800 Subject: [PATCH 13/48] feat(app): support current event transport (#38464) --- .../performance/timeline-stability/fixture.ts | 2 + .../session-timeline-transport.spec.ts | 6 +- packages/app/e2e/utils/mock-server.ts | 257 +++++++++++++++++- packages/app/e2e/utils/sse-transport.ts | 37 ++- packages/app/src/context/server-sdk.test.ts | 37 ++- packages/app/src/context/server-sdk.tsx | 194 +++++++++---- packages/app/src/utils/server-compat.test.ts | 22 +- packages/app/src/utils/server-compat.ts | 93 ++++--- 8 files changed, 527 insertions(+), 121 deletions(-) diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index 5095d95db0..df67da5a66 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -97,6 +97,7 @@ export async function setupTimeline( locale?: string deviceScaleFactor?: number seedHistory?: boolean + protocol?: "v1" | "v2" } = {}, ) { const sessions = input.sessions ?? [session()] @@ -114,6 +115,7 @@ export async function setupTimeline( retry: input.eventRetry ?? 20, }) await mockOpenCodeServer(page, { + protocol: input.protocol, directory, project: project(), provider: provider(), diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 850e966d0b..778ff3a3af 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -89,8 +89,8 @@ test("reconnects after a stream error", async ({ page }) => { expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) -test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) +test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { + const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -100,7 +100,7 @@ test("records event IDs and reconnect Last-Event-ID headers", async ({ page }) = const connection = await timeline.transport.waitForConnection({ after: first.connectionID }) expect(first.eventID).toBe("timeline-event-7") - expect(connection.headers["last-event-id"]).toBe("timeline-event-7") + expect(connection.headers["last-event-id"]).toBeUndefined() }) test("passes through non-event fetches", async ({ page }) => { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 2bfba5871a..5a7f8351ca 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -4,6 +4,7 @@ const emptyList = new Set(["/skill", "/command", "/lsp", "/formatter", "/vcs/sta const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mcp", "/experimental/resource"]) export interface MockServerConfig { + protocol?: "v1" | "v2" provider: unknown directory: string project: unknown @@ -54,14 +55,21 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (url.port !== targetPort && url.port !== appPort) return route.fallback() const path = url.pathname - if (path === "/global/event" || path === "/event") return sse(route, config.events?.(), config.eventRetry) - if (path === "/global/health") return json(route, { healthy: true }) - if (path === "/api/session") - return json(route, { - data: config.sessions.map((session) => v2Session(session, config.directory)), - cursor: {}, - }) - if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: false }) + if (path === "/global/event" || path === "/event" || path === "/api/event") { + const events = config.events?.() + return sse( + route, + path === "/api/event" + ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] + : events, + config.eventRetry, + ) + } + if (path === "/global/health") + return config.protocol === "v2" ? json(route, {}, undefined, 404) : json(route, { healthy: true }) + if (path === "/api/health" && config.protocol === "v2") + return json(route, { healthy: true, version: "2.0.0", pid: 1 }) + if (path === "/experimental/capabilities") return json(route, { backgroundSubagents: true }) if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") @@ -89,10 +97,122 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }, data: [], }) + if (path === "/api/agent") + return json(route, { + location: location(config), + data: [ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + request: { settings: {}, headers: {}, body: {} }, + permissions: [], + }, + ], + }) + if (path === "/api/command") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp") return json(route, { location: location(config), data: [] }) + if (path === "/api/mcp/resource") + return json(route, { location: location(config), data: { resources: [], templates: [] } }) + const integration = path.match(/^\/api\/integration\/([^/]+)$/)?.[1] + if (integration && route.request().method() === "GET") + return json(route, { + location: location(config), + data: { id: integration, name: integration, methods: [{ type: "key", label: "API key" }], connections: [] }, + }) + if (/^\/api\/integration\/[^/]+\/connect\/key$/.test(path) && route.request().method() === "POST") + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + if (path === "/api/project") return json(route, [config.project]) + if (path === "/api/project/current") + return json(route, { id: (config.project as { id?: string }).id, directory: config.directory }) + if (path.startsWith("/api/project/") && route.request().method() === "PATCH") return json(route, config.project) + if (path === "/api/path") + return json(route, { + state: config.directory, + config: config.directory, + worktree: config.directory, + directory: config.directory, + home: "C:/OpenCode", + }) + if (path === "/api/permission/request") + return json(route, { + location: location(config), + data: (typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])).map( + currentPermission, + ), + }) + if (path === "/api/question/request") + return json(route, { + location: location(config), + data: typeof config.questions === "function" ? config.questions() : (config.questions ?? []), + }) + if (path === "/api/vcs") + return json(route, { location: location(config), data: { branch: "main", defaultBranch: "main" } }) + if (path === "/api/vcs/status") return json(route, { location: location(config), data: [] }) + if (path === "/api/vcs/diff") return json(route, { location: location(config), data: config.vcsDiff ?? [] }) + if (path === "/api/pty/shells") return json(route, { location: location(config), data: [] }) + if (/^\/api\/pty\/[^/]+\/connect-token$/.test(path)) + return json(route, { location: location(config), data: { ticket: "e2e-ticket", expires_in: 60 } }) if (emptyObject.has(path)) return json(route, {}) if (emptyList.has(path)) return json(route, []) + if (path === "/api/session") { + const directory = url.searchParams.get("directory") + const parentID = url.searchParams.get("parentID") + const limit = Number(url.searchParams.get("limit") ?? 50) + const offset = Number(url.searchParams.get("cursor") ?? 0) + const sessions = config.sessions + .filter((session) => !directory || session.directory === directory) + .filter((session) => parentID !== "null" || session.parentID === undefined) + .filter((session) => { + const search = url.searchParams.get("search")?.toLowerCase() + return !search || String(session.title ?? "").toLowerCase().includes(search) + }) + const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions + const data = ordered.slice(offset, offset + limit) + const next = offset + limit < ordered.length ? String(offset + limit) : undefined + return json(route, { + data: data.map((session) => currentSession(session, config.directory)), + cursor: { next }, + }) + } + if (path === "/api/session/active") { + const statuses = (config.sessionStatus ?? {}) as Record + return json(route, { + data: Object.fromEntries( + Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])), + ), + }) + } + if (/^\/api\/session\/[^/]+\/shell$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if ( + /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && + route.request().method() === "POST" + ) { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } + if (/^\/api\/session\/[^/]+$/.test(path) && route.request().method() === "DELETE") { + return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) + } if (path in staticRoutes) return json(route, staticRoutes[path]) + const currentSessionMatch = path.match(/^\/api\/session\/([^/]+)$/) + if (currentSessionMatch) { + const session = config.sessions.find((item) => item.id === currentSessionMatch[1]) + if (!session) return json(route, { error: "Session not found" }, undefined, 404) + return json(route, { + data: currentSession(session, config.directory), + }) + } + const sessionMatch = path.match(/^\/session\/([^/]+)$/) if (sessionMatch) { const session = config.sessions.find((s) => s.id === sessionMatch[1]) @@ -115,6 +235,24 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (todoMatch) return json(route, config.todos?.(todoMatch[1]!) ?? []) if (/^\/session\/[^/]+\/(children|diff)$/.test(path)) return json(route, []) + const currentMessagesMatch = path.match(/^\/api\/session\/([^/]+)\/message$/) + if (currentMessagesMatch) { + const token = url.searchParams.get("cursor") ?? undefined + const before = token ? cursors.get(token) : undefined + if (token && !before) return json(route, { error: "Invalid cursor" }, undefined, 400) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "start" }) + await config.beforeMessagesResponse?.({ sessionID: currentMessagesMatch[1]!, before }) + if (config.messageDelay !== undefined) await new Promise((resolve) => setTimeout(resolve, config.messageDelay)) + const pageData = config.pageMessages(currentMessagesMatch[1], Number(url.searchParams.get("limit") ?? 50), before) + config.onMessages?.({ sessionID: currentMessagesMatch[1], before, phase: "end" }) + const cursor = pageData.cursor ? `cursor_${++nextCursor}` : undefined + if (cursor) cursors.set(cursor, pageData.cursor!) + return json(route, { + data: pageData.items.map(currentMessage).reverse(), + cursor: { next: cursor }, + }) + } + const messagesMatch = path.match(/^\/session\/([^/]+)\/message$/) if (messagesMatch) { const token = url.searchParams.get("before") ?? undefined @@ -137,12 +275,36 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { }) } -function v2Session(session: { id: string } & Record, fallbackDirectory: string) { +function location(config: MockServerConfig) { + return { + directory: config.directory, + project: { id: (config.project as { id?: string }).id, directory: config.directory }, + } +} + +function currentPermission(value: unknown) { + const permission = value as Record + if (permission.action) return permission + const tool = permission.tool as { messageID?: string; callID?: string } | undefined + return { + id: permission.id, + sessionID: permission.sessionID, + action: permission.permission, + resources: permission.patterns ?? [], + save: permission.always, + metadata: permission.metadata, + source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + } +} + +export function currentSession(session: { id: string } & Record, fallbackDirectory?: string) { const time = session.time && typeof session.time === "object" ? session.time : {} return { id: session.id, parentID: session.parentID, projectID: session.projectID ?? "project", + agent: session.agent ?? "build", + model: session.model ?? { id: "mock-model", providerID: "mock-provider" }, cost: session.cost ?? 0, tokens: session.tokens ?? { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, time: { @@ -157,7 +319,67 @@ function v2Session(session: { id: string } & Record, fallbackDi directory: typeof session.directory === "string" ? session.directory : fallbackDirectory, ...(typeof session.workspaceID === "string" ? { workspaceID: session.workspaceID } : {}), }, - ...(typeof session.path === "string" ? { subpath: session.path } : {}), + subpath: session.path, + revert: session.revert, + } +} + +function currentMessage(value: unknown) { + const item = value as { + info: Record & { id: string; role: "user" | "assistant"; time: { created: number } } + parts: Array & { type: string }> + } + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user", + time: item.info.time, + text: item.parts + .flatMap((part) => (part.type === "text" && typeof part.text === "string" ? [part.text] : [])) + .join("\n"), + } + } + return { + id: item.info.id, + type: "assistant", + time: item.info.time, + agent: item.info.agent ?? "build", + model: { id: item.info.modelID ?? "model", providerID: item.info.providerID ?? "provider" }, + cost: item.info.cost, + tokens: item.info.tokens, + error: item.info.error, + content: item.parts.flatMap((part) => { + if (part.type === "text" || part.type === "reasoning") return [{ type: part.type, text: part.text ?? "" }] + if (part.type !== "tool") return [] + const state = part.state as Record + return [ + { + type: "tool", + id: part.id, + name: part.tool, + time: state.time ?? { created: item.info.time.created }, + state: + state.status === "pending" + ? { status: "streaming", input: state.raw ?? JSON.stringify(state.input ?? {}) } + : state.status === "completed" + ? { + status: "completed", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [{ type: "text", text: state.output ?? "" }], + } + : state.status === "error" + ? { + status: "error", + input: state.input ?? {}, + structured: state.metadata ?? {}, + content: [], + error: { type: "ToolError", message: state.error ?? "Tool failed" }, + } + : { status: "running", input: state.input ?? {}, structured: state.metadata ?? {}, content: [] }, + }, + ] + }), } } @@ -181,3 +403,18 @@ function sse(route: Route, events?: unknown[], retry?: number) { body: `${retry === undefined ? "" : `retry: ${retry}\n\n`}${events?.map((event) => `data: ${JSON.stringify(event)}\n\n`).join("") || ": ok\n\n"}`, }) } + +function currentEvent(input: unknown) { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 55420485f3..66686ac259 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -3,7 +3,7 @@ import type { Page } from "@playwright/test" export type SseConnectionRecord = { id: number url: string - path: "/global/event" | "/event" + path: "/global/event" | "/event" | "/api/event" headers: Record openedAt: number endedAt?: number @@ -93,6 +93,20 @@ export async function installSseTransport( eventOptions.retry === undefined ? "" : `retry: ${eventOptions.retry}\n`, `data: ${JSON.stringify(payload)}\n\n`, ].join("") + const currentEvent = (input: unknown) => { + if (!input || typeof input !== "object" || !("payload" in input)) return input + const envelope = input as { directory?: string; payload?: unknown } + if (!envelope.payload || typeof envelope.payload !== "object") return input + const payload = envelope.payload as { id?: string; type?: string; properties?: unknown } + if (!payload.type) return input + return { + id: payload.id ?? `evt_mock_${Date.now()}`, + created: Date.now(), + type: payload.type, + data: payload.properties ?? {}, + location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + } + } const acknowledge = ( connection: Connection, bytes: number, @@ -140,14 +154,14 @@ export async function installSseTransport( output.forEach((chunk) => connection.controller.enqueue(chunk)) return acknowledge(connection, input.bytes.length, output.length) } - const encoded = input.deliveries.map((delivery) => ({ - delivery, - bytes: encoder.encode(frame(delivery.payload, delivery.options)), - })) + const encoded = input.deliveries.map((delivery) => { + const payload = connection.path === "/api/event" ? currentEvent(delivery.payload) : delivery.payload + return { delivery, payload, bytes: encoder.encode(frame(payload, delivery.options)) } + }) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { const bytes = encoder.encode( - encoded.map((item) => frame(item.delivery.payload, item.delivery.options)).join(""), + encoded.map((item) => frame(item.payload, item.delivery.options)).join(""), ) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) @@ -161,7 +175,10 @@ export async function installSseTransport( const fetch = (input: RequestInfo | URL, init?: RequestInit) => { const request = new Request(input, init) const url = new URL(request.url) - if (url.origin !== server || (url.pathname !== "/global/event" && url.pathname !== "/event")) + if ( + url.origin !== server || + (url.pathname !== "/global/event" && url.pathname !== "/event" && url.pathname !== "/api/event") + ) return originalFetch(request) const id = ++nextConnectionID @@ -177,6 +194,12 @@ export async function installSseTransport( record.controller = controller connections.push(record) if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) + if (url.pathname === "/api/event") + controller.enqueue( + encoder.encode( + frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }), + ), + ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 7b592178fa..1c17a6b9de 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import { coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import { adaptServerEvent, coalesceServerEvents, enqueueServerEvent, resumeStreamAfterPageShow } from "./server-sdk" +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" describe("resumeStreamAfterPageShow", () => { @@ -14,6 +15,23 @@ describe("resumeStreamAfterPageShow", () => { }) }) +describe("adaptServerEvent", () => { + test("preserves V2 events while adapting permission requests for existing consumers", () => { + const current = { + id: "evt_1", + created: 1, + type: "permission.v2.asked", + data: { id: "perm_1", sessionID: "ses_1", action: "read", resources: ["src/**"] }, + } as OpenCodeEvent + + expect(adaptServerEvent(current)).toMatchObject({ + type: "permission.asked", + properties: { id: "perm_1", sessionID: "ses_1", permission: "read", patterns: ["src/**"] }, + current, + }) + }) +}) + describe("coalesceServerEvents", () => { const delta = (value: string, field = "text", partID = "part") => ({ directory: "/repo", @@ -34,6 +52,23 @@ describe("coalesceServerEvents", () => { expect(result[0]?.payload).toMatchObject({ id: "second", properties: { delta: "hello world" } }) }) + test("merges adjacent current text deltas", () => { + const current = (id: string, value: string) => adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) + const result = coalesceServerEvents([ + { directory: "/repo", payload: current("evt_1", "hello ") }, + { directory: "/repo", payload: current("evt_2", "world") }, + ]) + + expect(result).toHaveLength(1) + expect(result[0]?.payload.current).toMatchObject({ id: "evt_2", data: { delta: "hello world" } }) + }) + test("preserves event boundaries and distinct fields", () => { const status = { directory: "/repo", diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 06597e56e7..62c5857794 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -1,21 +1,60 @@ +import type { OpenCodeEvent } from "@opencode-ai/client/promise" import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" -import { createSdkForServer } from "@/utils/server" +import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" import { ServerConnection, useServer } from "./server" import { createRefCountMap } from "@/utils/refcount" import { useGlobal } from "./global" import { ServerScope } from "@/utils/server-scope" +import { detectServerProtocol, type ServerProtocol } from "@/utils/server-protocol" +import { createCompatibleApi, type CompatibleApi } from "@/utils/server-compat" const isAbortError = (error: unknown) => error !== null && typeof error === "object" && "name" in error && error.name === "AbortError" const isStreamClosed = (error: unknown, signal?: AbortSignal) => isAbortError(error) || signal?.aborted === true -type QueuedServerEvent = { directory: string; payload: Event } +export type ServerEvent = Event & { current?: OpenCodeEvent } +type QueuedServerEvent = { directory: string; payload: ServerEvent } +type CurrentDelta = Extract< + OpenCodeEvent, + { type: "session.text.delta" | "session.reasoning.delta" | "session.tool.input.delta" | "session.compaction.delta" } +> + +export function adaptServerEvent(event: OpenCodeEvent): ServerEvent { + if (event.type === "permission.v2.asked") { + return { + id: event.id, + type: "permission.asked", + properties: { + id: event.data.id, + sessionID: event.data.sessionID, + permission: event.data.action, + patterns: event.data.resources, + always: event.data.save ?? [], + metadata: event.data.metadata ?? {}, + tool: + event.data.source?.type === "tool" + ? { messageID: event.data.source.messageID, callID: event.data.source.callID } + : undefined, + }, + current: event, + } as ServerEvent + } + if (event.type === "permission.v2.replied") + return { id: event.id, type: "permission.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.asked") + return { id: event.id, type: "question.asked", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.replied") + return { id: event.id, type: "question.replied", properties: event.data, current: event } as ServerEvent + if (event.type === "question.v2.rejected") + return { id: event.id, type: "question.rejected", properties: event.data, current: event } as ServerEvent + return { id: event.id, type: event.type, properties: event.data, current: event } as ServerEvent +} const coalescedKey = (event: QueuedServerEvent) => { if (event.payload.type === "lsp.updated") return `lsp.updated:${event.directory}` @@ -40,6 +79,34 @@ export function enqueueServerEvent(queue: QueuedServerEvent[], event: QueuedServ export function coalesceServerEvents(events: QueuedServerEvent[]) { const output: QueuedServerEvent[] = [] events.forEach((event) => { + const current = currentDelta(event.payload.current) + if (current) { + const previous = output[output.length - 1] + const prior = currentDelta(previous?.payload.current) + if ( + previous && + prior && + previous.directory === event.directory && + currentDeltaKey(prior) === currentDeltaKey(current) + ) { + const fragment = currentDeltaFragment(prior) + currentDeltaFragment(current) + const data = + current.type === "session.compaction.delta" + ? { ...current.data, text: fragment } + : { ...current.data, delta: fragment } + output[output.length - 1] = { + directory: event.directory, + payload: { + ...event.payload, + properties: data, + current: { ...current, data } as CurrentDelta, + } as ServerEvent, + } + return + } + output.push(event) + return + } if (event.payload.type !== "message.part.delta") { output.push(event) return @@ -71,12 +138,52 @@ export function coalesceServerEvents(events: QueuedServerEvent[]) { return output } +function currentDelta(event: OpenCodeEvent | undefined): CurrentDelta | undefined { + if ( + event?.type === "session.text.delta" || + event?.type === "session.reasoning.delta" || + event?.type === "session.tool.input.delta" || + event?.type === "session.compaction.delta" + ) + return event +} + +function currentDeltaKey(event: CurrentDelta) { + if (event.type === "session.tool.input.delta") + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.callID}` + if (event.type === "session.compaction.delta") return `${event.type}:${event.data.sessionID}` + return `${event.type}:${event.data.sessionID}:${event.data.assistantMessageID}:${event.data.ordinal}` +} + +function currentDeltaFragment(event: CurrentDelta) { + return event.type === "session.compaction.delta" ? event.data.text : event.data.delta +} + export function resumeStreamAfterPageShow(event: PageTransitionEvent, start: () => unknown) { if (!event.persisted) return start() } -function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope) { +type ServerEventEmitter = ReturnType> +type ServerSDKBase = { + server: ServerConnection.Any + scope: ServerScope + protocol: Promise + url: string + client: ReturnType + api: CompatibleApi + currentApi: ServerApi + event: { + on: ServerEventEmitter["on"] + listen: ServerEventEmitter["listen"] + start: () => Promise | undefined + } + createClient: ( + opts: Omit[0], "server" | "fetch">, + ) => ReturnType +} + +function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerScope): ServerSDKBase { const platform = usePlatform() const abort = new AbortController() @@ -91,13 +198,15 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } })() + const eventApi = createApiForServer({ server: server.http, fetch: eventFetch }) const eventSdk = createSdkForServer({ signal: abort.signal, fetch: eventFetch, server: server.http, }) + const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) const emitter = createGlobalEmitter<{ - [key: string]: Event + [key: string]: ServerEvent }>() type Queued = QueuedServerEvent @@ -142,21 +251,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS let run: Promise | undefined let started = false let generation = 0 - const HEARTBEAT_TIMEOUT_MS = 15_000 - let lastEventAt = Date.now() - let heartbeat: ReturnType | undefined - const resetHeartbeat = () => { - lastEventAt = Date.now() - if (heartbeat) clearTimeout(heartbeat) - heartbeat = setTimeout(() => { - attempt?.abort() - }, HEARTBEAT_TIMEOUT_MS) - } - const clearHeartbeat = () => { - if (!heartbeat) return - clearTimeout(heartbeat) - heartbeat = undefined - } const start = () => { if (started) return run @@ -168,35 +262,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS // oxlint-disable-next-line no-unmodified-loop-condition -- `started` is set to false by stop() which also aborts; both flags are checked to allow graceful exit while (!abort.signal.aborted && started && generation === active) { attempt = new AbortController() - lastEventAt = Date.now() const onAbort = () => { attempt?.abort() } abort.signal.addEventListener("abort", onAbort) try { - const events = await eventSdk.global.event({ - signal: attempt.signal, - onSseError: (error) => { - if (isStreamClosed(error, attempt?.signal)) return - if (streamErrorLogged) return - streamErrorLogged = true - console.error("[global-sdk] event stream error", { - url: server.http.url, - fetch: eventFetch ? "platform" : "webview", - error, - }) - }, - }) + const kind = await protocol + const events = + kind === "v1" + ? (await eventSdk.global.event({ signal: attempt.signal })).stream + : eventApi.event.subscribe({ signal: attempt.signal }) let yielded = Date.now() - resetHeartbeat() - for await (const event of events.stream) { - resetHeartbeat() + for await (const event of events) { streamErrorLogged = false - if (event.payload.type !== "sync") { - const directory = event.directory ?? "global" - const payload = event.payload as Event - if (enqueueServerEvent(queue, { directory, payload })) schedule() - } + const legacy = "payload" in event + if (legacy && event.payload.type === "sync") continue + const directory = legacy ? (event.directory ?? "global") : (event.location?.directory ?? "global") + const payload = legacy ? (event.payload as Event) : adaptServerEvent(event) + if (enqueueServerEvent(queue, { directory, payload })) schedule() if (Date.now() - yielded < STREAM_YIELD_MS) continue yielded = Date.now() @@ -214,7 +297,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } finally { abort.signal.removeEventListener("abort", onAbort) attempt = undefined - clearHeartbeat() } if (abort.signal.aborted || !started || generation !== active) return @@ -233,18 +315,11 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS started = false generation++ attempt?.abort() - clearHeartbeat() } onMount(() => { makeEventListener(window, "pagehide", stop) makeEventListener(window, "pageshow", (event) => resumeStreamAfterPageShow(event, start)) - makeEventListener(document, "visibilitychange", () => { - if (document.visibilityState !== "visible") return - if (!started) return - if (Date.now() - lastEventAt < HEARTBEAT_TIMEOUT_MS) return - attempt?.abort() - }) }) onCleanup(() => { @@ -258,12 +333,24 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS fetch: platform.fetch, throwOnError: true, }) + const currentApi: ServerApi = createApiForServer({ server: server.http, fetch: platform.fetch }) + const legacy = (directory?: string) => + createSdkForServer({ + server: server.http, + fetch: platform.fetch, + throwOnError: true, + directory, + }) + const api = createCompatibleApi({ protocol, current: currentApi, legacy }) return { server, scope, + protocol, url: server.http.url, client: sdk, + api, + currentApi, event: { on: emitter.on.bind(emitter), listen: emitter.listen.bind(emitter), @@ -279,7 +366,6 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS } } -type ServerSDKBase = ReturnType export type ServerSDK = ServerSDKBase & { ensureDirSdkContext: (directory: string) => ReturnType } @@ -309,7 +395,7 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }) type SDKEventMap = { - [key in Event["type"]]: Extract + [key in Event["type"]]: Extract } function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { @@ -329,6 +415,12 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { scope: serverSDK.scope, directory, client, + api: createCompatibleApi({ + protocol: serverSDK.protocol, + current: serverSDK.currentApi, + legacy: (next) => serverSDK.createClient({ directory: next ?? directory, throwOnError: true }), + directory, + }), event: emitter, get url() { return serverSDK.url diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index df86e76bfa..4907eb41eb 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { createApiForServer, createSdkForServer } from "./server" import { createCompatibleApi } from "./server-compat" -function setup(protocol: "v1" | "v2") { +function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) { const requests: Request[] = [] const fetcher = Object.assign( async (input: string | URL | Request, init?: RequestInit) => { @@ -39,7 +39,7 @@ function setup(protocol: "v1" | "v2") { ) const server = { url: "http://localhost:4096" } const api = createCompatibleApi({ - protocol: Promise.resolve(protocol), + protocol: typeof protocol === "string" ? Promise.resolve(protocol) : protocol, current: createApiForServer({ server, fetch: fetcher }), legacy: (directory) => createSdkForServer({ server, fetch: fetcher, directory, throwOnError: true }), directory: "/repo", @@ -86,6 +86,24 @@ describe("createCompatibleApi", () => { expect(requests[0]!.method).toBe("POST") }) + test("resolves protocol detection once across implementation methods", async () => { + let detections = 0 + const resolved = Promise.resolve<"v1" | "v2">("v2") + const protocol = new Proxy(resolved, { + get(target, property) { + if (property !== "then") return Reflect.get(target, property, target) + detections++ + return target.then.bind(target) + }, + }) + const { api } = setup(protocol) + + await api.session.archive({ sessionID: "ses_1" }) + await api.session.list() + + expect(detections).toBe(1) + }) + test("uses the global V1 session search endpoint", async () => { const { api, requests } = setup("v1") await api.session.list({ parentID: null, search: "session", limit: 50 }) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 95854d03b2..1772516900 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -37,6 +37,12 @@ type LegacyPrompt = { variant?: string } type LegacyLocation = { directory?: string } +type CompatibleInput = { + protocol: Promise + current: ServerApi + legacy: LegacyFor + directory?: string +} function mime(uri: string) { const match = /^data:([^;,]+)/.exec(uri) @@ -68,15 +74,48 @@ function sessionInfo(session: Session): SessionInfo { } } -export function createCompatibleApi(input: { - protocol: Promise - current: ServerApi - legacy: LegacyFor - directory?: string -}): CompatibleApi { +export function createCompatibleApi(input: CompatibleInput): CompatibleApi { + const v1 = createV1Api(input) + return lazyApi( + input.protocol.then((protocol) => (protocol === "v1" ? v1 : input.current)), + input.current, + ) +} + +function lazyApi(implementation: Promise, shape: T): T { + const cache = new Map() + return new Proxy(shape, { + get(target, property, receiver) { + const sample = Reflect.get(target, property, receiver) + if (typeof sample === "function") { + return (...args: unknown[]) => + implementation.then((value) => { + const method = Reflect.get(value, property) + if (typeof method !== "function") throw new Error(`API method unavailable: ${String(property)}`) + return Reflect.apply(method, value, args) + }) + } + if (sample === null || typeof sample !== "object") return sample + if (cache.has(property)) return cache.get(property) + const nested = lazyApi( + implementation.then((value) => { + const result = Reflect.get(value, property) + if (result === null || typeof result !== "object") { + throw new Error(`API namespace unavailable: ${String(property)}`) + } + return result + }), + sample, + ) + cache.set(property, nested) + return nested + }, + }) +} + +function createV1Api(input: CompatibleInput): CompatibleApi { const directory = (location?: { directory?: string }) => location?.directory ?? input.directory const legacy = (location?: { directory?: string }) => input.legacy(directory(location)) - const isV1 = () => input.protocol.then((protocol) => protocol === "v1") const located = (data: T, value?: { directory?: string }) => ({ location: { directory: directory(value) ?? "", @@ -93,7 +132,6 @@ export function createCompatibleApi(input: { value?: Parameters[0], options?: Parameters[1], ) { - if (!(await isV1())) return input.current.session.list(value, options) if (!value?.directory && value?.search !== undefined) { const result = await legacy().experimental.session.list( { @@ -114,7 +152,6 @@ export function createCompatibleApi(input: { return { data: (result.data ?? []).map(sessionInfo), cursor: {} } }, async create(value?: Parameters[0]) { - if (!(await isV1())) return input.current.session.create(value) const result = await legacy(value?.location ?? undefined).session.create({ directory: directory(value?.location ?? undefined), }) @@ -122,13 +159,11 @@ export function createCompatibleApi(input: { return sessionInfo(result.data) }, async get(value: Parameters[0]) { - if (!(await isV1())) return input.current.session.get(value) const result = await legacy().session.get(value) if (!result.data) throw new Error(`Session not found: ${value.sessionID}`) return sessionInfo(result.data) }, async active() { - if (!(await isV1())) return input.current.session.active() const result = await legacy().session.status() return Object.fromEntries( Object.entries(result.data ?? {}).flatMap(([sessionID, status]) => @@ -137,29 +172,23 @@ export function createCompatibleApi(input: { ) }, async rename(value: Parameters[0] & LegacyLocation) { - if (!(await isV1())) return input.current.session.rename(value) await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) }, async archive(value: Parameters[0] & LegacyLocation) { - if (!(await isV1())) return input.current.session.archive(value) await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) }, async remove(value: Parameters[0] & LegacyLocation) { - if (!(await isV1())) return input.current.session.remove(value) await legacy(value).session.delete(value) }, async fork(value: Parameters[0]) { - if (!(await isV1())) return input.current.session.fork(value) const result = await legacy().session.fork(value) if (!result.data) throw new Error("Failed to fork session") return sessionInfo(result.data) }, async interrupt(value: Parameters[0]) { - if (!(await isV1())) return input.current.session.interrupt(value) await legacy().session.abort(value) }, async prompt(value: SessionPromptInput & LegacyPrompt) { - if (!(await isV1())) return input.current.session.prompt(value) await legacy().session.promptAsync({ sessionID: value.sessionID, messageID: value.id ?? undefined, @@ -194,7 +223,6 @@ export function createCompatibleApi(input: { } }, async command(value: SessionCommandInput) { - if (!(await isV1())) return input.current.session.command(value) await legacy().session.command({ sessionID: value.sessionID, messageID: value.id ?? undefined, @@ -221,7 +249,6 @@ export function createCompatibleApi(input: { } }, async shell(value: SessionShellInput & LegacyPrompt) { - if (!(await isV1())) return input.current.session.shell(value) await legacy().session.shell({ sessionID: value.sessionID, command: value.command, @@ -230,7 +257,6 @@ export function createCompatibleApi(input: { }) }, compact: async (value: SessionCompactInput & { model?: LegacyPrompt["model"] }) => { - if (!(await isV1())) return input.current.session.compact(value) if (!value.model) throw new Error("A model is required to compact a V1 session") await legacy().session.summarize({ sessionID: value.sessionID, @@ -247,12 +273,10 @@ export function createCompatibleApi(input: { }, revert: { stage: async (value: Parameters[0]) => { - if (!(await isV1())) return input.current.session.revert.stage(value) await legacy().session.revert(value) return { messageID: value.messageID } }, clear: async (value: Parameters[0]) => { - if (!(await isV1())) return input.current.session.revert.clear(value) await legacy().session.unrevert(value) }, commit: input.current.session.revert.commit, @@ -261,17 +285,14 @@ export function createCompatibleApi(input: { project: { ...input.current.project, async list() { - if (!(await isV1())) return input.current.project.list() return ((await legacy().project.list()).data ?? []) as Project[] }, async current(value?: Parameters[0]) { - if (!(await isV1())) return input.current.project.current(value) const result = await legacy(value?.location).project.current() if (!result.data) throw new Error("Project not found") return { id: result.data.id, directory: result.data.worktree } satisfies ProjectCurrent }, async update(value: Parameters[0]) { - if (!(await isV1())) return input.current.project.update(value) const project = (await legacy().project.list()).data?.find((item) => item.id === value.projectID) const result = await legacy({ directory: project?.worktree }).project.update({ ...value, @@ -281,7 +302,6 @@ export function createCompatibleApi(input: { return result.data as Project }, async directories(value: Parameters[0]) { - if (!(await isV1())) return input.current.project.directories(value) const result = await legacy(value.location).worktree.list() return (result.data ?? []).map((item) => ({ directory: item })) }, @@ -289,7 +309,6 @@ export function createCompatibleApi(input: { path: { ...input.current.path, async get(value?: Parameters[0]) { - if (!(await isV1())) return input.current.path.get(value) const result = await legacy(value?.location).path.get() if (!result.data) throw new Error("Path unavailable") return result.data @@ -298,17 +317,14 @@ export function createCompatibleApi(input: { vcs: { ...input.current.vcs, async get(value?: Parameters[0]) { - if (!(await isV1())) return input.current.vcs.get(value) const result = await legacy(value?.location).vcs.get() return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) }, async status(value?: Parameters[0]) { - if (!(await isV1())) return input.current.vcs.status(value) const result = await legacy(value?.location).vcs.status() return located(result.data ?? [], value?.location) }, async diff(value: Parameters[0]) { - if (!(await isV1())) return input.current.vcs.diff(value) const result = await legacy(value.location).vcs.diff({ mode: value.mode === "working" ? "git" : value.mode, context: value.context, @@ -328,12 +344,10 @@ export function createCompatibleApi(input: { file: { ...input.current.file, async list(value?: Parameters[0]) { - if (!(await isV1())) return input.current.file.list(value) const result = await legacy(value?.location).file.list({ path: value?.path ?? "" }) return located(result.data ?? [], value?.location) }, async find(value: Parameters[0]) { - if (!(await isV1())) return input.current.file.find(value) const result = await legacy(value.location).find.files({ query: value.query, type: value.type, @@ -348,7 +362,6 @@ export function createCompatibleApi(input: { integration: { ...input.current.integration, async get(value: Parameters[0]) { - if (!(await isV1())) return input.current.integration.get(value) const methods = ((await legacy(value.location).provider.auth()).data?.[value.integrationID] ?? []).map( (method, index) => method.type === "api" @@ -368,7 +381,6 @@ export function createCompatibleApi(input: { connect: { ...input.current.integration.connect, key: async (value: Parameters[0]) => { - if (!(await isV1())) return input.current.integration.connect.key(value) await legacy(value.location).auth.set({ providerID: value.integrationID, auth: { type: "api", key: value.key }, @@ -378,7 +390,6 @@ export function createCompatibleApi(input: { oauth: { ...input.current.integration.oauth, connect: async (value: Parameters[0]) => { - if (!(await isV1())) return input.current.integration.oauth.connect(value) const method = Number(value.methodID) const result = await legacy(value.location).provider.oauth.authorize( { providerID: value.integrationID, method, inputs: value.inputs }, @@ -397,7 +408,6 @@ export function createCompatibleApi(input: { ) }, complete: async (value: Parameters[0]) => { - if (!(await isV1())) return input.current.integration.oauth.complete(value) const method = Number(value.attemptID.split(":").at(-1)) await legacy(value.location).provider.oauth.callback( { providerID: value.integrationID, method, code: value.code }, @@ -405,7 +415,6 @@ export function createCompatibleApi(input: { ) }, status: async (value: Parameters[0]) => { - if (!(await isV1())) return input.current.integration.oauth.status(value) const method = Number(value.attemptID.split(":").at(-1)) await legacy(value.location).provider.oauth.callback( { providerID: value.integrationID, method }, @@ -421,15 +430,12 @@ export function createCompatibleApi(input: { pty: { ...input.current.pty, async shells(value?: Parameters[0]) { - if (!(await isV1())) return input.current.pty.shells(value) return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) }, async list(value?: Parameters[0]) { - if (!(await isV1())) return input.current.pty.list(value) return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) }, async create(value?: Parameters[0]) { - if (!(await isV1())) return input.current.pty.create(value) const result = await legacy(value?.location).pty.create({ command: value?.command, args: value?.args ? [...value.args] : undefined, @@ -441,13 +447,11 @@ export function createCompatibleApi(input: { return located(result.data, value?.location) }, async get(value: Parameters[0]) { - if (!(await isV1())) return input.current.pty.get(value) const result = await legacy(value.location).pty.get({ ptyID: value.ptyID }) if (!result.data) throw new Error(`Terminal not found: ${value.ptyID}`) return located(result.data, value.location) }, async update(value: Parameters[0]) { - if (!(await isV1())) return input.current.pty.update(value) const result = await legacy(value.location).pty.update({ ptyID: value.ptyID, title: value.title, @@ -457,11 +461,9 @@ export function createCompatibleApi(input: { return located(result.data, value.location) }, async remove(value: Parameters[0]) { - if (!(await isV1())) return input.current.pty.remove(value) await legacy(value.location).pty.remove({ ptyID: value.ptyID }) }, async connectToken(value: Parameters[0]) { - if (!(await isV1())) return input.current.pty.connectToken(value) const result = await legacy(value.location).pty.connectToken({ ptyID: value.ptyID }) if (!result.data) throw new Error(`Failed to connect terminal: ${value.ptyID}`) return located(result.data, value.location) @@ -470,7 +472,6 @@ export function createCompatibleApi(input: { permission: { ...input.current.permission, async reply(value: Parameters[0]) { - if (!(await isV1())) return input.current.permission.reply(value) await legacy().permission.respond({ sessionID: value.sessionID, permissionID: value.requestID, @@ -481,14 +482,12 @@ export function createCompatibleApi(input: { question: { ...input.current.question, async reply(value: Parameters[0]) { - if (!(await isV1())) return input.current.question.reply(value) await legacy().question.reply({ requestID: value.requestID, answers: value.answers.map((answer) => [...answer]), }) }, async reject(value: Parameters[0]) { - if (!(await isV1())) return input.current.question.reject(value) await legacy().question.reject({ requestID: value.requestID }) }, }, From 62e4641235d7847dadc60da37cca8a023dd54fc1 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 09:23:15 +0000 Subject: [PATCH 14/48] chore: generate --- packages/app/e2e/utils/mock-server.ts | 14 +++++++++++--- packages/app/e2e/utils/sse-transport.ts | 11 ++++------- packages/app/src/context/server-sdk.test.ts | 15 ++++++++------- 3 files changed, 23 insertions(+), 17 deletions(-) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 5a7f8351ca..834ae7e808 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -166,7 +166,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { .filter((session) => parentID !== "null" || session.parentID === undefined) .filter((session) => { const search = url.searchParams.get("search")?.toLowerCase() - return !search || String(session.title ?? "").toLowerCase().includes(search) + return ( + !search || + String(session.title ?? "") + .toLowerCase() + .includes(search) + ) }) const ordered = url.searchParams.get("order") === "asc" ? sessions.toReversed() : sessions const data = ordered.slice(offset, offset + limit) @@ -180,7 +185,9 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const statuses = (config.sessionStatus ?? {}) as Record return json(route, { data: Object.fromEntries( - Object.entries(statuses).flatMap(([id, status]) => (status.type === "idle" ? [] : [[id, { type: "running" }]])), + Object.entries(statuses).flatMap(([id, status]) => + status.type === "idle" ? [] : [[id, { type: "running" }]], + ), ), }) } @@ -293,7 +300,8 @@ function currentPermission(value: unknown) { resources: permission.patterns ?? [], save: permission.always, metadata: permission.metadata, - source: tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, + source: + tool?.messageID && tool.callID ? { type: "tool", messageID: tool.messageID, callID: tool.callID } : undefined, } } diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 66686ac259..15c3577279 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -104,7 +104,8 @@ export async function installSseTransport( created: Date.now(), type: payload.type, data: payload.properties ?? {}, - location: envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, + location: + envelope.directory && envelope.directory !== "global" ? { directory: envelope.directory } : undefined, } } const acknowledge = ( @@ -160,9 +161,7 @@ export async function installSseTransport( }) encoded.forEach((item) => marker(item.delivery.options?.marker)) if (input.burst) { - const bytes = encoder.encode( - encoded.map((item) => frame(item.payload, item.delivery.options)).join(""), - ) + const bytes = encoder.encode(encoded.map((item) => frame(item.payload, item.delivery.options)).join("")) connection.controller.enqueue(bytes) return encoded.map((item) => acknowledge(connection, item.bytes.byteLength, 1, item.delivery.options?.id)) } @@ -196,9 +195,7 @@ export async function installSseTransport( if (retry !== undefined) controller.enqueue(encoder.encode(`retry: ${retry}\n\n`)) if (url.pathname === "/api/event") controller.enqueue( - encoder.encode( - frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} }), - ), + encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), ) request.signal.addEventListener( "abort", diff --git a/packages/app/src/context/server-sdk.test.ts b/packages/app/src/context/server-sdk.test.ts index 1c17a6b9de..57e1cd86f3 100644 --- a/packages/app/src/context/server-sdk.test.ts +++ b/packages/app/src/context/server-sdk.test.ts @@ -53,13 +53,14 @@ describe("coalesceServerEvents", () => { }) test("merges adjacent current text deltas", () => { - const current = (id: string, value: string) => adaptServerEvent({ - id, - created: 1, - type: "session.text.delta", - location: { directory: "/repo" }, - data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, - } as OpenCodeEvent) + const current = (id: string, value: string) => + adaptServerEvent({ + id, + created: 1, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "ses", assistantMessageID: "msg", ordinal: 0, delta: value }, + } as OpenCodeEvent) const result = coalesceServerEvents([ { directory: "/repo", payload: current("evt_1", "hello ") }, { directory: "/repo", payload: current("evt_2", "world") }, From 20589d66d514993652af66932cb3a253f6e2f9fe Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:47:33 -0500 Subject: [PATCH 15/48] fix(provider): preserve Mistral reasoning history (#38453) --- bun.lock | 14 +- package.json | 2 +- packages/core/package.json | 2 +- packages/core/test/provider-mistral.test.ts | 254 +++++++ packages/opencode/package.json | 2 +- packages/opencode/test/session/llm.test.ts | 111 +++ patches/@ai-sdk%2Fmistral@3.0.34.patch | 84 --- patches/@ai-sdk%2Fmistral@3.0.51.patch | 709 ++++++++++++++++++++ 8 files changed, 1084 insertions(+), 94 deletions(-) delete mode 100644 patches/@ai-sdk%2Fmistral@3.0.34.patch create mode 100644 patches/@ai-sdk%2Fmistral@3.0.51.patch diff --git a/bun.lock b/bun.lock index e37adbe9ae..ccca966458 100644 --- a/bun.lock +++ b/bun.lock @@ -303,7 +303,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.34", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -578,7 +578,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.34", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", @@ -1076,12 +1076,12 @@ "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", + "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", - "@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch", }, "overrides": { "@opentui/core": "catalog:", @@ -1202,7 +1202,7 @@ "@ai-sdk/groq": ["@ai-sdk/groq@3.0.31", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-XbbugpnFmXGu2TlXiq8KUJskP6/VVbuFcnFIGDzDIB/Chg6XHsNnqrTF80Zxkh0Pd3+NvbM+2Uqrtsndk6bDAg=="], - "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.34", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@ai-sdk/provider-utils": "4.0.26" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-HpK28sWGdIfg1vTSScJNtzVdvNRfA4mfCmPmPR+j/MGJ0oAuEJMqxWkL96ZnGPdhZt5KdW09aKovdIe+q2zQ7A=="], + "@ai-sdk/mistral": ["@ai-sdk/mistral@3.0.51", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@ai-sdk/provider-utils": "4.0.40" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-83eXY6p0lUFhSuMvNDmTKDuMciK5XDAWDlNh5c0L80tKjmtCFRItA1MZHp4IKe1r7eK8Rb5nN7qtxqMLUFRIRw=="], "@ai-sdk/openai": ["@ai-sdk/openai@3.0.48", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@ai-sdk/provider-utils": "4.0.21" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-ALmj/53EXpcRqMbGpPJPP4UOSWw0q4VGpnDo7YctvsynjkrKDmoneDG/1a7VQnSPYHnJp6tTRMf5ZdxZ5whulg=="], @@ -5702,9 +5702,9 @@ "@ai-sdk/groq/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], - "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.10", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-Q3BZ27qfpYqnCYGvE3vt+Qi6LGOF9R5Nmzn+9JoM1lCRsD9mYaIhfJLkSunN48nfGXJ6n+XNV0J/XVpqGQl7Dw=="], + "@ai-sdk/mistral/@ai-sdk/provider": ["@ai-sdk/provider@3.0.14", "", { "dependencies": { "json-schema": "^0.4.0" } }, "sha512-5X1k57JBJ4H7H1QjX7CnJYAB1I19r/trVZTMcSms7/kLNZ8RaU4Nt2agcwZzv82Hfx6Q7/TOLU7agAKeFfc8cA=="], - "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.26", "", { "dependencies": { "@ai-sdk/provider": "3.0.10", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-CsKNLKsOpvPujRlIYvoz+Ybw+kGn7J4/fIZa/58+R7iWLLfwn6ifE2G6Yq8K9XvH/I/3bzaDAJ3NhRwEMsLBKQ=="], + "@ai-sdk/mistral/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.40", "", { "dependencies": { "@ai-sdk/provider": "3.0.14", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.8" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-OL5IrpUm9Y8Dwy+w/vvFwPotS6m52O9W0op2oXgXdCROMJIBalBI0oro6OIBYkPxvm5Xg02GSkoQN25RlR0bnw=="], "@ai-sdk/openai/@ai-sdk/provider-utils": ["@ai-sdk/provider-utils@4.0.21", "", { "dependencies": { "@ai-sdk/provider": "3.0.8", "@standard-schema/spec": "^1.1.0", "eventsource-parser": "^3.0.6" }, "peerDependencies": { "zod": "^3.25.76 || ^4.1.8" } }, "sha512-MtFUYI1/8mgDvRmaBDjbLJPFFrMG777AvSgyIFQtZHIMzm88R/12vYBBpnk7pfiWLFE1DSZzY4WDYzGbKAcmiw=="], diff --git a/package.json b/package.json index 372335d724..5fd0f1d51a 100644 --- a/package.json +++ b/package.json @@ -149,7 +149,7 @@ "@standard-community/standard-openapi@0.2.9": "patches/@standard-community%2Fstandard-openapi@0.2.9.patch", "solid-js@1.9.10": "patches/solid-js@1.9.10.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@ai-sdk/mistral@3.0.34": "patches/@ai-sdk%2Fmistral@3.0.34.patch", + "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", diff --git a/packages/core/package.json b/packages/core/package.json index e0445e616f..761bee109a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -72,7 +72,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.34", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", diff --git a/packages/core/test/provider-mistral.test.ts b/packages/core/test/provider-mistral.test.ts index 58904ad3b1..6e3176695f 100644 --- a/packages/core/test/provider-mistral.test.ts +++ b/packages/core/test/provider-mistral.test.ts @@ -26,3 +26,257 @@ test("Mistral sends promptCacheKey as prompt_cache_key", async () => { expect(body?.prompt_cache_key).toBe("session-123") }) + +test("Mistral round-trips native reasoning in assistant history", async () => { + let body: { messages?: unknown[] } | undefined + const mockFetch = Object.assign( + async (_input: Parameters[0], init?: RequestInit) => { + body = JSON.parse(String(init?.body)) + return Response.json({ + id: "response-1", + created: 0, + model: "mistral-small-latest", + object: "chat.completion", + choices: [ + { + index: 0, + message: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "The user is greeting me." }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + { type: "text", text: "Hi" }, + ], + }, + finish_reason: "stop", + }, + ], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }, + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + + const first = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + const reasoning = first.content.find((part) => part.type === "reasoning") + const text = first.content.find((part) => part.type === "text") + if (!reasoning || !text) throw new Error("expected reasoning and text") + + await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [{ ...reasoning, providerOptions: reasoning.providerMetadata }, text], + }, + { role: "user", content: [{ type: "text", text: "Hello again" }] }, + ], + }) + + expect(body?.messages?.[1]).toEqual({ + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "The user is greeting me." }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + { type: "text", text: "Hi" }, + ], + }) + + await model.doGenerate({ + prompt: [ + { role: "user", content: [{ type: "text", text: "Hello" }] }, + { + role: "assistant", + content: [ + { type: "reasoning", text: "thinking" }, + { type: "text", text: "Hi" }, + ], + }, + { role: "user", content: [{ type: "text", text: "Hello again" }] }, + ], + }) + expect(body?.messages?.[1]).toEqual({ role: "assistant", content: "thinkingHi" }) +}) + +test("Mistral preserves native reasoning metadata while streaming", async () => { + const chunks = [ + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [ + { + index: 0, + delta: { + role: "assistant", + content: [ + { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + ], + }, + ], + }, + }, + ], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [ + { + index: 0, + delta: { + content: [ + { + type: "thinking", + thinking: [{ type: "reference", reference_ids: [1, "source-2"] }], + closed: true, + signature: "sig-123", + }, + ], + }, + }, + ], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [{ index: 0, delta: { content: [{ type: "text", text: "answer" }] } }], + }, + { + id: "response-1", + created: 0, + model: "mistral-small-latest", + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }, + ] + const mockFetch = Object.assign( + async () => + new Response(chunks.map((chunk) => `data: ${JSON.stringify(chunk)}\n\n`).join(""), { + headers: { "Content-Type": "text/event-stream" }, + }), + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + const result = await model.doStream({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + const events = [] + for await (const event of result.stream) events.push(event) + + expect(events.find((event) => event.type === "reasoning-end")?.providerMetadata).toEqual({ + mistral: { + thinking: { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + }, + }, + }) + expect( + events + .filter((event) => event.type === "reasoning-start" || event.type === "reasoning-delta") + .every((event) => event.providerMetadata === undefined), + ).toBe(true) +}) + +test("Mistral preserves metadata-only thinking chunks", async () => { + const thinking = { + type: "thinking" as const, + thinking: [ + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + } + const mockFetch = Object.assign( + async () => + Response.json({ + id: "response-1", + created: 0, + model: "mistral-small-latest", + object: "chat.completion", + choices: [{ index: 0, message: { role: "assistant", content: [thinking] }, finish_reason: "stop" }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }), + { preconnect: fetch.preconnect }, + ) + const model = createMistral({ apiKey: "test", fetch: mockFetch })("mistral-small-latest") + const result = await model.doGenerate({ + prompt: [{ role: "user", content: [{ type: "text", text: "Hello" }] }], + }) + + expect(result.content).toEqual([ + { + type: "reasoning", + text: "", + providerMetadata: { mistral: { thinking } }, + }, + ]) +}) diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6781bf488e..0876f4badb 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -66,7 +66,7 @@ "@ai-sdk/google": "3.0.73", "@ai-sdk/google-vertex": "4.0.128", "@ai-sdk/groq": "3.0.31", - "@ai-sdk/mistral": "3.0.34", + "@ai-sdk/mistral": "3.0.51", "@ai-sdk/openai": "3.0.84", "@ai-sdk/openai-compatible": "2.0.41", "@ai-sdk/perplexity": "3.0.26", diff --git a/packages/opencode/test/session/llm.test.ts b/packages/opencode/test/session/llm.test.ts index 61aac13ac3..3bfc722e2b 100644 --- a/packages/opencode/test/session/llm.test.ts +++ b/packages/opencode/test/session/llm.test.ts @@ -906,6 +906,117 @@ describe("session.llm.stream", () => { }, ) + const mistralFixture = { providerID: "mistral", modelID: "mistral-small-latest" } + it.instance( + "replays native Mistral reasoning from chat history", + () => + Effect.gen(function* () { + const fixture = loadFixture(mistralFixture.providerID, mistralFixture.modelID) + const request = waitRequest( + "/chat/completions", + createEventResponse( + [ + { + id: "chatcmpl-mistral", + object: "chat.completion.chunk", + created: 0, + model: fixture.model.id, + choices: [{ index: 0, delta: { role: "assistant", content: "Hello" } }], + }, + { + id: "chatcmpl-mistral", + object: "chat.completion.chunk", + created: 0, + model: fixture.model.id, + choices: [{ index: 0, delta: {}, finish_reason: "stop" }], + }, + ], + true, + ), + ) + + const resolved = yield* Provider.use.getModel( + ProviderV2.ID.make(mistralFixture.providerID), + ModelV2.ID.make(fixture.model.id), + ) + const sessionID = SessionID.make("session-test-mistral-reasoning") + const agent = { + name: "test", + mode: "primary", + options: {}, + permission: [{ permission: "*", pattern: "*", action: "allow" }], + } satisfies Agent.Info + + const user = { + id: MessageID.make("msg_user-mistral-reasoning"), + sessionID, + role: "user", + time: { created: Date.now() }, + agent: agent.name, + model: { providerID: ProviderV2.ID.make(mistralFixture.providerID), modelID: resolved.id }, + } satisfies SessionV1.User + + const thinking = { + type: "thinking", + thinking: [ + { type: "text", text: "thinking" }, + { + type: "tool_reference", + tool: "web_search", + title: "Example result", + url: "https://example.com/tool", + favicon: "https://example.com/favicon.ico", + description: "Example description", + }, + { type: "reference", reference_ids: [1, "source-2"] }, + ], + closed: true, + signature: "sig-123", + } + + yield* drain({ + user, + sessionID, + model: resolved, + agent, + system: ["You are a helpful assistant."], + messages: [ + { role: "user", content: "Hello" }, + { + role: "assistant", + content: [ + { + type: "reasoning", + text: "thinking", + providerOptions: { mistral: { thinking } }, + }, + { type: "text", text: "Previous answer" }, + ], + }, + { role: "user", content: "Continue" }, + ] satisfies ModelMessage[], + tools: {}, + }) + + const capture = yield* Effect.promise(() => request) + const messages = capture.body.messages as Array> + expect(messages.find((message) => message.role === "assistant")).toEqual({ + role: "assistant", + content: [thinking, { type: "text", text: "Previous answer" }], + }) + }), + { + config: () => ({ + enabled_providers: [mistralFixture.providerID], + provider: { + [mistralFixture.providerID]: { + options: { apiKey: "test-key", baseURL: `${state.server!.url.origin}/v1` }, + }, + }, + }), + }, + ) + const alibabaQwenFixture = { providerID: "alibaba", modelID: "qwen-plus" } it.instance( "service stream cancellation cancels provider response body promptly", diff --git a/patches/@ai-sdk%2Fmistral@3.0.34.patch b/patches/@ai-sdk%2Fmistral@3.0.34.patch deleted file mode 100644 index 1d771f4fd9..0000000000 --- a/patches/@ai-sdk%2Fmistral@3.0.34.patch +++ /dev/null @@ -1,84 +0,0 @@ -diff --git a/dist/index.d.ts b/dist/index.d.ts -index 1ca9113bed2728a616db773a8e08d8d6957447d7..15408ec429dc210b5fa43589d81b69c93bf27b2d 100644 ---- a/dist/index.d.ts -+++ b/dist/index.d.ts -@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ - none: "none"; - high: "high"; - }>>; -+ promptCacheKey: z.ZodOptional; - }, z.core.$strip>; - type MistralLanguageModelOptions = z.infer; - -diff --git a/dist/index.js b/dist/index.js -index 45735e524aaff54ea058c99c729c5ffd3c507058..6aca5f6f13da0054ede31c1f1a692e4eaed37d34 100644 ---- a/dist/index.js -+++ b/dist/index.js -@@ -268,7 +268,8 @@ var mistralLanguageModelOptions = import_v4.z.object({ - * - `'high'`: Enable reasoning - * - `'none'`: Disable reasoning - */ -- reasoningEffort: import_v4.z.enum(["high", "none"]).optional() -+ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(), -+ promptCacheKey: import_v4.z.string().optional() - }); - - // src/mistral-error.ts -@@ -413,6 +414,7 @@ var MistralChatLanguageModel = class { - top_p: topP, - random_seed: seed, - reasoning_effort: options.reasoningEffort, -+ prompt_cache_key: options.promptCacheKey, - // response format: - response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { - type: "json_schema", -diff --git a/dist/index.mjs b/dist/index.mjs -index 4c22df1cd78a1ba81309c8a86ceecefef4ba4aea..30cd3b1f503860109b7fa2107cd1eb17b70c96be 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -256,7 +256,8 @@ var mistralLanguageModelOptions = z.object({ - * - `'high'`: Enable reasoning - * - `'none'`: Disable reasoning - */ -- reasoningEffort: z.enum(["high", "none"]).optional() -+ reasoningEffort: z.enum(["high", "none"]).optional(), -+ promptCacheKey: z.string().optional() - }); - - // src/mistral-error.ts -@@ -403,6 +404,7 @@ var MistralChatLanguageModel = class { - top_p: topP, - random_seed: seed, - reasoning_effort: options.reasoningEffort, -+ prompt_cache_key: options.promptCacheKey, - // response format: - response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { - type: "json_schema", -diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts -index 480c472d534bedbe8897979673453bd1c29a70b7..e46496da94f7d4af9822897202ca6baae67dae3a 100644 ---- a/src/mistral-chat-language-model.ts -+++ b/src/mistral-chat-language-model.ts -@@ -129,6 +129,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { - top_p: topP, - random_seed: seed, - reasoning_effort: options.reasoningEffort, -+ prompt_cache_key: options.promptCacheKey, - - // response format: - response_format: -diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts -index 80fff45fba2c378fa06962f071946bcd2b882a0b..b4fdfa51f3bf11a4220e010c8aca92482cb0c3db 100644 ---- a/src/mistral-chat-options.ts -+++ b/src/mistral-chat-options.ts -@@ -62,6 +62,11 @@ export const mistralLanguageModelOptions = z.object({ - * - `'none'`: Disable reasoning - */ - reasoningEffort: z.enum(['high', 'none']).optional(), -+ -+ /** -+ * A stable identifier used to route requests with shared prompt prefixes. -+ */ -+ promptCacheKey: z.string().optional(), - }); - - export type MistralLanguageModelOptions = z.infer< diff --git a/patches/@ai-sdk%2Fmistral@3.0.51.patch b/patches/@ai-sdk%2Fmistral@3.0.51.patch new file mode 100644 index 0000000000..141b14a689 --- /dev/null +++ b/patches/@ai-sdk%2Fmistral@3.0.51.patch @@ -0,0 +1,709 @@ +diff --git a/dist/index.d.mts b/dist/index.d.mts +index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644 +--- a/dist/index.d.mts ++++ b/dist/index.d.mts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.d.ts b/dist/index.d.ts +index 1bde0b9f8cbe6771a52c1041095c9dddfe8e5b6c..0ca2ffb2a0c9327aed5ddcf0004500dc8b42569f 100644 +--- a/dist/index.d.ts ++++ b/dist/index.d.ts +@@ -14,6 +14,7 @@ declare const mistralLanguageModelOptions: z.ZodObject<{ + none: "none"; + high: "high"; + }>>; ++ promptCacheKey: z.ZodOptional; + }, z.core.$strip>; + type MistralLanguageModelOptions = z.infer; + +diff --git a/dist/index.js b/dist/index.js +index d3f904c12a1d582cc7b9e9a2d30273e1a8505b28..267f34e20ea392b7a85ad5259d72d50605a6f971 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -128,11 +128,14 @@ function convertToMistralChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ const structuredContent = []; ++ let hasNativeReasoning = false; + const toolCalls = []; + for (const part of content) { + switch (part.type) { + case "text": { + text += part.text; ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + case "tool-call": { +@@ -148,6 +151,13 @@ function convertToMistralChatMessages(prompt) { + } + case "reasoning": { + text += part.text; ++ const native = part.providerOptions?.mistral?.thinking; ++ if (native?.type === "thinking") { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + default: { +@@ -159,7 +169,7 @@ function convertToMistralChatMessages(prompt) { + } + messages.push({ + role: "assistant", +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : void 0, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0 + }); +@@ -268,7 +278,8 @@ var mistralLanguageModelOptions = import_v4.z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: import_v4.z.enum(["high", "none"]).optional() ++ reasoningEffort: import_v4.z.enum(["high", "none"]).optional(), ++ promptCacheKey: import_v4.z.string().optional() + }); + + // src/mistral-error.ts +@@ -407,6 +418,7 @@ var MistralChatLanguageModel = class { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +@@ -465,9 +477,11 @@ var MistralChatLanguageModel = class { + for (const part of choice.message.content) { + if (part.type === "thinking") { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: "reasoning", text: reasoningText }); +- } ++ content.push({ ++ type: "reasoning", ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } } ++ }); + } else if (part.type === "text") { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); +@@ -528,6 +542,7 @@ var MistralChatLanguageModel = class { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId = null; ++ let activeThinking = null; + const generateId2 = this.generateId; + return { + stream: response.pipeThrough( +@@ -561,18 +576,19 @@ var MistralChatLanguageModel = class { + for (const part of delta.content) { + if (part.type === "thinking") { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- if (activeText) { +- controller.enqueue({ type: "text-end", id: "0" }); +- activeText = false; +- } +- activeReasoningId = generateId2(); +- controller.enqueue({ +- type: "reasoning-start", +- id: activeReasoningId +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ if (activeText) { ++ controller.enqueue({ type: "text-end", id: "0" }); ++ activeText = false; + } ++ activeReasoningId = generateId2(); ++ controller.enqueue({ ++ type: "reasoning-start", ++ id: activeReasoningId ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: "reasoning-delta", + id: activeReasoningId, +@@ -587,9 +603,11 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: "text-start", id: "0" }); + activeText = true; +@@ -638,7 +656,8 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + } + if (activeText) { +@@ -660,6 +679,13 @@ var MistralChatLanguageModel = class { + function extractReasoningContent(thinking) { + return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join(""); + } ++function mergeThinking(current, next) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== void 0) current.closed = next.closed; ++ if (next.signature !== void 0) current.signature = next.signature; ++ return current; ++} + function extractTextContent(content) { + if (typeof content === "string") { + return content; +@@ -686,6 +712,30 @@ function extractTextContent(content) { + } + return textContent.length ? textContent.join("") : void 0; + } ++var mistralThinkingContentSchema = import_v43.z.discriminatedUnion("type", [ ++ import_v43.z.object({ ++ type: import_v43.z.literal("text"), ++ text: import_v43.z.string() ++ }), ++ import_v43.z.object({ ++ type: import_v43.z.literal("tool_reference"), ++ tool: import_v43.z.string(), ++ title: import_v43.z.string(), ++ url: import_v43.z.string().nullish(), ++ favicon: import_v43.z.string().nullish(), ++ description: import_v43.z.string().nullish() ++ }), ++ import_v43.z.object({ ++ type: import_v43.z.literal("reference"), ++ reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number().int()])) ++ }) ++]); ++var mistralThinkChunkSchema = import_v43.z.object({ ++ type: import_v43.z.literal("thinking"), ++ thinking: import_v43.z.array(mistralThinkingContentSchema), ++ closed: import_v43.z.boolean().optional(), ++ signature: import_v43.z.string().nullish() ++}); + var mistralContentSchema = import_v43.z.union([ + import_v43.z.string(), + import_v43.z.array( +@@ -708,15 +758,7 @@ var mistralContentSchema = import_v43.z.union([ + type: import_v43.z.literal("reference"), + reference_ids: import_v43.z.array(import_v43.z.union([import_v43.z.string(), import_v43.z.number()])) + }), +- import_v43.z.object({ +- type: import_v43.z.literal("thinking"), +- thinking: import_v43.z.array( +- import_v43.z.object({ +- type: import_v43.z.literal("text"), +- text: import_v43.z.string() +- }) +- ) +- }) ++ mistralThinkChunkSchema + ]) + ) + ]).nullish(); +diff --git a/dist/index.mjs b/dist/index.mjs +index d2eff622c1b84a96bdeb4012cb0206a33012a04d..3bff11ddd6136ada45809568828cbc8f2493a42a 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -116,11 +116,14 @@ function convertToMistralChatMessages(prompt) { + } + case "assistant": { + let text = ""; ++ const structuredContent = []; ++ let hasNativeReasoning = false; + const toolCalls = []; + for (const part of content) { + switch (part.type) { + case "text": { + text += part.text; ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + case "tool-call": { +@@ -136,6 +139,13 @@ function convertToMistralChatMessages(prompt) { + } + case "reasoning": { + text += part.text; ++ const native = part.providerOptions?.mistral?.thinking; ++ if (native?.type === "thinking") { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: "text", text: part.text }); + break; + } + default: { +@@ -147,7 +157,7 @@ function convertToMistralChatMessages(prompt) { + } + messages.push({ + role: "assistant", +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : void 0, + tool_calls: toolCalls.length > 0 ? toolCalls : void 0 + }); +@@ -256,7 +266,8 @@ var mistralLanguageModelOptions = z.object({ + * - `'high'`: Enable reasoning + * - `'none'`: Disable reasoning + */ +- reasoningEffort: z.enum(["high", "none"]).optional() ++ reasoningEffort: z.enum(["high", "none"]).optional(), ++ promptCacheKey: z.string().optional() + }); + + // src/mistral-error.ts +@@ -397,6 +408,7 @@ var MistralChatLanguageModel = class { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + // response format: + response_format: (responseFormat == null ? void 0 : responseFormat.type) === "json" ? structuredOutputs && (responseFormat == null ? void 0 : responseFormat.schema) != null ? { + type: "json_schema", +@@ -455,9 +467,11 @@ var MistralChatLanguageModel = class { + for (const part of choice.message.content) { + if (part.type === "thinking") { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: "reasoning", text: reasoningText }); +- } ++ content.push({ ++ type: "reasoning", ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } } ++ }); + } else if (part.type === "text") { + if (part.text.length > 0) { + content.push({ type: "text", text: part.text }); +@@ -518,6 +532,7 @@ var MistralChatLanguageModel = class { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId = null; ++ let activeThinking = null; + const generateId2 = this.generateId; + return { + stream: response.pipeThrough( +@@ -551,18 +566,19 @@ var MistralChatLanguageModel = class { + for (const part of delta.content) { + if (part.type === "thinking") { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- if (activeText) { +- controller.enqueue({ type: "text-end", id: "0" }); +- activeText = false; +- } +- activeReasoningId = generateId2(); +- controller.enqueue({ +- type: "reasoning-start", +- id: activeReasoningId +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ if (activeText) { ++ controller.enqueue({ type: "text-end", id: "0" }); ++ activeText = false; + } ++ activeReasoningId = generateId2(); ++ controller.enqueue({ ++ type: "reasoning-start", ++ id: activeReasoningId ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: "reasoning-delta", + id: activeReasoningId, +@@ -577,9 +593,11 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: "text-start", id: "0" }); + activeText = true; +@@ -628,7 +646,8 @@ var MistralChatLanguageModel = class { + if (activeReasoningId != null) { + controller.enqueue({ + type: "reasoning-end", +- id: activeReasoningId ++ id: activeReasoningId, ++ providerMetadata: { mistral: { thinking: activeThinking } } + }); + } + if (activeText) { +@@ -650,6 +669,13 @@ var MistralChatLanguageModel = class { + function extractReasoningContent(thinking) { + return thinking.filter((chunk) => chunk.type === "text").map((chunk) => chunk.text).join(""); + } ++function mergeThinking(current, next) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== void 0) current.closed = next.closed; ++ if (next.signature !== void 0) current.signature = next.signature; ++ return current; ++} + function extractTextContent(content) { + if (typeof content === "string") { + return content; +@@ -676,6 +702,30 @@ function extractTextContent(content) { + } + return textContent.length ? textContent.join("") : void 0; + } ++var mistralThinkingContentSchema = z3.discriminatedUnion("type", [ ++ z3.object({ ++ type: z3.literal("text"), ++ text: z3.string() ++ }), ++ z3.object({ ++ type: z3.literal("tool_reference"), ++ tool: z3.string(), ++ title: z3.string(), ++ url: z3.string().nullish(), ++ favicon: z3.string().nullish(), ++ description: z3.string().nullish() ++ }), ++ z3.object({ ++ type: z3.literal("reference"), ++ reference_ids: z3.array(z3.union([z3.string(), z3.number().int()])) ++ }) ++]); ++var mistralThinkChunkSchema = z3.object({ ++ type: z3.literal("thinking"), ++ thinking: z3.array(mistralThinkingContentSchema), ++ closed: z3.boolean().optional(), ++ signature: z3.string().nullish() ++}); + var mistralContentSchema = z3.union([ + z3.string(), + z3.array( +@@ -698,15 +748,7 @@ var mistralContentSchema = z3.union([ + type: z3.literal("reference"), + reference_ids: z3.array(z3.union([z3.string(), z3.number()])) + }), +- z3.object({ +- type: z3.literal("thinking"), +- thinking: z3.array( +- z3.object({ +- type: z3.literal("text"), +- text: z3.string() +- }) +- ) +- }) ++ mistralThinkChunkSchema + ]) + ) + ]).nullish(); +diff --git a/src/convert-to-mistral-chat-messages.ts b/src/convert-to-mistral-chat-messages.ts +index 3c6914f8da615d7517bc43dd56198298d0a50247..8cd6f4c7577f746ef41e8a0aee682234c473667a 100644 +--- a/src/convert-to-mistral-chat-messages.ts ++++ b/src/convert-to-mistral-chat-messages.ts +@@ -3,7 +3,11 @@ import { + type LanguageModelV3DataContent, + type LanguageModelV3Prompt, + } from '@ai-sdk/provider'; +-import type { MistralPrompt } from './mistral-chat-prompt'; ++import type { ++ MistralAssistantMessageContent, ++ MistralPrompt, ++ MistralThinkChunk, ++} from './mistral-chat-prompt'; + import { convertToBase64 } from '@ai-sdk/provider-utils'; + + function formatFileUrl({ +@@ -76,6 +80,8 @@ export function convertToMistralChatMessages( + + case 'assistant': { + let text = ''; ++ const structuredContent: Array = []; ++ let hasNativeReasoning = false; + const toolCalls: Array<{ + id: string; + type: 'function'; +@@ -86,6 +92,7 @@ export function convertToMistralChatMessages( + switch (part.type) { + case 'text': { + text += part.text; ++ structuredContent.push({ type: 'text', text: part.text }); + break; + } + case 'tool-call': { +@@ -101,6 +108,14 @@ export function convertToMistralChatMessages( + } + case 'reasoning': { + text += part.text; ++ const native = part.providerOptions?.mistral ++ ?.thinking as MistralThinkChunk | undefined; ++ if (native?.type === 'thinking') { ++ hasNativeReasoning = true; ++ structuredContent.push(native); ++ break; ++ } ++ structuredContent.push({ type: 'text', text: part.text }); + break; + } + default: { +@@ -113,7 +128,7 @@ export function convertToMistralChatMessages( + + messages.push({ + role: 'assistant', +- content: text, ++ content: hasNativeReasoning ? structuredContent : text, + prefix: isLastMessage ? true : undefined, + tool_calls: toolCalls.length > 0 ? toolCalls : undefined, + }); +diff --git a/src/mistral-chat-language-model.ts b/src/mistral-chat-language-model.ts +index 7e4a7ab552f1b41b7074e1b3cada8a51d791268d..847d26f9dfe03572a969a122f8c96b8bbfda8066 100644 +--- a/src/mistral-chat-language-model.ts ++++ b/src/mistral-chat-language-model.ts +@@ -122,6 +122,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + stop: stopSequences, + random_seed: seed, + reasoning_effort: options.reasoningEffort, ++ prompt_cache_key: options.promptCacheKey, + + // response format: + response_format: +@@ -201,9 +202,11 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + for (const part of choice.message.content) { + if (part.type === 'thinking') { + const reasoningText = extractReasoningContent(part.thinking); +- if (reasoningText.length > 0) { +- content.push({ type: 'reasoning', text: reasoningText }); +- } ++ content.push({ ++ type: 'reasoning', ++ text: reasoningText, ++ providerMetadata: { mistral: { thinking: part } }, ++ }); + } else if (part.type === 'text') { + if (part.text.length > 0) { + content.push({ type: 'text', text: part.text }); +@@ -278,6 +281,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + let isFirstChunk = true; + let activeText = false; + let activeReasoningId: string | null = null; ++ let activeThinking: z.infer | null = null; + + const generateId = this.generateId; + +@@ -326,20 +330,21 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + for (const part of delta.content) { + if (part.type === 'thinking') { + const reasoningDelta = extractReasoningContent(part.thinking); +- if (reasoningDelta.length > 0) { +- if (activeReasoningId == null) { +- // end any active text before starting reasoning +- if (activeText) { +- controller.enqueue({ type: 'text-end', id: '0' }); +- activeText = false; +- } +- +- activeReasoningId = generateId(); +- controller.enqueue({ +- type: 'reasoning-start', +- id: activeReasoningId, +- }); ++ activeThinking = mergeThinking(activeThinking, part); ++ if (activeReasoningId == null) { ++ // end any active text before starting reasoning ++ if (activeText) { ++ controller.enqueue({ type: 'text-end', id: '0' }); ++ activeText = false; + } ++ ++ activeReasoningId = generateId(); ++ controller.enqueue({ ++ type: 'reasoning-start', ++ id: activeReasoningId, ++ }); ++ } ++ if (reasoningDelta.length > 0) { + controller.enqueue({ + type: 'reasoning-delta', + id: activeReasoningId, +@@ -357,8 +362,12 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + controller.enqueue({ + type: 'reasoning-end', + id: activeReasoningId, ++ providerMetadata: { ++ mistral: { thinking: activeThinking }, ++ }, + }); + activeReasoningId = null; ++ activeThinking = null; + } + controller.enqueue({ type: 'text-start', id: '0' }); + activeText = true; +@@ -416,6 +425,9 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + controller.enqueue({ + type: 'reasoning-end', + id: activeReasoningId, ++ providerMetadata: { ++ mistral: { thinking: activeThinking }, ++ }, + }); + } + if (activeText) { +@@ -437,7 +449,7 @@ export class MistralChatLanguageModel implements LanguageModelV3 { + } + + function extractReasoningContent( +- thinking: Array<{ type: string; text: string }>, ++ thinking: Array>, + ) { + return thinking + .filter(chunk => chunk.type === 'text') +@@ -445,6 +457,17 @@ function extractReasoningContent( + .join(''); + } + ++function mergeThinking( ++ current: z.infer | null, ++ next: z.infer, ++) { ++ if (current === null) return { ...next, thinking: [...next.thinking] }; ++ current.thinking.push(...next.thinking); ++ if (next.closed !== undefined) current.closed = next.closed; ++ if (next.signature !== undefined) current.signature = next.signature; ++ return current; ++} ++ + function extractTextContent(content: z.infer) { + if (typeof content === 'string') { + return content; +@@ -478,6 +501,32 @@ function extractTextContent(content: z.infer) { + return textContent.length ? textContent.join('') : undefined; + } + ++const mistralThinkingContentSchema = z.discriminatedUnion('type', [ ++ z.object({ ++ type: z.literal('text'), ++ text: z.string(), ++ }), ++ z.object({ ++ type: z.literal('tool_reference'), ++ tool: z.string(), ++ title: z.string(), ++ url: z.string().nullish(), ++ favicon: z.string().nullish(), ++ description: z.string().nullish(), ++ }), ++ z.object({ ++ type: z.literal('reference'), ++ reference_ids: z.array(z.union([z.string(), z.number().int()])), ++ }), ++]); ++ ++const mistralThinkChunkSchema = z.object({ ++ type: z.literal('thinking'), ++ thinking: z.array(mistralThinkingContentSchema), ++ closed: z.boolean().optional(), ++ signature: z.string().nullish(), ++}); ++ + const mistralContentSchema = z + .union([ + z.string(), +@@ -501,15 +550,7 @@ const mistralContentSchema = z + type: z.literal('reference'), + reference_ids: z.array(z.union([z.string(), z.number()])), + }), +- z.object({ +- type: z.literal('thinking'), +- thinking: z.array( +- z.object({ +- type: z.literal('text'), +- text: z.string(), +- }), +- ), +- }), ++ mistralThinkChunkSchema, + ]), + ), + ]) +diff --git a/src/mistral-chat-options.ts b/src/mistral-chat-options.ts +index 54b29c08517d348995b6ca093b11160e453d5c8b..de30c3e7d924889339e38b1067cb26e9ada05d11 100644 +--- a/src/mistral-chat-options.ts ++++ b/src/mistral-chat-options.ts +@@ -64,6 +64,11 @@ export const mistralLanguageModelOptions = z.object({ + * - `'none'`: Disable reasoning + */ + reasoningEffort: z.enum(['high', 'none']).optional(), ++ ++ /** ++ * A stable identifier used to route requests with shared prompt prefixes. ++ */ ++ promptCacheKey: z.string().optional(), + }); + + export type MistralLanguageModelOptions = z.infer< +diff --git a/src/mistral-chat-prompt.ts b/src/mistral-chat-prompt.ts +index 13f1dced55ac4be084128127a57fbdd58115bc28..172b11dde3dd326c2f3befd99237474ed8c79285 100644 +--- a/src/mistral-chat-prompt.ts ++++ b/src/mistral-chat-prompt.ts +@@ -23,7 +23,7 @@ export type MistralUserMessageContent = + + export interface MistralAssistantMessage { + role: 'assistant'; +- content: string; ++ content: string | Array; + prefix?: boolean; + tool_calls?: Array<{ + id: string; +@@ -32,6 +32,29 @@ export interface MistralAssistantMessage { + }>; + } + ++export type MistralAssistantMessageContent = ++ | { type: 'text'; text: string } ++ | MistralThinkChunk; ++ ++export type MistralThinkChunk = { ++ type: 'thinking'; ++ thinking: Array; ++ closed?: boolean; ++ signature?: string | null; ++}; ++ ++export type MistralThinkingContent = ++ | { type: 'text'; text: string } ++ | { ++ type: 'tool_reference'; ++ tool: string; ++ title: string; ++ url?: string | null; ++ favicon?: string | null; ++ description?: string | null; ++ } ++ | { type: 'reference'; reference_ids: Array }; ++ + export interface MistralToolMessage { + role: 'tool'; + name: string; From 743f6410f2e5002723fc5e893039ac49fbfe0de8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 23 Jul 2026 18:04:46 +0000 Subject: [PATCH 16/48] chore: update nix node_modules hashes --- nix/hashes.json | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/nix/hashes.json b/nix/hashes.json index de8b61cd62..407d7812fb 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-P6Y+qaho1njCsiRdH9ej+Wyd+BuDJ60w/tcS4koUrLo=", - "aarch64-linux": "sha256-cjOYq60xL1xGGg5PugnOGX3DAYZAetP/BmCbkd5cqtQ=", - "aarch64-darwin": "sha256-L95qDP53TDoHPlJDBztqTCDiFJ9mxmX4lS8h60hnZ54=", - "x86_64-darwin": "sha256-OeMS5Z8LO+GCzQqLeFxBiQEGWUxVerTwctDi+0SiFb0=" + "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=", + "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=", + "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=", + "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ=" } } From 204f48de8beada708ec0fff9310d556733ae4395 Mon Sep 17 00:00:00 2001 From: Jack Date: Fri, 24 Jul 2026 10:22:45 +0800 Subject: [PATCH 17/48] docs(zen): add Ling 3.0 Flash free model (#38503) --- packages/web/src/content/docs/ar/zen.mdx | 4 ++++ packages/web/src/content/docs/bs/zen.mdx | 4 ++++ packages/web/src/content/docs/da/zen.mdx | 4 ++++ packages/web/src/content/docs/de/zen.mdx | 4 ++++ packages/web/src/content/docs/es/zen.mdx | 4 ++++ packages/web/src/content/docs/fr/zen.mdx | 4 ++++ packages/web/src/content/docs/it/zen.mdx | 4 ++++ packages/web/src/content/docs/ja/zen.mdx | 4 ++++ packages/web/src/content/docs/ko/zen.mdx | 4 ++++ packages/web/src/content/docs/nb/zen.mdx | 4 ++++ packages/web/src/content/docs/pl/zen.mdx | 4 ++++ packages/web/src/content/docs/pt-br/zen.mdx | 4 ++++ packages/web/src/content/docs/ru/zen.mdx | 4 ++++ packages/web/src/content/docs/th/zen.mdx | 4 ++++ packages/web/src/content/docs/tr/zen.mdx | 4 ++++ packages/web/src/content/docs/zen.mdx | 4 ++++ packages/web/src/content/docs/zh-cn/zen.mdx | 4 ++++ packages/web/src/content/docs/zh-tw/zen.mdx | 4 ++++ 18 files changed, 72 insertions(+) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 842e97a921..6aeda44697 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -109,6 +109,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -137,6 +138,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -210,6 +212,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - MiMo-V2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Laguna S 2.1 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. +- Ling-3.0-flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - North Mini Code Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Ultra Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -267,6 +270,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - MiMo-V2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Laguna S 2.1 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- Ling-3.0-flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - North Mini Code Free: خلال فترته المجانية، قد يُحتفَظ بالبيانات المُجمَّعة وتُستخدم لتحسين النموذج. لا تُرسل بيانات شخصية أو سرية. راجع [شروط الاستخدام](https://cohere.com/terms-of-use) و[سياسة الخصوصية](https://cohere.com/privacy). - Nemotron 3 Ultra Free (نقاط نهاية NVIDIA المجانية): للاستخدام التجريبي فقط — لا ترسل بيانات شخصية أو سرية. يُسجَّل استخدامك لأغراض أمنية ولتحسين منتجات وخدمات NVIDIA. بيانات الجلسة المُسجَّلة لأغراض التحسين غير مرتبطة بهويتك أو بأي مُعرِّف دائم. لمزيد من المعلومات حول ممارسات معالجة البيانات لدينا، راجع [سياسة الخصوصية](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). بتفاعلك مع نقطة النهاية هذه، فإنك توافق على جمعنا لهذه المعلومات وتسجيلها واستخدامها وعلى [شروط خدمة النسخة التجريبية من واجهة NVIDIA API](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: يتم الاحتفاظ بالطلبات لمدة 30 يوما وفقا لـ [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index fb68f022c9..184febb8c7 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -114,6 +114,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ Besplatni modeli: - DeepSeek V4 Flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - MiMo-V2.5 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Laguna S 2.1 Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. +- Ling-3.0-flash Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - North Mini Code Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Nemotron 3 Ultra Free je dostupan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. - Big Pickle je stealth model koji je besplatan na OpenCode ograničeno vrijeme. Tim koristi ovo vrijeme da prikupi povratne informacije i poboljša model. @@ -279,6 +282,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - DeepSeek V4 Flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - MiMo-V2.5 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - Laguna S 2.1 Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. +- Ling-3.0-flash Free: Tokom besplatnog perioda, prikupljeni podaci mogu se koristiti za poboljšanje modela. - North Mini Code Free: Tokom besplatnog perioda, prikupljeni podaci mogu biti zadržani i korišteni za poboljšanje modela. Nemojte slati lične ili povjerljive podatke. Pogledajte naše [Uslove korištenja](https://cohere.com/terms-of-use) i [Politiku privatnosti](https://cohere.com/privacy). - Nemotron 3 Ultra Free (besplatni NVIDIA endpointi): Samo za probnu upotrebu — nemojte slati lične ili povjerljive podatke. Vaše korištenje se bilježi radi sigurnosti i poboljšanja NVIDIA proizvoda i usluga. Zabilježeni podaci sesije koji se koriste u svrhu poboljšanja nisu povezani s vašim identitetom niti bilo kojim trajnim identifikatorom. Za više informacija o našim praksama obrade podataka pogledajte našu [Politiku privatnosti](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interakcijom s ovim endpointom pristajete na naše prikupljanje, bilježenje i korištenje takvih informacija te na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index ced99167a5..09744d896d 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -114,6 +114,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ De gratis modeller: - DeepSeek V4 Flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - MiMo-V2.5 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Laguna S 2.1 Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. +- Ling-3.0-flash Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - North Mini Code Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Nemotron 3 Ultra Free er tilgængelig på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. - Big Pickle er en stealth-model, som er gratis på OpenCode i en begrænset periode. Teamet bruger denne tid til at indsamle feedback og forbedre modellen. @@ -277,6 +280,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - DeepSeek V4 Flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - MiMo-V2.5 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - Laguna S 2.1 Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. +- Ling-3.0-flash Free: I den gratis periode kan indsamlede data blive brugt til at forbedre modellen. - North Mini Code Free: I gratisperioden kan indsamlede data blive opbevaret og brugt til at forbedre modellen. Indsend ikke personlige eller fortrolige oplysninger. Se vores [Brugsvilkår](https://cohere.com/terms-of-use) og [Privatlivspolitik](https://cohere.com/privacy). - Nemotron 3 Ultra Free (gratis NVIDIA-endpoints): Kun til prøvebrug — indsend ikke personlige eller fortrolige data. Din brug logges af sikkerhedshensyn og for at forbedre NVIDIAs produkter og tjenester. De loggede sessionsdata, der bruges til forbedringsformål, er ikke knyttet til din identitet eller nogen vedvarende identifikator. For mere information om vores databehandlingspraksis, se vores [privatlivspolitik](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved at interagere med dette endpoint giver du samtykke til vores indsamling, registrering og brug af sådanne oplysninger samt [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: Anmodninger opbevares i 30 dage i overensstemmelse med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 55f5457d57..5bbb88bb76 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -105,6 +105,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ Die kostenlosen Modelle: - DeepSeek V4 Flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - MiMo-V2.5 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Laguna S 2.1 Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. +- Ling-3.0-flash Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - North Mini Code Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Nemotron 3 Ultra Free ist für begrenzte Zeit auf OpenCode verfügbar. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. - Big Pickle ist ein Stealth-Modell, das für begrenzte Zeit kostenlos auf OpenCode verfügbar ist. Das Team nutzt diese Zeit, um Feedback zu sammeln und das Modell zu verbessern. @@ -263,6 +266,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - DeepSeek V4 Flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - MiMo-V2.5 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - Laguna S 2.1 Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. +- Ling-3.0-flash Free: Während des kostenlosen Zeitraums können gesammelte Daten zur Verbesserung des Modells verwendet werden. - North Mini Code Free: Während des kostenlosen Zeitraums können erhobene Daten gespeichert und zur Verbesserung des Modells verwendet werden. Übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Weitere Informationen finden Sie in unseren [Nutzungsbedingungen](https://cohere.com/terms-of-use) und unserer [Datenschutzerklärung](https://cohere.com/privacy). - Nemotron 3 Ultra Free (kostenlose NVIDIA-Endpunkte): Nur für Testzwecke — übermitteln Sie keine personenbezogenen oder vertraulichen Daten. Ihre Nutzung wird zu Sicherheitszwecken und zur Verbesserung der Produkte und Dienste von NVIDIA protokolliert. Die zu Verbesserungszwecken protokollierten Sitzungsdaten sind nicht mit Ihrer Identität oder einem dauerhaften Identifikator verknüpft. Weitere Informationen zu unseren Datenverarbeitungspraktiken finden Sie in unserer [Datenschutzrichtlinie](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Durch die Interaktion mit diesem Endpunkt stimmen Sie unserer Erhebung, Aufzeichnung und Nutzung solcher Informationen sowie den [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) zu. - OpenAI APIs: Anfragen werden in Übereinstimmung mit [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 30 Tage lang gespeichert. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index bc647efba8..f9c08b764c 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -114,6 +114,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ Los modelos gratuitos: - DeepSeek V4 Flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - MiMo-V2.5 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Laguna S 2.1 Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. +- Ling-3.0-flash Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - North Mini Code Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Nemotron 3 Ultra Free está disponible en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. - Big Pickle es un modelo stealth que es gratuito en OpenCode por tiempo limitado. El equipo está usando este tiempo para recopilar comentarios y mejorar el modelo. @@ -277,6 +280,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - DeepSeek V4 Flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - MiMo-V2.5 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - Laguna S 2.1 Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. +- Ling-3.0-flash Free: Durante su período gratuito, los datos recopilados pueden usarse para mejorar el modelo. - North Mini Code Free: Durante el período gratuito, los datos recopilados podrán conservarse y utilizarse para mejorar el modelo. No envíes datos personales ni confidenciales. Consulta nuestros [Términos de uso](https://cohere.com/terms-of-use) y nuestra [Política de privacidad](https://cohere.com/privacy). - Nemotron 3 Ultra Free (endpoints gratuitos de NVIDIA): Solo para uso de prueba — no envíes datos personales ni confidenciales. Tu uso se registra con fines de seguridad y para mejorar los productos y servicios de NVIDIA. Los datos de sesión registrados con fines de mejora no están vinculados a tu identidad ni a ningún identificador persistente. Para obtener más información sobre nuestras prácticas de procesamiento de datos, consulta nuestra [Política de privacidad](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Al interactuar con este endpoint, aceptas que recopilemos, registremos y usemos dicha información, así como los [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: Las solicitudes se conservan durante 30 días de acuerdo con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 8dd1401389..bb6ea9bbd3 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -105,6 +105,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ Les modèles gratuits : - DeepSeek V4 Flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - MiMo-V2.5 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Laguna S 2.1 Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. +- Ling-3.0-flash Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - North Mini Code Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Nemotron 3 Ultra Free est disponible sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. - Big Pickle est un modèle stealth gratuit sur OpenCode pour une durée limitée. L'équipe utilise cette période pour recueillir des retours et améliorer le modèle. @@ -263,6 +266,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - DeepSeek V4 Flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - MiMo-V2.5 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - Laguna S 2.1 Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. +- Ling-3.0-flash Free : Pendant sa période gratuite, les données collectées peuvent être utilisées pour améliorer le modèle. - North Mini Code Free : Pendant la période de gratuité, les données collectées peuvent être conservées et utilisées pour améliorer le modèle. Ne transmettez aucune donnée personnelle ou confidentielle. Consultez nos [Conditions d’utilisation](https://cohere.com/terms-of-use) et notre [Politique de confidentialité](https://cohere.com/privacy). - Nemotron 3 Ultra Free (endpoints NVIDIA gratuits) : Réservé à un usage d'essai — n'envoyez pas de données personnelles ou confidentielles. Votre utilisation est journalisée à des fins de sécurité et pour améliorer les produits et services de NVIDIA. Les données de session journalisées à des fins d'amélioration ne sont pas liées à votre identité ni à un quelconque identifiant persistant. Pour plus d'informations sur nos pratiques de traitement des données, consultez notre [Politique de confidentialité](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). En interagissant avec cet endpoint, vous consentez à notre collecte, à notre enregistrement et à notre utilisation de ces informations ainsi qu'aux [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs : Les requêtes sont conservées pendant 30 jours conformément à [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index 5143c71176..d2863f724a 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -114,6 +114,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ I modelli gratuiti: - DeepSeek V4 Flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - MiMo-V2.5 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Laguna S 2.1 Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. +- Ling-3.0-flash Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - North Mini Code Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Nemotron 3 Ultra Free è disponibile su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. - Big Pickle è un modello stealth che è gratuito su OpenCode per un periodo limitato. Il team usa questo periodo per raccogliere feedback e migliorare il modello. @@ -277,6 +280,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - DeepSeek V4 Flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - MiMo-V2.5 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - Laguna S 2.1 Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. +- Ling-3.0-flash Free: durante il periodo gratuito, i dati raccolti possono essere usati per migliorare il modello. - North Mini Code Free: Durante il periodo gratuito, i dati raccolti possono essere conservati e utilizzati per migliorare il modello. Non inviare dati personali o riservati. Consulta i nostri [Termini di utilizzo](https://cohere.com/terms-of-use) e la nostra [Informativa sulla privacy](https://cohere.com/privacy). - Nemotron 3 Ultra Free (endpoint NVIDIA gratuiti): solo per uso di prova — non inviare dati personali o riservati. Il tuo utilizzo viene registrato per finalità di sicurezza e per migliorare i prodotti e i servizi di NVIDIA. I dati di sessione registrati a fini di miglioramento non sono collegati alla tua identità né ad alcun identificatore persistente. Per maggiori informazioni sulle nostre pratiche di trattamento dei dati, consulta la nostra [Informativa sulla privacy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Interagendo con questo endpoint, acconsenti alla nostra raccolta, registrazione e utilizzo di tali informazioni e ai [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: le richieste vengono conservate per 30 giorni in conformità con [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 32709f9bcf..90189c522d 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -105,6 +105,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - MiMo-V2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Laguna S 2.1 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 +- Ling-3.0-flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - North Mini Code Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Ultra Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - MiMo-V2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Laguna S 2.1 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- Ling-3.0-flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - North Mini Code Free: 無料提供期間中、収集されたデータは保持され、モデルの改善に使用される場合があります。個人情報や機密情報を送信しないでください。詳しくは、[利用規約](https://cohere.com/terms-of-use)および[プライバシーポリシー](https://cohere.com/privacy)をご覧ください。 - Nemotron 3 Ultra Free(NVIDIA の無料エンドポイント): 試用専用です — 個人情報や機密データは送信しないでください。お客様の利用は、セキュリティ目的および NVIDIA の製品とサービスの改善のために記録されます。改善目的で記録されたセッションデータは、お客様の身元や永続的な識別子とは関連付けられません。当社のデータ処理慣行の詳細については、[プライバシーポリシー](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)をご覧ください。このエンドポイントを利用することで、お客様はそのような情報の当社による収集、記録、利用、および [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) に同意したものとみなされます。 - OpenAI APIs: リクエストは [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) に従って 30 日間保持されます。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index c9d6a3722f..a1dced6061 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -105,6 +105,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - MiMo-V2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Laguna S 2.1 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. +- Ling-3.0-flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - North Mini Code Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Ultra Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - MiMo-V2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Laguna S 2.1 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- Ling-3.0-flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - North Mini Code Free: 무료 제공 기간 동안 수집된 데이터는 보관되며 모델 개선에 사용될 수 있습니다. 개인 정보나 기밀 정보를 제출하지 마세요. 자세한 내용은 [이용 약관](https://cohere.com/terms-of-use) 및 [개인정보 처리방침](https://cohere.com/privacy)을 참조하세요. - Nemotron 3 Ultra Free(NVIDIA 무료 엔드포인트): 평가판 전용이며 — 개인 정보나 기밀 데이터는 제출하지 마세요. 사용 내역은 보안 목적과 NVIDIA 제품 및 서비스 개선을 위해 기록됩니다. 개선 목적으로 기록된 세션 데이터는 사용자의 신원이나 영구 식별자와 연결되지 않습니다. 당사의 데이터 처리 관행에 대한 자세한 내용은 [개인정보처리방침](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)을 참조하세요. 이 엔드포인트와 상호 작용함으로써 사용자는 당사가 이러한 정보를 수집, 기록, 사용하는 것과 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)에 동의하게 됩니다. - OpenAI APIs: 요청은 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data)에 따라 30일 동안 보관됩니다. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 360827880d..4fdfc8bc0f 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -114,6 +114,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ Gratis-modellene: - DeepSeek V4 Flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - MiMo-V2.5 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Laguna S 2.1 Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. +- Ling-3.0-flash Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - North Mini Code Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Nemotron 3 Ultra Free er tilgjengelig på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. - Big Pickle er en stealth-modell som er gratis på OpenCode i en begrenset periode. Teamet bruker denne tiden til å samle inn tilbakemeldinger og forbedre modellen. @@ -277,6 +280,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - DeepSeek V4 Flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - MiMo-V2.5 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - Laguna S 2.1 Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. +- Ling-3.0-flash Free: I gratisperioden kan innsamlede data brukes til å forbedre modellen. - North Mini Code Free: I gratisperioden kan innsamlede data bli oppbevart og brukt til å forbedre modellen. Ikke send inn personopplysninger eller konfidensielle opplysninger. Se våre [Vilkår for bruk](https://cohere.com/terms-of-use) og vår [Personvernerklæring](https://cohere.com/privacy). - Nemotron 3 Ultra Free (gratis NVIDIA-endepunkter): Kun for prøvebruk — ikke send inn personopplysninger eller konfidensielle data. Bruken din logges av sikkerhetshensyn og for å forbedre NVIDIAs produkter og tjenester. Sesjonsdataene som logges for forbedringsformål, er ikke knyttet til identiteten din eller noen vedvarende identifikator. For mer informasjon om vår databehandlingspraksis, se vår [personvernerklæring](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ved å samhandle med dette endepunktet samtykker du til at vi samler inn, registrerer og bruker slik informasjon, samt til [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: Forespørsler lagres i 30 dager i samsvar med [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 339048b3ae..35a8ee123d 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -114,6 +114,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ Darmowe modele: - DeepSeek V4 Flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - MiMo-V2.5 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Laguna S 2.1 Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. +- Ling-3.0-flash Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - North Mini Code Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Nemotron 3 Ultra Free jest dostępny w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. - Big Pickle to stealth model, który jest darmowy w OpenCode przez ograniczony czas. Zespół wykorzystuje ten czas do zbierania opinii i ulepszania modelu. @@ -277,6 +280,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - DeepSeek V4 Flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - MiMo-V2.5 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - Laguna S 2.1 Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. +- Ling-3.0-flash Free: W czasie darmowego okresu zebrane dane mogą być wykorzystywane do ulepszania modelu. - North Mini Code Free: W okresie bezpłatnego dostępu zebrane dane mogą być przechowywane i wykorzystywane do ulepszania modelu. Nie przesyłaj danych osobowych ani poufnych. Zapoznaj się z naszym [Regulaminem korzystania](https://cohere.com/terms-of-use) i [Polityką prywatności](https://cohere.com/privacy). - Nemotron 3 Ultra Free (darmowe endpointy NVIDIA): Tylko do użytku próbnego — nie przesyłaj danych osobowych ani poufnych. Twoje korzystanie jest rejestrowane w celach bezpieczeństwa oraz w celu ulepszania produktów i usług NVIDIA. Rejestrowane dane sesji wykorzystywane do celów ulepszania nie są powiązane z Twoją tożsamością ani żadnym trwałym identyfikatorem. Aby uzyskać więcej informacji o naszych praktykach przetwarzania danych, zapoznaj się z naszą [Polityką prywatności](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Korzystając z tego endpointu, wyrażasz zgodę na gromadzenie, rejestrowanie i wykorzystywanie przez nas takich informacji oraz na [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: Żądania są przechowywane przez 30 dni zgodnie z [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 0866b0f746..1ddde6392e 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -105,6 +105,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ Os modelos gratuitos: - DeepSeek V4 Flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - MiMo-V2.5 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Laguna S 2.1 Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. +- Ling-3.0-flash Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - North Mini Code Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Nemotron 3 Ultra Free está disponível no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. - Big Pickle é um modelo stealth que está gratuito no OpenCode por tempo limitado. A equipe está usando esse período para coletar feedback e melhorar o modelo. @@ -263,6 +266,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - DeepSeek V4 Flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - MiMo-V2.5 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - Laguna S 2.1 Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. +- Ling-3.0-flash Free: Durante seu período gratuito, os dados coletados podem ser usados para melhorar o modelo. - North Mini Code Free: Durante o período gratuito, os dados coletados poderão ser retidos e usados para aprimorar o modelo. Não envie dados pessoais ou confidenciais. Consulte nossos [Termos de Uso](https://cohere.com/terms-of-use) e nossa [Política de Privacidade](https://cohere.com/privacy). - Nemotron 3 Ultra Free (endpoints gratuitos da NVIDIA): Apenas para uso de avaliação — não envie dados pessoais ou confidenciais. Seu uso é registrado para fins de segurança e para melhorar os produtos e serviços da NVIDIA. Os dados de sessão registrados para fins de melhoria não estão vinculados à sua identidade nem a qualquer identificador persistente. Para mais informações sobre nossas práticas de processamento de dados, consulte nossa [Política de Privacidade](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Ao interagir com este endpoint, você consente com a nossa coleta, registro e uso dessas informações e com os [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: As solicitações são retidas por 30 dias de acordo com [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index dfea9e3f12..4b0e8d231a 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -114,6 +114,7 @@ OpenCode Zen работает как любой другой провайдер | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - MiMo-V2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Laguna S 2.1 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- Ling-3.0-flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - North Mini Code Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Ultra Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -277,6 +280,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - MiMo-V2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Laguna S 2.1 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- Ling-3.0-flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - North Mini Code Free: В течение бесплатного периода собранные данные могут храниться и использоваться для улучшения модели. Не отправляйте персональные или конфиденциальные данные. Ознакомьтесь с нашими [Условиями использования](https://cohere.com/terms-of-use) и [Политикой конфиденциальности](https://cohere.com/privacy). - Nemotron 3 Ultra Free (бесплатные эндпоинты NVIDIA): только для пробного использования — не отправляйте персональные или конфиденциальные данные. Использование логируется в целях безопасности и для улучшения продуктов и сервисов NVIDIA. Логируемые данные сессии, используемые в целях улучшения, не связаны с вашей личностью или каким-либо постоянным идентификатором. Подробнее о наших практиках обработки данных см. в нашей [Политике конфиденциальности](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). Взаимодействуя с этим эндпоинтом, вы соглашаетесь на сбор, запись и использование нами такой информации, а также с [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: запросы хранятся 30 дней в соответствии с [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 2da078f43e..f7b151784c 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -107,6 +107,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -135,6 +136,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -208,6 +210,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - MiMo-V2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Laguna S 2.1 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล +- Ling-3.0-flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - North Mini Code Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Ultra Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -265,6 +268,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - MiMo-V2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Laguna S 2.1 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- Ling-3.0-flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - North Mini Code Free: ในช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกเก็บรักษาและนำไปใช้เพื่อปรับปรุงโมเดล โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลที่เป็นความลับ ดู[ข้อกำหนดการใช้งาน](https://cohere.com/terms-of-use)และ[นโยบายความเป็นส่วนตัว](https://cohere.com/privacy)ของเรา - Nemotron 3 Ultra Free (endpoint ฟรีของ NVIDIA): ใช้สำหรับการทดลองเท่านั้น — โปรดอย่าส่งข้อมูลส่วนบุคคลหรือข้อมูลลับ การใช้งานของคุณจะถูกบันทึกเพื่อวัตถุประสงค์ด้านความปลอดภัยและเพื่อปรับปรุงผลิตภัณฑ์และบริการของ NVIDIA ข้อมูลเซสชันที่บันทึกไว้เพื่อวัตถุประสงค์ในการปรับปรุงจะไม่เชื่อมโยงกับตัวตนของคุณหรือตัวระบุถาวรใด ๆ สำหรับข้อมูลเพิ่มเติมเกี่ยวกับแนวปฏิบัติในการประมวลผลข้อมูลของเรา โปรดดู [นโยบายความเป็นส่วนตัว](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) ของเรา การโต้ตอบกับ endpoint นี้ถือว่าคุณยินยอมให้เราเก็บรวบรวม บันทึก และใช้ข้อมูลดังกล่าว รวมถึง [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) - OpenAI APIs: คำขอจะถูกเก็บไว้เป็นเวลา 30 วันตาม [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 90e9865598..f26b6706b7 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -105,6 +105,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına - DeepSeek V4 Flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - MiMo-V2.5 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Laguna S 2.1 Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. +- Ling-3.0-flash Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - North Mini Code Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Nemotron 3 Ultra Free, sınırlı bir süre için OpenCode'da ücretsizdir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. - Big Pickle, sınırlı bir süre için OpenCode'da ücretsiz olan gizli bir modeldir. Ekip bu süreyi geri bildirim toplamak ve modeli iyileştirmek için kullanıyor. @@ -263,6 +266,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - DeepSeek V4 Flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - MiMo-V2.5 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - Laguna S 2.1 Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. +- Ling-3.0-flash Free: Ücretsiz döneminde toplanan veriler modeli iyileştirmek için kullanılabilir. - North Mini Code Free: Ücretsiz kullanım süresi boyunca toplanan veriler saklanabilir ve modeli geliştirmek için kullanılabilir. Kişisel veya gizli veriler göndermeyin. [Kullanım Koşullarımıza](https://cohere.com/terms-of-use) ve [Gizlilik Politikamıza](https://cohere.com/privacy) bakın. - Nemotron 3 Ultra Free (ücretsiz NVIDIA uç noktaları): Yalnızca deneme amaçlıdır — kişisel veya gizli veri göndermeyin. Kullanımınız güvenlik amacıyla ve NVIDIA ürünlerini ve hizmetlerini geliştirmek için kaydedilir. Geliştirme amacıyla kaydedilen oturum verileri kimliğinizle veya herhangi bir kalıcı tanımlayıcıyla ilişkilendirilmez. Veri işleme uygulamalarımız hakkında daha fazla bilgi için [Gizlilik Politikamıza](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) bakın. Bu uç noktayla etkileşime geçerek, bu tür bilgileri toplamamıza, kaydetmemize ve kullanmamıza ve [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf) koşullarına onay vermiş olursunuz. - OpenAI APIs: İstekler [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) uyarınca 30 gün boyunca saklanır. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index c330f0b0bd..d040cfde43 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -114,6 +114,7 @@ You can also access our models through the following API endpoints. | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -144,6 +145,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -217,6 +219,7 @@ The free models: - DeepSeek V4 Flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - MiMo-V2.5 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Laguna S 2.1 Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. +- Ling-3.0-flash Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - North Mini Code Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Nemotron 3 Ultra Free is available on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. - Big Pickle is a stealth model that's free on OpenCode for a limited time. The team is using this time to collect feedback and improve the model. @@ -277,6 +280,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - DeepSeek V4 Flash Free: During its free period, collected data may be used to improve the model. - MiMo-V2.5 Free: During its free period, collected data may be used to improve the model. - Laguna S 2.1 Free: During its free period, collected data may be used to improve the model. +- Ling-3.0-flash Free: During its free period, collected data may be used to improve the model. - North Mini Code Free: During its free period, collected data may be retained and used to improve the model. Do not submit personal or confidential data. See our [Terms of Use](https://cohere.com/terms-of-use) and [Privacy Policy](https://cohere.com/privacy). - Nemotron 3 Ultra Free (NVIDIA free endpoints): Trial use only — do not submit personal or confidential data. Your use is logged for security purposes and to improve NVIDIA products and services. The logged session data for improvement purposes is not linked to your identity or any persistent identifier. For more information about our data processing practices, see our [Privacy Policy](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). By interacting with this endpoint, you consent to our collection, recording, and use of such information and the [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf). - OpenAI APIs: Requests are retained for 30 days in accordance with [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data). diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index a22a95a1b8..d01940efa6 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -105,6 +105,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -133,6 +134,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -206,6 +208,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - MiMo-V2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Laguna S 2.1 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- Ling-3.0-flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - North Mini Code Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Ultra Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -263,6 +266,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free:在免费期间,收集的数据可能会被用于改进模型。 - MiMo-V2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - Laguna S 2.1 Free:在免费期间,收集的数据可能会被用于改进模型。 +- Ling-3.0-flash Free:在免费期间,收集的数据可能会被用于改进模型。 - North Mini Code Free:免费期间,所收集的数据可能会被保留并用于改进模型。请勿提交个人或机密数据。请参阅我们的[使用条款](https://cohere.com/terms-of-use)和[隐私政策](https://cohere.com/privacy)。 - Nemotron 3 Ultra Free(NVIDIA 免费端点):仅供试用 — 请勿提交个人或机密数据。出于安全目的以及为改进 NVIDIA 产品和服务,系统会记录你的使用情况。出于改进目的而记录的会话数据不会与你的身份或任何持久标识符相关联。有关我们数据处理实践的更多信息,请参阅我们的[隐私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。与此端点进行交互,即表示你同意我们收集、记录和使用此类信息,并同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。 - OpenAI APIs:请求会根据 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 20ca597411..d89c532e16 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -109,6 +109,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | Big Pickle | big-pickle | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | MiMo-V2.5 Free | mimo-v2.5-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Laguna S 2.1 Free | laguna-s-2.1-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | +| Ling-3.0-flash Free | ling-3.0-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | North Mini Code Free | north-mini-code-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | Nemotron 3 Ultra Free | nemotron-3-ultra-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | | DeepSeek V4 Flash Free | deepseek-v4-flash-free | `https://opencode.ai/zen/v1/chat/completions` | `@ai-sdk/openai-compatible` | @@ -138,6 +139,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Flash Free | Free | Free | Free | - | | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | +| Ling-3.0-flash Free | Free | Free | Free | - | | North Mini Code Free | Free | Free | Free | - | | Nemotron 3 Ultra Free | Free | Free | Free | - | | MiniMax M3 | $0.30 | $1.20 | $0.06 | - | @@ -211,6 +213,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - MiMo-V2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Laguna S 2.1 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 +- Ling-3.0-flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - North Mini Code Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Nemotron 3 Ultra Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Big Pickle 是一個隱身模型,在 OpenCode 上限時免費提供。團隊正在利用這段時間收集回饋並改進模型。 @@ -269,6 +272,7 @@ https://opencode.ai/zen/v1/models - DeepSeek V4 Flash Free: 在免費期間,收集到的資料可能會用於改進模型。 - MiMo-V2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 - Laguna S 2.1 Free: 在免費期間,收集到的資料可能會用於改進模型。 +- Ling-3.0-flash Free: 在免費期間,收集到的資料可能會用於改進模型。 - North Mini Code Free:免費期間,所收集的資料可能會被保留並用於改進模型。請勿提交個人或機密資料。請參閱我們的[使用條款](https://cohere.com/terms-of-use)和[隱私權政策](https://cohere.com/privacy)。 - Nemotron 3 Ultra Free(NVIDIA 免費端點):僅供試用 — 請勿提交個人或機密資料。基於安全目的以及為了改進 NVIDIA 產品與服務,系統會記錄你的使用情況。基於改進目的而記錄的工作階段資料不會與你的身分或任何持久識別碼相關聯。有關我們資料處理實務的更多資訊,請參閱我們的[隱私政策](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。與此端點進行互動,即表示你同意我們收集、記錄與使用此類資訊,並同意 [NVIDIA API Trial Terms of Service](https://assets.ngc.nvidia.com/products/api-catalog/legal/NVIDIA%20API%20Trial%20Terms%20of%20Service.pdf)。 - OpenAI APIs: 請求會依據 [OpenAI's Data Policies](https://platform.openai.com/docs/guides/your-data) 保留 30 天。 From 37c263e1536f728064dcf78a5284251427b85d10 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 10:49:16 +0800 Subject: [PATCH 18/48] feat(app): project current server state (#38459) --- .../e2e/regression/remote-tab-busy.spec.ts | 6 +- .../e2e/regression/review-open-file.spec.ts | 2 +- .../review-state-persistence.spec.ts | 2 +- packages/app/e2e/utils/mock-server.ts | 7 +- packages/app/e2e/utils/sse-transport.ts | 8 + .../app/src/components/prompt-input-v2.tsx | 4 +- packages/app/src/components/prompt-input.tsx | 2 +- .../components/prompt-input/submit.test.ts | 24 + .../app/src/components/prompt-input/submit.ts | 98 ++-- .../status-popover-indicator.test.ts | 2 +- .../components/status-popover-indicator.ts | 7 +- packages/app/src/context/directory-sync.ts | 11 +- .../src/context/global-sync/bootstrap.test.ts | 192 +++++--- .../app/src/context/global-sync/bootstrap.ts | 282 ++++++++---- .../src/context/global-sync/child-store.ts | 1 + .../context/global-sync/event-reducer.test.ts | 10 +- .../src/context/global-sync/event-reducer.ts | 80 +++- .../app/src/context/global-sync/mcp.test.ts | 20 + packages/app/src/context/global-sync/mcp.ts | 5 +- .../context/global-sync/session-cache.test.ts | 19 +- .../src/context/global-sync/session-cache.ts | 16 +- .../src/context/global-sync/session-load.ts | 36 +- packages/app/src/context/global-sync/types.ts | 27 +- .../app/src/context/global-sync/utils.test.ts | 122 ++++- packages/app/src/context/global-sync/utils.ts | 174 +++++-- .../context/server-session-v2-reducer.test.ts | 148 ++++++ .../src/context/server-session-v2-reducer.ts | 434 ++++++++++++++++++ .../app/src/context/server-session.test.ts | 157 +++++++ packages/app/src/context/server-session.ts | 285 ++++++++++-- packages/app/src/context/server-sync.test.ts | 173 +++++-- packages/app/src/context/server-sync.tsx | 271 +++++++++-- packages/app/src/pages/session.tsx | 80 +--- packages/app/src/utils/server-compat.test.ts | 15 +- packages/app/src/utils/server-compat.ts | 2 +- .../app/src/utils/session-message.test.ts | 200 ++++++++ packages/app/src/utils/session-message.ts | 348 ++++++++++++++ packages/app/src/utils/session.test.ts | 94 ++++ packages/app/src/utils/session.ts | 37 ++ 38 files changed, 2923 insertions(+), 478 deletions(-) create mode 100644 packages/app/src/context/server-session-v2-reducer.test.ts create mode 100644 packages/app/src/context/server-session-v2-reducer.ts create mode 100644 packages/app/src/utils/session-message.test.ts create mode 100644 packages/app/src/utils/session-message.ts create mode 100644 packages/app/src/utils/session.test.ts create mode 100644 packages/app/src/utils/session.ts diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 119fc7ee2d..7692928f9d 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -105,5 +105,9 @@ function json(route: Route, body: unknown, status = 200) { } function sse(route: Route) { - return route.fulfill({ status: 200, contentType: "text/event-stream", body: ": ok\n\n" }) + return route.fulfill({ + status: 200, + contentType: "text/event-stream", + body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`, + }) } diff --git a/packages/app/e2e/regression/review-open-file.spec.ts b/packages/app/e2e/regression/review-open-file.spec.ts index 25ebd3a37a..04e6d2cced 100644 --- a/packages/app/e2e/regression/review-open-file.spec.ts +++ b/packages/app/e2e/regression/review-open-file.spec.ts @@ -133,7 +133,7 @@ test("opens and searches project files inline", async ({ page }) => { await expect(panel.getByRole("tab", { name: "nested.ts" })).toHaveCount(1) await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") await expect(sidebarToggle).toBeDisabled() - await panel.getByRole("tab", { name: /Review/ }).click() + await panel.locator("#session-side-panel-review-tab").click() await expect(sidebarToggle).toBeEnabled() await panel.getByRole("tab", { name: "Open file" }).click() await page.keyboard.press("Control+w") diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index aa42f1bb51..6c27ad6467 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -46,7 +46,7 @@ test("restores review mode and selected file per session", async ({ page }) => { async function selectMode(page: Page, current: string, next: string) { await page.getByRole("button", { name: current }).click() - await page.getByRole("option", { name: next }).click() + await page.getByRole("option", { name: next }).dispatchEvent("click") } async function selectFile(page: Page, file: string) { diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 834ae7e808..78f60bbbca 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -61,7 +61,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { route, path === "/api/event" ? [{ id: "evt_mock_connected", type: "server.connected", data: {} }, ...(events?.map(currentEvent) ?? [])] - : events, + : [ + ...(path === "/global/event" + ? [{ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } }] + : []), + ...(events ?? []), + ], config.eventRetry, ) } diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index 15c3577279..b0e3b74c6d 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -197,6 +197,14 @@ export async function installSseTransport( controller.enqueue( encoder.encode(frame({ id: `evt_mock_connected_${id}`, type: "server.connected", data: {} })), ) + if (url.pathname === "/global/event") + controller.enqueue( + encoder.encode( + frame({ + payload: { id: `evt_mock_connected_${id}`, type: "server.connected", properties: {} }, + }), + ), + ) request.signal.addEventListener( "abort", () => { diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 921d8ff45c..13df57bec2 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -310,7 +310,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): ) const resources = createMemo(() => Object.values(sync().data.mcp_resource).map((resource) => ({ - id: `resource:${resource.client}:${resource.uri}`, + id: `resource:${resource.server}:${resource.uri}`, kind: "resource" as const, label: `@${resource.name}`, path: resource.uri, @@ -327,7 +327,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): source: { type: "resource" as const, text: { value: `@${resource.name}`, start: 0, end: resource.name.length + 1 }, - clientName: resource.client, + clientName: resource.server, uri: resource.uri, }, }, diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index bcc5acc0bb..3842b08791 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -591,7 +591,7 @@ export const PromptInput: Component = (props) => { type: "resource", name: resource.name, uri: resource.uri, - client: resource.client, + client: resource.server, display: resource.name, description: resource.description, mime: resource.mimeType, diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index f563a50982..834fc4795a 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -19,6 +19,7 @@ const optimistic: Array<{ }> = [] const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} +const sessionDirectories: Record = {} const promoted: Array<{ directory: string; sessionID: string }> = [] const sentShell: string[] = [] const syncedDirectories: string[] = [] @@ -89,6 +90,27 @@ const clientFor = (directory: string) => { } } +const api = { + session: { + async create(input: { location: { directory: string } }) { + await createSessionGate + createdSessions.push(input.location.directory) + const session = { + id: `session-${createdSessions.length}`, + title: `New session ${createdSessions.length}`, + } + sessionDirectories[session.id] = input.location.directory + return session + }, + async shell(input: { sessionID: string }) { + sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main") + }, + async prompt() {}, + async command() {}, + async interrupt() {}, + }, +} + beforeAll(async () => { const rootClient = clientFor("/repo/main") @@ -171,6 +193,7 @@ beforeAll(async () => { const sdk = { scope: "local", directory: "/repo/main", + api, client: rootClient, url: "http://localhost:4096", createClient(opts: any) { @@ -265,6 +288,7 @@ beforeEach(() => { permissionServer = "server-a" createSessionGate = undefined for (const key of Object.keys(storedSessions)) delete storedSessions[key] + for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] }) describe("prompt submit worktree selection", () => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 203722fc6c..2cd30da3ef 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -20,6 +20,8 @@ import { setCursorPosition } from "./editor-dom" import { formatServerError } from "@/utils/server-errors" import { ScopedKey } from "@/utils/server-scope" import { createPromptSubmissionState } from "./submission-state" +import { normalizeSessionInfo } from "@/utils/session" +import { Event } from "@opencode-ai/schema/event" type PendingPrompt = { abort: AbortController @@ -39,7 +41,7 @@ export type FollowupDraft = { } type FollowupSendInput = { - client: DirectorySDK["client"] + api: DirectorySDK["api"]["session"] serverSync: ServerSync sync: DirectorySync draft: FollowupDraft @@ -81,19 +83,21 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.client.session.command({ + const messageID = Identifier.ascending("message") + await input.api.command({ sessionID: input.draft.sessionID, + id: messageID, command: cmd, arguments: tail.join(" "), agent: input.draft.agent, - model: `${input.draft.model.providerID}/${input.draft.model.modelID}`, - variant: input.draft.variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, + model: { + id: input.draft.model.modelID, + providerID: input.draft.model.providerID, + variant: input.draft.variant, + }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, + name: attachment.filename, })), }) return true @@ -152,13 +156,36 @@ export async function sendFollowupDraft(input: FollowupSendInput) { return false } - await input.client.session.promptAsync({ + await input.api.prompt({ sessionID: input.draft.sessionID, + id: messageID, agent: input.draft.agent, model: input.draft.model, - messageID, - parts: requestParts, variant: input.draft.variant, + text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + files: requestParts.flatMap((part) => { + if (part.type !== "file") return [] + const text = part.source?.text + return [ + { + uri: part.url, + name: part.filename, + mention: text ? { start: text.start, end: text.end, text: text.value } : undefined, + }, + ] + }), + agents: requestParts.flatMap((part) => + part.type === "agent" + ? [ + { + name: part.name, + mention: part.source + ? { start: part.source.start, end: part.source.end, text: part.source.value } + : undefined, + }, + ] + : [], + ), }) return true } catch (err) { @@ -210,6 +237,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { const pendingKey = (sessionID: string) => ScopedKey.from(sdk().scope, sessionID) const errorMessage = (err: unknown) => { + if (err && typeof err === "object" && "message" in err && typeof err.message === "string") return err.message if (err && typeof err === "object" && "data" in err) { const data = (err as { data?: { message?: string } }).data if (data?.message) return data.message @@ -235,9 +263,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { return Promise.resolve() } return sdk() - .client.session.abort({ - sessionID, - }) + .api.session.interrupt({ sessionID }) .catch(() => {}) } @@ -364,9 +390,13 @@ export function createPromptSubmit(input: PromptSubmitInput) { let session = input.info() if (!session && isNewSession) { - const created = await client.session - .create() - .then((x) => x.data ?? undefined) + const created = await sdk() + .api.session.create({ + agent: currentAgent.name, + model: { id: currentModel.id, providerID: currentModel.provider.id, variant }, + location: { directory: sessionDirectory }, + }) + .then(normalizeSessionInfo) .catch((err) => { showToast({ title: language.t("prompt.toast.sessionCreateFailed.title"), @@ -450,12 +480,14 @@ export function createPromptSubmit(input: PromptSubmitInput) { if (mode === "shell") { clearInput() - client.session - .shell({ + const eventID = Event.ID.create() + sdk() + .api.session.shell({ sessionID: session.id, + id: eventID, + command: text, agent, model, - command: text, }) .catch((err) => { showToast({ @@ -473,23 +505,23 @@ export function createPromptSubmit(input: PromptSubmitInput) { const customCommand = sync().data.command.find((c) => c.name === commandName) if (customCommand) { clearInput() - client.session - .command({ + const messageID = Identifier.ascending("message") + serverSync().session.set("session_status", session.id, { type: "busy" }) + sdk() + .api.session.command({ sessionID: session.id, + id: messageID, command: commandName, arguments: args.join(" "), agent, - model: `${model.providerID}/${model.modelID}`, - variant, - parts: images.map((attachment) => ({ - id: Identifier.ascending("part"), - type: "file" as const, - mime: attachment.mime, - url: attachment.dataUrl, - filename: attachment.filename, + model: { id: model.modelID, providerID: model.providerID, variant }, + files: images.map((attachment) => ({ + uri: attachment.dataUrl, + name: attachment.filename, })), }) .catch((err) => { + serverSync().session.set("session_status", session.id, { type: "idle" }) showToast({ title: language.t("prompt.toast.commandSendFailed.title"), description: formatServerError(err, language.t, language.t("common.requestFailed")), @@ -573,7 +605,7 @@ export function createPromptSubmit(input: PromptSubmitInput) { } void sendFollowupDraft({ - client, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), draft, diff --git a/packages/app/src/components/status-popover-indicator.test.ts b/packages/app/src/components/status-popover-indicator.test.ts index e3c62d2a95..c1c57b9d70 100644 --- a/packages/app/src/components/status-popover-indicator.test.ts +++ b/packages/app/src/components/status-popover-indicator.test.ts @@ -26,7 +26,7 @@ describe("hasNonBlockingServiceIssue", () => { expect(hasNonBlockingServiceIssue({ mcp: ["failed"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_auth"], lsp: [] })).toBe(true) expect(hasNonBlockingServiceIssue({ mcp: ["needs_client_registration"], lsp: [] })).toBe(true) - expect(hasNonBlockingServiceIssue({ mcp: ["connected", "disabled"], lsp: [] })).toBe(false) + expect(hasNonBlockingServiceIssue({ mcp: ["connected", "pending", "disabled"], lsp: [] })).toBe(false) }) test("detects LSP failures that do not block chatting", () => { diff --git a/packages/app/src/components/status-popover-indicator.ts b/packages/app/src/components/status-popover-indicator.ts index efb7473753..d89f90febb 100644 --- a/packages/app/src/components/status-popover-indicator.ts +++ b/packages/app/src/components/status-popover-indicator.ts @@ -1,11 +1,12 @@ -import type { LspStatus, McpStatus } from "@opencode-ai/sdk/v2/client" +import type { LspStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" export function hasNonBlockingServiceIssue(input: { - mcp: Array + mcp: Array lsp: Array }) { return ( - input.mcp.some((status) => status !== "connected" && status !== "disabled") || + input.mcp.some((status) => status !== "connected" && status !== "pending" && status !== "disabled") || input.lsp.some((status) => status === "error") ) } diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index 68e6b19cef..ca6df85a53 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -5,6 +5,7 @@ import { produce, reconcile, type SetStoreFunction } from "solid-js/store" import type { createServerSdkContext } from "./server-sdk" import type { createServerSyncContextInner } from "./server-sync" import type { State } from "./global-sync/types" +import { normalizeSessionInfo } from "@/utils/session" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const sessionFields = new Set([ @@ -15,6 +16,7 @@ const sessionFields = new Set([ "permission", "question", "message", + "session_message", "part", "part_text_accum_delta", ]) @@ -114,7 +116,6 @@ export const createDirSyncContext = ( await serverSync.session.sync(sessionID, options) index(sessionID) }, - diff: serverSync.session.diff, todo: serverSync.session.todo, history: serverSync.session.history, evict(sessionID: string) { @@ -123,9 +124,9 @@ export const createDirSyncContext = ( fetch: async (count = 10) => { const [store, setStore] = current() setStore("limit", (value) => value + count) - const response = await client.session.list() - const sessions = (response.data ?? []) - .filter((session) => !!session?.id) + const response = await serverSDK.api.session.list({ directory, limit: store.limit, order: "desc" }) + const sessions = response.data + .map(normalizeSessionInfo) .sort((a, b) => cmp(a.id, b.id)) .slice(0, store.limit) sessions.forEach(serverSync.session.remember) @@ -133,7 +134,7 @@ export const createDirSyncContext = ( }, more: createMemo(() => current()[0].session.length >= current()[0].limit), archive: async (sessionID: string) => { - await serverSDK.client.session.update({ sessionID, time: { archived: Date.now() } }) + await serverSDK.api.session.archive({ sessionID, directory }) current()[1]( "session", produce((draft) => { diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index 40735fb822..dceab47d86 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -1,14 +1,39 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" -import type { Config, OpencodeClient, Project, Session } from "@opencode-ai/sdk/v2/client" +import type { Config, OpencodeClient, Project } from "@opencode-ai/sdk/v2/client" +import type { AgentApi, CatalogApi, CommandApi, ProjectApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" -import { bootstrapDirectory, loadPathQuery, loadProvidersQuery } from "./bootstrap" +import { + bootstrapDirectory, + loadAgentsQuery, + loadCommands, + loadPathQuery, + loadProjectsQuery, + loadProvidersQuery, + loadReferencesQuery, +} from "./bootstrap" import type { State, VcsCache } from "./types" -import { createServerSession } from "../server-session" import { ServerScope } from "@/utils/server-scope" +import type { ServerApi } from "@/utils/server" const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse +const api = { + agent: { list: async () => ({ location: {}, data: [] }) }, + provider: { list: async () => ({ location: {}, data: [] }) }, + model: { + list: async () => ({ location: {}, data: [] }), + default: async () => ({ location: {}, data: null }), + }, + permission: { request: { list: async () => ({ location: {}, data: [] }) } }, + project: { + list: async () => [], + current: async () => ({ id: "project", directory: "/project" }), + }, + question: { request: { list: async () => ({ location: {}, data: [] }) } }, + reference: { list: async () => ({ location: {}, data: [] }) }, + vcs: { get: async () => ({ location: {}, data: {} }) }, +} as unknown as ServerApi function directoryState() { return createStore({ @@ -41,6 +66,7 @@ function directoryState() { vcs: undefined, limit: 5, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, }) @@ -64,7 +90,6 @@ describe("bootstrapDirectory", () => { sdk: { app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, config: { get: async () => ({ data: {} }) }, - session: { status: async () => ({ data: {} }) }, vcs: { get: async () => ({ data: undefined }) }, command: { list: async () => { @@ -83,6 +108,7 @@ describe("bootstrapDirectory", () => { }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, + api, store, setStore, vcsCache: { setStore() {} } as unknown as VcsCache, @@ -99,78 +125,108 @@ describe("bootstrapDirectory", () => { expect(mcpReads).toEqual([]) }) - test("seeds session status even while warming session info stalls", async () => { - const [store, setStore] = directoryState() - const stalled = Promise.withResolvers() - const client = { - app: { agents: async () => ({ data: [{ name: "build", mode: "primary" }] }) }, - config: { get: async () => ({ data: {} }) }, - session: { - status: async () => ({ data: { ses_busy: { type: "busy" } } }), - get: () => stalled.promise, - }, - vcs: { get: async () => ({ data: undefined }) }, - command: { list: async () => ({ data: [] }) }, - permission: { list: async () => ({ data: [] }) }, - question: { list: async () => ({ data: [] }) }, - v2: { reference: { list: async () => ({ data: { data: [] } }) } }, - mcp: { status: async () => ({ data: {} }) }, - provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, - } as unknown as OpencodeClient - const session = createServerSession(client) - const stale: Session = { - id: "ses_stale", - slug: "ses_stale", - projectID: "project", - directory: "/project", - title: "stale", - version: "1", - time: { created: 1, updated: 1 }, - } - session.remember(stale) - session.set("session_status", stale.id, { type: "busy" }) - - await bootstrapDirectory({ - directory: "/project", - scope: ServerScope.local, - mcp: false, - global: { - config: {} satisfies Config, - path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, - project: [{ id: "project", worktree: "/project" } as Project], - provider, - }, - sdk: client, - store, - setStore, - vcsCache: { setStore() {} } as unknown as VcsCache, - loadSessions() {}, - translate: (key) => key, - queryClient: new QueryClient(), - session, - }) - - const deadline = Date.now() + 500 - while (!session.data.session_working("ses_busy") && Date.now() < deadline) { - await new Promise((resolve) => setTimeout(resolve, 10)) - } - - expect(session.data.session_status["ses_busy"]?.type).toBe("busy") - expect(session.data.session_status[stale.id]).toBeUndefined() - }) }) describe("query keys", () => { test("partitions identical directories by server scope", () => { - const client = {} as OpencodeClient + const client = {} as Parameters[2] + const api = {} as CatalogApi const remote = "https://debian.example" as typeof ServerScope.local expect([...loadPathQuery(ServerScope.local, "/repo", client).queryKey]).toEqual(["local", "/repo", "path"]) expect([...loadPathQuery(remote, "/repo", client).queryKey]).toEqual(["https://debian.example", "/repo", "path"]) - expect([...loadProvidersQuery(remote, null, client).queryKey]).toEqual([ - "https://debian.example", - null, - "providers", + expect([...loadProvidersQuery(remote, null, api).queryKey]).toEqual(["https://debian.example", null, "providers"]) + }) + + test("loads the current provider and model catalog", async () => { + const calls: unknown[] = [] + const api = { + provider: { + list: async (input: unknown) => { + calls.push(["provider", input]) + return { location: {}, data: [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] } + }, + }, + model: { + list: async (input: unknown) => { + calls.push(["model", input]) + return { location: {}, data: [] } + }, + default: async (input: unknown) => { + calls.push(["default", input]) + return { location: {}, data: null } + }, + }, + } as unknown as CatalogApi + + const result = await new QueryClient().fetchQuery(loadProvidersQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([ + ["provider", { location: { directory: "/repo" } }], + ["model", { location: { directory: "/repo" } }], + ["default", { location: { directory: "/repo" } }], ]) + expect(result.connected).toEqual(["openai"]) + }) + + test("loads agents from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [] } + }, + } as unknown as AgentApi + + const result = await new QueryClient().fetchQuery(loadAgentsQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([]) + }) + + test("loads commands from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { + location: {}, + data: [{ name: "review", template: "Review files", source: "command" as const }], + } + }, + } as unknown as CommandApi + + const result = await loadCommands("/repo", api) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }]) + }) + + test("loads projects from the current endpoint", async () => { + const api = { + list: async () => [ + { id: "b", worktree: "/b", time: { created: 1, updated: 1 }, sandboxes: [] }, + { id: "a", worktree: "/a", time: { created: 1, updated: 1 }, sandboxes: [] }, + ], + } as unknown as ProjectApi + + const result = await new QueryClient().fetchQuery(loadProjectsQuery(ServerScope.local, api)) + + expect(result.map((project) => project.id)).toEqual(["a", "b"]) + }) + + test("loads references from the current location-scoped endpoint", async () => { + const calls: unknown[] = [] + const api = { + list: async (input: unknown) => { + calls.push(input) + return { location: {}, data: [{ name: "AGENTS.md", path: "/repo/AGENTS.md", source: "instructions" }] } + }, + } as unknown as ReferenceApi + + const result = await new QueryClient().fetchQuery(loadReferencesQuery(ServerScope.local, "/repo", api)) + + expect(calls).toEqual([{ location: { directory: "/repo" } }]) + expect(result).toHaveLength(1) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index c63b702522..4c527a9580 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -9,6 +9,26 @@ import type { ReferenceInfo, Session, } from "@opencode-ai/sdk/v2/client" +import type { + AgentListInput, + AgentListOutput, + CatalogApi, + CommandInfo, + CommandListInput, + CommandListOutput, + McpApi, + PathGetInput, + PathGetOutput, + PermissionApi, + ProjectCurrentInput, + ProjectCurrentOutput, + ProjectListOutput, + QuestionApi, + ReferenceListInput, + ReferenceListOutput, + SessionApi, + VcsApi, +} from "@opencode-ai/client/promise" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" import { retry } from "@opencode-ai/core/util/retry" @@ -16,12 +36,20 @@ import { batch } from "solid-js" import { produce, reconcile, type SetStoreFunction, type Store } from "solid-js/store" import type { State, VcsCache } from "./types" import type { ServerSession } from "../server-session" -import { cmp, normalizeAgentList, normalizeProviderList } from "./utils" +import { + cmp, + normalizeAgentList, + normalizePermissionRequest, + normalizeProjectInfo, + normalizeProviderList, +} from "./utils" import { formatServerError } from "@/utils/server-errors" import { QueryClient, queryOptions } from "@tanstack/solid-query" import { loadMcpQuery, loadMcpResourcesQuery } from "../server-sync" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { ScopedKey, type ServerScope } from "@/utils/server-scope" +import { normalizeSessionInfo } from "@/utils/session" +import type { ServerProtocol } from "@/utils/server-protocol" type GlobalStore = { ready: boolean @@ -88,15 +116,25 @@ export const loadGlobalConfigQuery = (scope: ServerScope, sdk: OpencodeClient) = queryFn: () => retry(() => sdk.global.config.get().then((x) => x.data!)), }) -export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => +type ProjectApi = { + readonly list: () => Promise + readonly current: (input?: ProjectCurrentInput) => Promise +} + +type PathApi = { + readonly get: (input?: PathGetInput) => Promise +} + +export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => queryOptions({ queryKey: [scope, "project"], queryFn: () => retry(() => - sdk.project.list().then((x) => { - return (x.data ?? []) + api.list().then((projects) => { + return projects .filter((p) => !!p?.id) .filter((p) => !!p.worktree && !p.worktree.includes("opencode-test")) + .map(normalizeProjectInfo) .slice() .sort((a, b) => cmp(a.id, b.id)) }), @@ -105,6 +143,8 @@ export const loadProjectsQuery = (scope: ServerScope, sdk: OpencodeClient) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient + serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi } + protocol?: Promise scope: ServerScope requestFailedTitle: string translate: (key: string, vars?: Record) => string @@ -114,11 +154,14 @@ export async function bootstrapGlobal(input: { }) { const slow = [ () => input.queryClient.fetchQuery(loadGlobalConfigQuery(input.scope, input.serverSDK)), - () => input.queryClient.fetchQuery(loadProvidersQuery(input.scope, null, input.serverSDK)), - () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverSDK)), + () => + input.queryClient.fetchQuery( + loadProvidersQuery(input.scope, null, input.serverAPI, input.serverSDK, input.protocol), + ), + () => input.queryClient.fetchQuery(loadPathQuery(input.scope, null, input.serverAPI.path)), () => input.queryClient - .fetchQuery(loadProjectsQuery(input.scope, input.serverSDK)) + .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) .then((data) => input.setGlobalStore("project", data)), ] await runAll(slow) @@ -162,44 +205,117 @@ function warmSessions(input: { ids: string[] store: Store setStore: SetStoreFunction - sdk: OpencodeClient + api: SessionApi }) { const known = new Set(input.store.session.map((item) => item.id)) const ids = [...new Set(input.ids)].filter((id) => !!id && !known.has(id)) if (ids.length === 0) return Promise.resolve() return Promise.all( ids.map((sessionID) => - retry(() => input.sdk.session.get({ sessionID })).then((x) => { - const session = x.data - if (!session?.id) return - mergeSession(input.setStore, session) - }), + retry(() => input.api.get({ sessionID })).then((session) => + mergeSession(input.setStore, normalizeSessionInfo(session)), + ), ), ).then(() => undefined) } -export const loadProvidersQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadProvidersQuery = ( + scope: ServerScope, + directory: string | null, + sdk: CatalogApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "providers"], - queryFn: () => retry(() => sdk.provider.list().then((x) => normalizeProviderList(x.data!))), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + const result = await legacy.provider.list() + return normalizeProviderList(result.data!) + } + const location = directory ? { location: { directory } } : undefined + const [providers, models, defaultModel] = await Promise.all([ + sdk.provider.list(location), + sdk.model.list(location), + sdk.model.default(location), + ]) + return normalizeProviderList(providers.data, models.data, defaultModel.data) + }), }) -export const loadAgentsQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +type AgentListApi = { + readonly list: (input?: AgentListInput) => Promise +} + +type CommandListApi = { + readonly list: (input?: CommandListInput) => Promise +} + +type ReferenceListApi = { + readonly list: (input?: ReferenceListInput) => Promise +} + +export const loadAgentsQuery = ( + scope: ServerScope, + directory: string, + sdk: AgentListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "agents"], - queryFn: () => retry(() => sdk.app.agents().then((x) => normalizeAgentList(x.data))), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return normalizeAgentList((await legacy.app.agents()).data ?? []) + return sdk.list({ location: { directory } }).then((result) => normalizeAgentList(result.data)) + }), }) -export const loadPathQuery = (scope: ServerScope, directory: string | null, sdk: OpencodeClient) => +export const loadCommands = ( + directory: string, + api: CommandListApi, + legacy?: OpencodeClient, + protocol?: Promise, +): Promise => + retry(async () => { + if ((await protocol) === "v1" && legacy) { + return ((await legacy.command.list()).data ?? []).map((command) => { + const [providerID, id] = command.model?.split("/") ?? [] + return { + name: command.name, + template: command.template, + description: command.description, + agent: command.agent, + model: providerID && id ? { providerID, id } : undefined, + subtask: command.subtask, + source: command.source === "skill" ? undefined : command.source, + } + }) + } + return api.list({ location: { directory } }).then((result) => result.data) + }) + +export const loadPathQuery = (scope: ServerScope, directory: string | null, api: PathApi) => queryOptions({ queryKey: [scope, directory, "path"], - queryFn: () => retry(() => sdk.path.get().then((x) => x.data!)), + queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)), }) -export const loadReferencesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => +export const loadReferencesQuery = ( + scope: ServerScope, + directory: string, + api: ReferenceListApi, + legacy?: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "references"] as const, - queryFn: () => retry(() => sdk.v2.reference.list().then((x) => x.data?.data ?? [])).catch(() => []), + queryFn: () => + retry(async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.v2.reference.list()).data?.data ?? [] + return api.list({ location: { directory } }).then((result) => result.data) + }).catch(() => []), placeholderData: [], }) @@ -208,6 +324,18 @@ export async function bootstrapDirectory(input: { scope: ServerScope mcp: boolean sdk: OpencodeClient + api: CatalogApi & { + readonly agent: AgentListApi + readonly command: CommandListApi + readonly mcp: McpApi + readonly path: PathApi + readonly permission: PermissionApi + readonly project: ProjectApi + readonly question: QuestionApi + readonly reference: ReferenceListApi + readonly session: SessionApi + readonly vcs: VcsApi + } store: Store setStore: SetStoreFunction vcsCache: VcsCache @@ -221,6 +349,7 @@ export async function bootstrapDirectory(input: { } queryClient: QueryClient session?: ServerSession + protocol?: Promise }) { const loading = input.store.status !== "complete" const seededProject = projectID(input.directory, input.global.project) @@ -240,66 +369,55 @@ export async function bootstrapDirectory(input: { () => Promise.resolve(input.loadSessions(input.directory)), () => input.queryClient - .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.sdk)) + .ensureQueryData(loadAgentsQuery(input.scope, input.directory, input.api.agent, input.sdk, input.protocol)) .then((data) => input.setStore("agent", data)), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), - () => - retry(() => - input.sdk.session.status().then(async (x) => { - if (!input.session) { - input.setStore("session_status", x.data!) - return - } - const statuses = x.data ?? {} - input.session.set( - "session_status", - produce((draft) => { - for (const sessionID of Object.keys(draft)) { - if (statuses[sessionID]) continue - if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] - } - }), - ) - for (const [sessionID, status] of Object.entries(statuses)) { - input.session.set("session_status", sessionID, reconcile(status)) - } - // Warm session info only after seeding statuses so a stalled session - // fetch cannot park busy indicators behind it, mirroring how live - // session.status events apply first and resolve info in the background. - await Promise.all( - Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), - ) - }), - ), !seededProject && - (() => retry(() => input.sdk.project.current()).then((x) => input.setStore("project", x.data!.id))), + (() => + retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => + input.setStore("project", project.id), + )), !seededPath && (() => - input.queryClient.ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk)).then((data) => { - const next = projectID(data.directory ?? input.directory, input.global.project) - if (next) input.setStore("project", next) - })), + input.queryClient + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path)) + .then((data) => { + const next = projectID(data.directory ?? input.directory, input.global.project) + if (next) input.setStore("project", next) + })), () => retry(() => - input.sdk.vcs.get().then((x) => { - const next = x.data ?? input.store.vcs + input.api.vcs.get({ location: { directory: input.directory } }).then((result) => { + const next = { branch: result.data.branch, default_branch: result.data.defaultBranch } input.setStore("vcs", next) if (next) input.vcsCache.setStore("value", next) }), ), - input.mcp && (() => retry(() => input.sdk.command.list().then((x) => input.setStore("command", x.data ?? [])))), - () => input.queryClient.fetchQuery(loadReferencesQuery(input.scope, input.directory, input.sdk)), + input.mcp && + (() => + loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => + input.setStore("command", commands), + )), + () => + input.queryClient.fetchQuery( + loadReferencesQuery(input.scope, input.directory, input.api.reference, input.sdk, input.protocol), + ), () => retry(() => - input.sdk.permission.list().then((x) => { - const ids = (x.data ?? []).map((perm) => perm?.sessionID).filter((id): id is string => !!id) + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.permission.list()).data ?? [] + return input.api.permission.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) + })().then((permissions) => { + const ids = permissions.map((permission) => permission.sessionID) const grouped = groupBySession( - (x.data ?? []).filter((perm): perm is PermissionRequest => !!perm?.id && !!perm.sessionID), + permissions.filter((permission) => !!permission.id && !!permission.sessionID), ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) return warm.then(() => batch(() => { const current = input.session?.data.permission ?? input.store.permission @@ -323,12 +441,19 @@ export async function bootstrapDirectory(input: { ), () => retry(() => - input.sdk.question.list().then((x) => { - const ids = (x.data ?? []).map((question) => question?.sessionID).filter((id): id is string => !!id) - const grouped = groupBySession((x.data ?? []).filter((q): q is QuestionRequest => !!q?.id && !!q.sessionID)) + (async () => { + if ((await input.protocol) === "v1") return (await input.sdk.question.list()).data ?? [] + return input.api.question.request + .list({ location: { directory: input.directory } }) + .then((result) => result.data) + })().then((questions) => { + const ids = questions.map((question) => question.sessionID) + const grouped = groupBySession( + questions.filter((question) => !!question.id && !!question.sessionID) as QuestionRequest[], + ) const warm = input.session ? Promise.all(ids.map((sessionID) => input.session!.resolve(sessionID))).then(() => undefined) - : warmSessions({ ids, store: input.store, setStore: input.setStore, sdk: input.sdk }) + : warmSessions({ ids, store: input.store, setStore: input.setStore, api: input.api.session }) return warm.then(() => batch(() => { const current = input.session?.data.question ?? input.store.question @@ -351,17 +476,20 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.sdk))), - input.mcp && (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.sdk))), + input.mcp && (() => input.queryClient.fetchQuery(loadMcpQuery(input.scope, input.directory, input.api.mcp))), + input.mcp && + (() => input.queryClient.fetchQuery(loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp))), () => - input.queryClient.fetchQuery(loadProvidersQuery(input.scope, input.directory, input.sdk)).catch((err) => { - const project = getFilename(input.directory) - showToast({ - variant: "error", - title: input.translate("toast.project.reloadFailed.title", { project }), - description: formatServerError(err, input.translate), - }) - }), + input.queryClient + .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) + .catch((err) => { + const project = getFilename(input.directory) + showToast({ + variant: "error", + title: input.translate("toast.project.reloadFailed.title", { project }), + description: formatServerError(err, input.translate), + }) + }), ].filter(Boolean) as (() => Promise)[] await waitForPaint() diff --git a/packages/app/src/context/global-sync/child-store.ts b/packages/app/src/context/global-sync/child-store.ts index b36973e7f0..4eaa785789 100644 --- a/packages/app/src/context/global-sync/child-store.ts +++ b/packages/app/src/context/global-sync/child-store.ts @@ -255,6 +255,7 @@ export function createChildStoreManager(input: { vcs: vcsStore.value, limit: 5, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, }) diff --git a/packages/app/src/context/global-sync/event-reducer.test.ts b/packages/app/src/context/global-sync/event-reducer.test.ts index fb58fc4832..b53fb691b3 100644 --- a/packages/app/src/context/global-sync/event-reducer.test.ts +++ b/packages/app/src/context/global-sync/event-reducer.test.ts @@ -80,6 +80,7 @@ const baseState = (input: Partial = {}) => vcs: undefined, limit: 10, message: {}, + session_message: {}, part: {}, part_text_accum_delta: {}, ...input, @@ -261,8 +262,8 @@ describe("applyDirectoryEvent", () => { test("cleans session caches when deleted and decrements only root totals", () => { const cases = [ - { info: rootSession({ id: "ses_1" }), expectedTotal: 1 }, - { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2 }, + { info: rootSession({ id: "ses_1" }), expectedTotal: 1, current: false }, + { info: rootSession({ id: "ses_2", parentID: "ses_1" }), expectedTotal: 2, current: true }, ] for (const item of cases) { @@ -286,7 +287,10 @@ describe("applyDirectoryEvent", () => { ) applyDirectoryEvent({ - event: { type: "session.deleted", properties: { info: item.info } }, + event: { + type: "session.deleted", + properties: item.current ? { sessionID: item.info.id } : { info: item.info }, + }, store, setStore, push() {}, diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index b12df5eb55..39ba22c59d 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -8,9 +8,9 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { State, VcsCache } from "./types" import { trimSessions } from "./session-trim" import { dropSessionCaches } from "./session-cache" @@ -171,8 +171,11 @@ export function applyDirectoryEvent(input: { break } case "session.deleted": { - const info = (event.properties as { info: Session }).info - const result = Binary.search(input.store.session, info.id, (s) => s.id) + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) break + const result = Binary.search(input.store.session, sessionID, (s) => s.id) + const info = properties.info ?? (result.found ? input.store.session[result.index] : undefined) if (result.found) { input.setStore( "session", @@ -181,14 +184,77 @@ export function applyDirectoryEvent(input: { }), ) } - cleanupSessionCaches(input.setStore, info.id, input.setSessionTodo) - if (info.parentID) break + cleanupSessionCaches(input.setStore, sessionID, input.setSessionTodo) + if (info?.parentID) break input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) break } + case "session.renamed": { + const properties = event.properties as { sessionID: string; title: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + title: properties.title, + time: { ...session.time, updated: Date.now() }, + })) + break + } + case "session.usage.updated": { + const properties = event.properties as Pick & { sessionID: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + input.setStore("session", result.index, (session) => ({ + ...session, + cost: properties.cost, + tokens: properties.tokens, + })) + break + } + case "session.archived": { + const properties = event.properties as { sessionID: string } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + const info = input.store.session[result.index] + input.setStore( + "session", + produce((draft) => void draft.splice(result.index, 1)), + ) + cleanupSessionCaches(input.setStore, properties.sessionID) + if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } + case "session.moved": { + const properties = event.properties as { + sessionID: string + location: { directory: string; workspaceID?: string } + projectID?: string + subpath?: string + } + const result = Binary.search(input.store.session, properties.sessionID, (session) => session.id) + if (!result.found) break + if (properties.location.directory === input.directory) { + input.setStore("session", result.index, (session) => ({ + ...session, + projectID: properties.projectID ?? session.projectID, + workspaceID: properties.location.workspaceID, + directory: properties.location.directory, + path: properties.subpath, + time: { ...session.time, updated: Date.now() }, + })) + break + } + const info = input.store.session[result.index] + input.setStore( + "session", + produce((draft) => void draft.splice(result.index, 1)), + ) + if (!info?.parentID) input.setStore("sessionTotal", (value) => Math.max(0, value - 1)) + break + } case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - input.setStore("session_diff", props.sessionID, reconcile(list(props.diff), { key: "file" })) + const props = event.properties as { sessionID: string; diff: FileDiffInfo[] } + input.setStore("session_diff", props.sessionID, reconcile(list(props.diff) as FileDiffInfo[], { key: "file" })) break } case "todo.updated": { diff --git a/packages/app/src/context/global-sync/mcp.test.ts b/packages/app/src/context/global-sync/mcp.test.ts index a292d23df9..ebfd9738ee 100644 --- a/packages/app/src/context/global-sync/mcp.test.ts +++ b/packages/app/src/context/global-sync/mcp.test.ts @@ -31,4 +31,24 @@ describe("toggleMcp", () => { await toggleMcp(input("disabled")) expect(calls).toEqual(["connect", "refresh"]) }) + + test("does not toggle a server while its connection is pending", async () => { + const calls: string[] = [] + await toggleMcp({ + status: "pending", + connect: async () => { + calls.push("connect") + }, + disconnect: async () => { + calls.push("disconnect") + }, + authenticate: async () => { + calls.push("authenticate") + }, + refresh: async () => { + calls.push("refresh") + }, + }) + expect(calls).toEqual([]) + }) }) diff --git a/packages/app/src/context/global-sync/mcp.ts b/packages/app/src/context/global-sync/mcp.ts index 2eeb297b95..cd91f396d0 100644 --- a/packages/app/src/context/global-sync/mcp.ts +++ b/packages/app/src/context/global-sync/mcp.ts @@ -1,12 +1,13 @@ -import type { McpStatus } from "@opencode-ai/sdk/v2/client" +import type { McpServer } from "@opencode-ai/client/promise" export async function toggleMcp(input: { - status: McpStatus["status"] + status: McpServer["status"]["status"] connect: () => Promise disconnect: () => Promise authenticate: () => Promise refresh: () => Promise }) { + if (input.status === "pending") return await { connected: input.disconnect, needs_auth: input.authenticate, diff --git a/packages/app/src/context/global-sync/session-cache.test.ts b/packages/app/src/context/global-sync/session-cache.test.ts index 4b2be505ea..45fbe38abe 100644 --- a/packages/app/src/context/global-sync/session-cache.test.ts +++ b/packages/app/src/context/global-sync/session-cache.test.ts @@ -1,13 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { dropSessionCaches, pickSessionCacheEvictions } from "./session-cache" const msg = (id: string, sessionID: string) => @@ -33,9 +26,10 @@ describe("app session cache", () => { test("dropSessionCaches clears orphaned parts without message rows", () => { const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -45,6 +39,7 @@ describe("app session cache", () => { session_diff: { ses_1: [] }, todo: { ses_1: [] as Todo[] }, message: {}, + session_message: {}, part: { msg_1: [part("prt_1", "ses_1", "msg_1")] }, permission: { ses_1: [] as PermissionRequest[] }, question: { ses_1: [] as QuestionRequest[] }, @@ -67,9 +62,10 @@ describe("app session cache", () => { const m = msg("msg_1", "ses_1") const store: { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -79,6 +75,7 @@ describe("app session cache", () => { session_diff: {}, todo: {}, message: { ses_1: [m] }, + session_message: {}, part: { [m.id]: [part("prt_1", "ses_1", m.id)] }, permission: {}, question: {}, diff --git a/packages/app/src/context/global-sync/session-cache.ts b/packages/app/src/context/global-sync/session-cache.ts index 05cdc84643..7d684a5a1a 100644 --- a/packages/app/src/context/global-sync/session-cache.ts +++ b/packages/app/src/context/global-sync/session-cache.ts @@ -1,20 +1,15 @@ -import type { - Message, - Part, - PermissionRequest, - QuestionRequest, - SessionStatus, - SnapshotFileDiff, - Todo, -} from "@opencode-ai/sdk/v2/client" +import type { Message, Part, PermissionRequest, QuestionRequest, SessionStatus, Todo } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" export const SESSION_CACHE_LIMIT = 40 type SessionCache = { session_status: Record - session_diff: Record + session_diff: Record todo: Record message: Record + session_message: Record part: Record permission: Record question: Record @@ -37,6 +32,7 @@ export function dropSessionCaches(store: SessionCache, sessionIDs: Iterable; directory: string; limit: number }) { + const result = await input.api.list({ + directory: input.directory, + parentID: null, + limit: input.limit, + order: "desc", + }) + return { + data: result.data.map(normalizeSessionInfo), + limit: input.limit, + limited: true, + } as const +} + +export async function loadRootSessionsV1(input: { client: OpencodeClient; directory: string; limit: number }) { try { - const result = await input.list({ directory: input.directory, roots: true, limit: input.limit }) - return { - data: result.data, - limit: input.limit, - limited: true, - } as const + const result = await input.client.session.list({ directory: input.directory, roots: true, limit: input.limit }) + return { data: result.data, limit: input.limit, limited: true } as const } catch { - const result = await input.list({ directory: input.directory, roots: true }) - return { - data: result.data, - limit: input.limit, - limited: false, - } as const + const result = await input.client.session.list({ directory: input.directory, roots: true }) + return { data: result.data, limit: input.limit, limited: false } as const } } diff --git a/packages/app/src/context/global-sync/types.ts b/packages/app/src/context/global-sync/types.ts index 86b489cd09..74191e10b0 100644 --- a/packages/app/src/context/global-sync/types.ts +++ b/packages/app/src/context/global-sync/types.ts @@ -1,10 +1,7 @@ import type { Agent, - Command, Config, LspStatus, - McpResource, - McpStatus, Message, Part, Path, @@ -13,11 +10,12 @@ import type { ReferenceInfo, Session, SessionStatus, - SnapshotFileDiff, Todo, VcsInfo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" +import type { CommandInfo, McpResource, McpServer, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Accessor } from "solid-js" import type { SetStoreFunction, Store } from "solid-js/store" @@ -35,7 +33,7 @@ export type ProjectMeta = { export type State = { status: "loading" | "partial" | "complete" agent: Agent[] - command: Command[] + command: CommandInfo[] reference: ReferenceInfo[] project: string projectMeta: ProjectMeta | undefined @@ -51,7 +49,7 @@ export type State = { } session_working(id: string): boolean session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: FileDiffInfo[] } todo: { [sessionID: string]: Todo[] @@ -64,7 +62,7 @@ export type State = { } mcp_ready: boolean mcp: { - [name: string]: McpStatus + [name: string]: McpServer["status"] } mcp_resource: { [key: string]: McpResource @@ -76,6 +74,9 @@ export type State = { message: { [sessionID: string]: Message[] } + session_message: { + [sessionID: string]: SessionMessageInfo[] + } part: { [messageID: string]: Part[] } @@ -128,18 +129,6 @@ export type DisposeCheck = { loadingSessions: boolean } -export type RootLoadArgs = { - directory: string - limit: number - list: (query: { directory: string; roots: true; limit?: number }) => Promise<{ data?: Session[] }> -} - -export type RootLoadResult = { - data?: Session[] - limit: number - limited: boolean -} - export const MAX_DIR_STORES = 30 export const DIR_IDLE_TTL_MS = 20 * 60 * 1000 export const SESSION_RECENT_WINDOW = 4 * 60 * 60 * 1000 diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 406c0f124e..83989244a0 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,36 +1,112 @@ import { describe, expect, test } from "bun:test" -import type { Agent } from "@opencode-ai/sdk/v2/client" -import { directoryKey, normalizeAgentList } from "./utils" - -const agent = (name = "build") => - ({ - name, - mode: "primary", - permission: {}, - options: {}, - }) as Agent +import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise" +import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" describe("normalizeAgentList", () => { - test("keeps array payloads", () => { - expect(normalizeAgentList([agent("build"), agent("docs")])).toEqual([agent("build"), agent("docs")]) - }) + test("adapts current agents to the app agent shape", () => { + const result = normalizeAgentList([ + { + id: "build", + name: "Build", + mode: "primary", + hidden: false, + color: "primary", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + request: { settings: { temperature: 0.2, topP: 0.9 }, headers: {}, body: {} }, + system: "Build software", + permissions: [{ action: "read", resource: "*", effect: "allow" }], + }, + ] as AgentListOutput["data"]) - test("wraps a single agent payload", () => { - expect(normalizeAgentList(agent("docs"))).toEqual([agent("docs")]) + expect(result).toEqual([ + { + name: "build", + description: undefined, + mode: "primary", + hidden: false, + temperature: 0.2, + topP: 0.9, + color: "primary", + permission: [{ permission: "read", pattern: "*", action: "allow" }], + model: { providerID: "openai", modelID: "gpt-5" }, + variant: "high", + prompt: "Build software", + options: { temperature: 0.2, topP: 0.9 }, + steps: undefined, + }, + ]) }) +}) - test("extracts agents from keyed objects", () => { +describe("normalizePermissionRequest", () => { + test("adapts the current permission request to app state", () => { expect( - normalizeAgentList({ - build: agent("build"), - docs: agent("docs"), + normalizePermissionRequest({ + id: "permission-1", + sessionID: "session-1", + action: "read", + resources: ["README.md"], + save: ["*.md"], + metadata: { path: "README.md" }, + source: { type: "tool", messageID: "message-1", callID: "call-1" }, }), - ).toEqual([agent("build"), agent("docs")]) + ).toEqual({ + id: "permission-1", + sessionID: "session-1", + permission: "read", + patterns: ["README.md"], + always: ["*.md"], + metadata: { path: "README.md" }, + tool: { messageID: "message-1", callID: "call-1" }, + }) }) +}) - test("drops invalid payloads", () => { - expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) - expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) +describe("normalizeProviderList", () => { + test("groups current models into the app provider catalog", () => { + const result = normalizeProviderList( + [{ id: "openai", name: "OpenAI", package: "@ai-sdk/openai" }] as ProviderListOutput["data"], + [ + { + id: "gpt-5", + modelID: "gpt-5", + providerID: "openai", + name: "GPT-5", + capabilities: { tools: true, input: ["text", "image"], output: ["text"] }, + variants: [{ id: "high" }], + time: { released: 1 }, + cost: [{ input: 1, output: 2, cache: { read: 0.1, write: 0.2 } }], + status: "active", + enabled: true, + limit: { context: 128_000, output: 8_192 }, + }, + { + id: "gpt-old", + modelID: "gpt-old", + providerID: "openai", + name: "GPT Old", + capabilities: { tools: false, input: ["text"], output: ["text"] }, + variants: [], + time: { released: 0 }, + cost: [], + status: "deprecated", + enabled: true, + limit: { context: 1, output: 1 }, + }, + ] as ModelListOutput["data"], + { id: "gpt-5", providerID: "openai" } as ModelDefaultOutput["data"], + ) + + expect(result.connected).toEqual(["openai"]) + expect(result.default).toEqual({ openai: "gpt-5" }) + expect(result.all.get("openai")?.models["gpt-old"]).toBeUndefined() + expect(result.all.get("openai")?.models["gpt-5"]).toMatchObject({ + id: "gpt-5", + providerID: "openai", + capabilities: { toolcall: true, attachment: true }, + cost: { input: 1, output: 2 }, + variants: { high: {} }, + }) }) }) diff --git a/packages/app/src/context/global-sync/utils.ts b/packages/app/src/context/global-sync/utils.ts index e54bc88d4d..59632e53c9 100644 --- a/packages/app/src/context/global-sync/utils.ts +++ b/packages/app/src/context/global-sync/utils.ts @@ -1,39 +1,152 @@ -import type { Agent, Project, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + PermissionV2Request, + ProviderListOutput, +} from "@opencode-ai/client/promise" +import type { Agent, PermissionRequest, Project, Provider, ProviderListResponse } from "@opencode-ai/sdk/v2/client" +import type { Project as CurrentProject } from "@opencode-ai/client/promise" import { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" export { pathKey as directoryKey, type PathKey as DirectoryKey } from "@/utils/path-key" export const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) -function isAgent(input: unknown): input is Agent { - if (!input || typeof input !== "object") return false - const item = input as { name?: unknown; mode?: unknown } - if (typeof item.name !== "string") return false - return item.mode === "subagent" || item.mode === "primary" || item.mode === "all" +export function normalizeAgentList(input: AgentListOutput["data"] | Agent[]): Agent[] { + if (input.every((agent) => !("request" in agent))) return input as Agent[] + return (input as AgentListOutput["data"]).map((agent) => ({ + name: agent.id, + description: agent.description, + mode: agent.mode, + hidden: agent.hidden, + temperature: + typeof agent.request.settings.temperature === "number" ? agent.request.settings.temperature : undefined, + topP: typeof agent.request.settings.topP === "number" ? agent.request.settings.topP : undefined, + color: agent.color, + permission: agent.permissions.map((rule) => ({ + permission: rule.action, + pattern: rule.resource, + action: rule.effect, + })), + model: agent.model && { providerID: agent.model.providerID, modelID: agent.model.id }, + variant: agent.model?.variant, + prompt: agent.system, + options: agent.request.settings, + steps: agent.steps, + })) } -export function normalizeAgentList(input: unknown): Agent[] { - if (Array.isArray(input)) return input.filter(isAgent) - if (isAgent(input)) return [input] - if (!input || typeof input !== "object") return [] - return Object.values(input).filter(isAgent) -} - -export function normalizeProviderList(input: ProviderListResponse): NormalizedProviderListResponse { +export function normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { + if ("permission" in input) return input return { - ...input, - all: new Map( - input.all.map( - (provider) => - [ - provider.id, - { - ...provider, - models: Object.fromEntries( - Object.entries(provider.models).filter(([, info]) => info.status !== "deprecated"), - ), - }, - ] as const, + id: input.id, + sessionID: input.sessionID, + permission: input.action, + patterns: input.resources, + always: input.save ?? [], + metadata: input.metadata ?? {}, + tool: + input.source?.type === "tool" ? { messageID: input.source.messageID, callID: input.source.callID } : undefined, + } +} + +export function normalizeProviderList( + providers: ProviderListOutput["data"] | ProviderListResponse, + models?: ModelListOutput["data"], + defaultModel?: ModelDefaultOutput["data"], +): NormalizedProviderListResponse { + if (!Array.isArray(providers)) { + return { + ...providers, + all: new Map( + providers.all.map((provider) => [ + provider.id, + { + ...provider, + models: Object.fromEntries( + Object.entries(provider.models).filter(([, model]) => model.status !== "deprecated"), + ), + }, + ]), ), + } + } + const all = new Map() + + for (const provider of providers) { + all.set(provider.id, { + id: provider.id, + name: provider.name, + source: "custom", + env: [], + options: provider.settings ?? {}, + models: {}, + }) + } + + for (const model of models ?? []) { + const provider = all.get(model.providerID) + if (!provider || model.status === "deprecated") continue + const cost = model.cost.find((item) => item.tier === undefined) ?? model.cost[0] + provider.models[model.id] = { + id: model.id, + providerID: model.providerID, + api: { + id: model.modelID, + url: "", + npm: model.package ?? provider.id, + }, + name: model.name, + family: model.family, + capabilities: { + temperature: false, + reasoning: false, + attachment: model.capabilities.input.some((item) => item !== "text"), + toolcall: model.capabilities.tools, + input: { + text: model.capabilities.input.includes("text"), + audio: model.capabilities.input.includes("audio"), + image: model.capabilities.input.includes("image"), + video: model.capabilities.input.includes("video"), + pdf: model.capabilities.input.includes("pdf"), + }, + output: { + text: model.capabilities.output.includes("text"), + audio: model.capabilities.output.includes("audio"), + image: model.capabilities.output.includes("image"), + video: model.capabilities.output.includes("video"), + pdf: model.capabilities.output.includes("pdf"), + }, + interleaved: false, + }, + cost: { + input: cost?.input ?? 0, + output: cost?.output ?? 0, + cache: { + read: cost?.cache.read ?? 0, + write: cost?.cache.write ?? 0, + }, + }, + limit: model.limit, + status: model.status, + options: model.settings ?? {}, + headers: model.headers ?? {}, + release_date: new Date(model.time.released).toISOString().slice(0, 10), + variants: Object.fromEntries(model.variants.map((variant) => [variant.id, variant.settings ?? {}])), + } + } + + return { + all, + connected: providers.map((provider) => provider.id), + default: Object.fromEntries( + providers.flatMap((provider) => { + const model = + defaultModel?.providerID === provider.id + ? defaultModel + : models?.find((item) => item.providerID === provider.id && item.status !== "deprecated") + return model ? [[provider.id, model.id]] : [] + }), ), } } @@ -49,3 +162,10 @@ export function sanitizeProject(project: Project) { }, } } + +export function normalizeProjectInfo(project: Project | CurrentProject): Project { + return { + ...project, + vcs: project.vcs === "git" ? "git" : undefined, + } +} diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts new file mode 100644 index 0000000000..578d636ef6 --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, test } from "bun:test" +import type { OpenCodeEvent, SessionMessageInfo } from "@opencode-ai/client/promise" +import { createV2SessionReducer } from "./server-session-v2-reducer" + +const event = (input: object) => input as OpenCodeEvent +const base = { created: 1, location: { directory: "/repo" }, durable: { aggregateID: "ses_1", seq: 1, version: 1 } } + +describe("v2 session reducer", () => { + test("projects promoted input and streaming assistant content", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + return result + } + + apply({ + ...base, + id: "evt_admitted", + type: "session.input.admitted", + data: { + sessionID: "ses_1", + inputID: "msg_user", + input: { type: "user", delivery: "steer", data: { text: "hello" } }, + }, + }) + apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } }) + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_text_start", + type: "session.text.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0 }, + }) + apply({ + ...base, + id: "evt_text_delta", + type: "session.text.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, delta: "hel" }, + }) + apply({ + ...base, + id: "evt_text_end", + type: "session.text.ended", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", ordinal: 0, text: "hello" }, + }) + + expect(messages[0]).toMatchObject({ id: "msg_user", type: "user", text: "hello" }) + expect(messages[1]).toMatchObject({ + id: "msg_assistant", + type: "assistant", + content: [{ type: "text", text: "hello" }], + }) + }) + + test("folds tool, retry, and completion events", () => { + const reducer = createV2SessionReducer() + let messages: SessionMessageInfo[] = [] + const apply = (input: object) => { + const result = reducer.reduce(messages, event(input)) + if (result) messages = result.messages + } + + apply({ + ...base, + id: "evt_step", + type: "session.step.started", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + ...base, + id: "evt_tool_start", + type: "session.tool.input.started", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", name: "bash" }, + }) + apply({ + ...base, + id: "evt_tool_delta", + type: "session.tool.input.delta", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", delta: "{}" }, + }) + apply({ + ...base, + id: "evt_tool_called", + type: "session.tool.called", + data: { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", input: {}, executed: true }, + }) + apply({ + ...base, + id: "evt_tool_success", + type: "session.tool.success", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + callID: "call_1", + structured: {}, + content: [{ type: "text", text: "done" }], + executed: true, + }, + }) + apply({ + ...base, + id: "evt_retry", + type: "session.retry.scheduled", + data: { + sessionID: "ses_1", + assistantMessageID: "msg_assistant", + attempt: 2, + at: 10, + error: { type: "ProviderError", message: "retry" }, + }, + }) + apply({ ...base, id: "evt_done", type: "session.execution.succeeded", data: { sessionID: "ses_1" } }) + + expect(messages[0]).toMatchObject({ + type: "assistant", + retry: undefined, + content: [{ type: "tool", id: "call_1", state: { status: "completed", content: [{ text: "done" }] } }], + }) + }) + + test("requests hydration when promotion admission was missed", () => { + const result = createV2SessionReducer().reduce([], event({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + })) + + expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) + }) +}) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts new file mode 100644 index 0000000000..10c6676419 --- /dev/null +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -0,0 +1,434 @@ +import type { OpenCodeEvent, SessionMessageInfo, SessionPendingMessage } from "@opencode-ai/client/promise" + +type Assistant = Extract +type Compaction = Extract +type Shell = Extract + +export type V2SessionReduction = { + sessionID: string + messages: SessionMessageInfo[] + touched: string[] + missing?: string +} + +export function createV2SessionReducer() { + const pending = new Map() + + const reduce = (source: readonly SessionMessageInfo[], event: OpenCodeEvent): V2SessionReduction | undefined => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const result = (messages: SessionMessageInfo[], touched: string[] = []): V2SessionReduction => ({ + sessionID, + messages, + touched, + }) + const append = (message: SessionMessageInfo) => + result(source.some((item) => item.id === message.id) ? [...source] : [...source, message], [message.id]) + + switch (event.type) { + case "session.input.admitted": + pending.set(key(sessionID, event.data.inputID), event.data.input) + return result([...source]) + case "session.input.promoted": { + const input = pending.get(key(sessionID, event.data.inputID)) + pending.delete(key(sessionID, event.data.inputID)) + if (!input) return { ...result([...source]), missing: event.data.inputID } + if (input.type === "user") + return append({ + id: event.data.inputID, + type: "user", + metadata: input.data.metadata, + text: input.data.text, + files: input.data.files, + agents: input.data.agents, + time: { created: event.created }, + }) + return append({ + id: event.data.inputID, + type: "synthetic", + metadata: input.data.metadata, + text: input.data.text, + description: input.data.description, + time: { created: event.created }, + }) + } + case "session.agent.selected": + return append({ + id: messageID(event.id), + type: "agent-switched", + metadata: event.metadata, + agent: event.data.agent, + time: { created: event.created }, + }) + case "session.model.selected": + return append({ + id: messageID(event.id), + type: "model-switched", + metadata: event.metadata, + model: event.data.model, + previous: source.findLast( + (item): item is Extract => + item.type === "model-switched" || item.type === "assistant", + )?.model, + time: { created: event.created }, + }) + case "session.synthetic": + return append({ + id: messageID(event.id), + type: "synthetic", + metadata: event.data.metadata, + text: event.data.text, + description: event.data.description, + time: { created: event.created }, + }) + case "session.skill.activated": + return append({ + id: messageID(event.id), + type: "skill", + metadata: event.metadata, + skill: event.data.id, + name: event.data.name, + text: event.data.text, + time: { created: event.created }, + }) + case "session.shell.started": + return append({ + id: messageID(event.id), + type: "shell", + metadata: event.metadata, + shellID: event.data.shell.id, + command: event.data.shell.command, + status: event.data.shell.status, + exit: event.data.shell.exit, + time: { created: event.created }, + }) + case "session.shell.ended": + return updateMessage(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({ + ...item, + status: event.data.shell.status, + exit: event.data.shell.exit, + output: event.data.output, + time: { ...item.time, completed: event.created }, + }), sessionID) + case "session.step.started": { + const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) + const completed = current && current.id !== event.data.assistantMessageID + ? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item) + : [...source] + const existing = completed.find((item) => item.id === event.data.assistantMessageID) + if (existing?.type === "assistant") + return result(update(completed, existing.id, (item) => item.type === "assistant" ? { + ...item, + agent: event.data.agent, + model: event.data.model, + retry: undefined, + error: undefined, + finish: undefined, + snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, + time: { ...item.time, completed: undefined }, + } : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id]) + return result([...completed, { + id: event.data.assistantMessageID, + type: "assistant", + metadata: event.metadata, + agent: event.data.agent, + model: event.data.model, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + time: { created: event.created }, + }], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID]) + } + case "session.step.ended": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + finish: event.data.finish, + cost: event.data.cost, + tokens: event.data.tokens, + snapshot: event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, + time: { ...item.time, completed: event.created }, + })) + case "session.step.failed": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + finish: "error", + error: event.data.error, + retry: undefined, + cost: event.data.cost ?? item.cost, + tokens: event.data.tokens ?? item.tokens, + snapshot: event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, + time: { ...item.time, completed: event.created }, + })) + case "session.text.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: insertOrdinal(item.content, "text", event.data.ordinal, { type: "text", text: "" }), + })) + case "session.text.delta": + return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ + ...item, + text: item.text + event.data.delta, + })) + case "session.text.ended": + return updateContent(source, event.data.assistantMessageID, sessionID, "text", event.data.ordinal, (item) => ({ + ...item, + text: event.data.text, + })) + case "session.reasoning.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: insertOrdinal(item.content, "reasoning", event.data.ordinal, { + type: "reasoning", + text: "", + state: event.data.state, + time: { created: event.created }, + }), + })) + case "session.reasoning.delta": + return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({ + ...item, + text: item.text + event.data.delta, + })) + case "session.reasoning.ended": + return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({ + ...item, + text: event.data.text, + state: event.data.state ?? item.state, + time: { created: item.time?.created ?? event.created, completed: event.created }, + })) + case "session.tool.input.started": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID) + ? item.content + : [...item.content, { + type: "tool", + id: event.data.callID, + name: event.data.name, + state: { status: "streaming", input: "" }, + time: { created: event.created }, + }], + })) + case "session.tool.input.delta": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "streaming" + ? { ...tool, state: { ...tool.state, input: tool.state.input + event.data.delta } } + : tool, + ) + case "session.tool.input.ended": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "streaming" ? { ...tool, state: { ...tool.state, input: event.data.text } } : tool, + ) + case "session.tool.called": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => ({ + ...tool, + executed: event.data.executed, + providerState: event.data.state, + state: { status: "running", input: event.data.input, structured: {}, content: [] }, + time: { ...tool.time, ran: event.created }, + })) + case "session.tool.progress": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => + tool.state.status === "running" + ? { ...tool, state: { ...tool.state, structured: event.data.structured, content: event.data.content } } + : tool, + ) + case "session.tool.success": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { + if (tool.state.status !== "running") return tool + return { + ...tool, + executed: event.data.executed || tool.executed === true, + providerResultState: event.data.resultState, + state: { + status: "completed", + input: tool.state.input, + structured: event.data.structured, + content: event.data.content, + result: event.data.result, + }, + time: { ...tool.time, completed: event.created }, + } + }) + case "session.tool.failed": + return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => { + if (tool.state.status !== "streaming" && tool.state.status !== "running") return tool + return { + ...tool, + executed: event.data.executed || tool.executed === true, + providerResultState: event.data.resultState, + state: { + status: "error", + input: typeof tool.state.input === "string" ? {} : tool.state.input, + structured: tool.state.status === "running" ? tool.state.structured : {}, + content: tool.state.status === "running" ? tool.state.content : [], + error: event.data.error, + result: event.data.result, + }, + time: { ...tool.time, completed: event.created }, + } + }) + case "session.retry.scheduled": + return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ + ...item, + retry: { attempt: event.data.attempt, at: event.data.at, error: event.data.error }, + })) + case "session.execution.succeeded": + case "session.execution.failed": + case "session.execution.interrupted": { + const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) + if (!current?.retry) return result([...source]) + return updateAssistant(source, current.id, sessionID, (item) => ({ ...item, retry: undefined })) + } + case "session.compaction.started": + return append({ + id: event.data.inputID ?? messageID(event.id), + type: "compaction", + status: "running", + metadata: event.metadata, + reason: event.data.reason, + summary: "", + recent: event.data.recent, + time: { created: event.created }, + }) + case "session.compaction.delta": + return updateMessage>(source, (item): item is Extract => item.type === "compaction" && item.status === "running", (item) => ({ + ...item, + summary: item.summary + event.data.text, + }), sessionID) + case "session.compaction.ended": { + const current = source.findLast((item): item is Extract => item.type === "compaction" && item.status === "running") + if (!current) + return append({ + id: messageID(event.id), + type: "compaction", + status: "completed", + metadata: event.metadata, + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + time: { created: event.created }, + }) + return result(update(source, current.id, () => ({ + ...current, + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + })), [current.id]) + } + case "session.compaction.failed": { + const current = source.findLast((item): item is Extract => item.type === "compaction" && item.status === "running") + const failed: Extract = { + id: current?.id ?? event.data.inputID ?? messageID(event.id), + type: "compaction", + status: "failed", + metadata: current?.metadata ?? event.metadata, + reason: event.data.reason, + error: event.data.error, + time: current?.time ?? { created: event.created }, + } + if (!current) return append(failed) + return result(update(source, current.id, () => failed), [failed.id]) + } + default: + return + } + } + + return { + reduce, + clear(sessionID: string) { + for (const id of pending.keys()) { + if (id.startsWith(`${sessionID}:`)) pending.delete(id) + } + }, + } +} + +function key(sessionID: string, inputID: string) { + return `${sessionID}:${inputID}` +} + +function messageID(eventID: string) { + return eventID.replace(/^evt_/, "msg_") +} + +function update( + source: readonly SessionMessageInfo[], + id: string, + apply: (item: SessionMessageInfo) => SessionMessageInfo, +) { + return source.map((item) => item.id === id ? apply(item) : item) +} + +function updateMessage( + source: readonly SessionMessageInfo[], + matches: (item: SessionMessageInfo) => item is T, + apply: (item: T) => T, + sessionID: string, +): V2SessionReduction { + const current = source.findLast(matches) + if (!current) return { sessionID, messages: [...source], touched: [] } + return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] } +} + +function updateAssistant( + source: readonly SessionMessageInfo[], + id: string, + sessionID: string, + apply: (item: Assistant) => Assistant, +): V2SessionReduction { + return { + sessionID, + messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item), + touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [], + } +} + +function updateContent( + source: readonly SessionMessageInfo[], + messageID: string, + sessionID: string, + type: T, + ordinal: number, + apply: (item: Extract) => Extract, +) { + return updateAssistant(source, messageID, sessionID, (assistant) => { + let index = -1 + return { + ...assistant, + content: assistant.content.map((item) => { + if (item.type !== type || ++index !== ordinal) return item + return apply(item as Extract) + }), + } + }) +} + +function updateTool( + source: readonly SessionMessageInfo[], + messageID: string, + callID: string, + sessionID: string, + apply: (item: Extract) => Extract, +) { + return updateAssistant(source, messageID, sessionID, (assistant) => ({ + ...assistant, + content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item), + })) +} + +function insertOrdinal( + source: Assistant["content"], + type: T, + ordinal: number, + item: Extract, +) { + const matches = source.filter((content) => content.type === type) + if (matches[ordinal]) return source + return [...source, item] +} diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 5554928075..30723ecfbf 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { retry } from "@opencode-ai/core/util/retry" +import type { MessageApi, OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" @@ -158,6 +159,57 @@ function setup(sessions: Record) { } describe("server session", () => { + test("projects V2 session events into current and legacy message state", () => { + const ctx = setup({ child: session("child") }) + ctx.store.remember(session("child")) + ctx.store.set("session_message", "child", [ + { + id: "msg_1_user", + type: "user", + text: "hello", + time: { created: 1 }, + }, + ]) + const apply = (input: object) => ctx.store.applyV2(input as OpenCodeEvent) + + apply({ + id: "evt_step", + created: 2, + type: "session.step.started", + durable: { aggregateID: "child", seq: 1, version: 1 }, + location: { directory: "/repo" }, + data: { + sessionID: "child", + assistantMessageID: "msg_2_assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + }, + }) + apply({ + id: "evt_text_start", + created: 3, + type: "session.text.started", + durable: { aggregateID: "child", seq: 2, version: 1 }, + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0 }, + }) + apply({ + id: "evt_text_delta", + created: 4, + type: "session.text.delta", + location: { directory: "/repo" }, + data: { sessionID: "child", assistantMessageID: "msg_2_assistant", ordinal: 0, delta: "world" }, + }) + + expect(ctx.store.data.session_message.child?.at(-1)).toMatchObject({ + id: "msg_2_assistant", + type: "assistant", + content: [{ type: "text", text: "world" }], + }) + expect(ctx.store.data.message.child?.map((message) => message.id)).toEqual(["msg_1_user", "msg_2_assistant"]) + expect(ctx.store.data.part.msg_2_assistant).toMatchObject([{ type: "text", text: "world" }]) + }) + test("resolves lineage by session ID without directory", async () => { const ctx = setup({ child: session("child", "root"), root: session("root") }) @@ -178,6 +230,111 @@ describe("server session", () => { expect(ctx.store.data.message.root).toEqual([]) }) + test("loads current session content through the current message API", async () => { + const requests: unknown[] = [] + const user = { id: "msg_z_user", type: "user", text: "hello", time: { created: 1 } } + const assistant = { + id: "msg_a_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "hi" }], + time: { created: 2, completed: 3 }, + } + const client = { + session: { + messages: () => { + throw new Error("legacy message endpoint called") + }, + }, + } as unknown as OpencodeClient + const messageApi = { + list: async (input: unknown) => { + requests.push(input) + return { data: [assistant, user], cursor: { previous: null, next: null } } + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + + expect(requests).toEqual([{ sessionID: "root", limit: 20, order: "desc" }]) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + + test("reprojects current assistants when an older page supplies their user", async () => { + const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const + const assistant = (id: string, created: number) => ({ + id, + type: "assistant" as const, + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text" as const, text: id }], + time: { created, completed: created }, + }) + const assistants = [ + assistant("msg_2_assistant", 2), + assistant("msg_3_assistant", 3), + assistant("msg_4_assistant", 4), + ] + const pages = [ + { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, + { data: [assistants[0], user], cursor: { previous: null, next: null } }, + ] + const messageApi = { + list: async () => pages.shift()!, + } as unknown as MessageApi + const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) + store.remember(session("root")) + + await store.sync("root") + expect(store.data.message.root).toEqual([]) + + await store.history.loadMore("root") + + expect(store.data.message.root.map((message) => message.id)).toEqual([ + user.id, + ...assistants.map((item) => item.id), + ]) + expect(assistants.map((item) => store.data.part[item.id]?.[0]?.type)).toEqual(["text", "text", "text"]) + }) + + test("indexes V1 messages for the current timeline projection", async () => { + const user = userMessage("message-1", { sessionID: "root" }) + const assistant = assistantMessage("message-2", user.id, { sessionID: "root" }) + const client = messageClient( + response([ + { info: user, parts: [textPart(user.id, { sessionID: "root" })] }, + { info: assistant, parts: [textPart(assistant.id, { sessionID: "root" })] }, + ]), + ) + const messageApi = { + list: () => { + throw new Error("current message endpoint called") + }, + } as unknown as MessageApi + const store = createServerSession(client, {} as SessionApi, messageApi, { + protocol: Promise.resolve("v1"), + }) + store.remember(session("root")) + + await store.sync("root") + + expect(store.data.message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + expect(store.data.session_message.root).toMatchObject([ + { id: user.id, type: "user", text: "text" }, + { id: assistant.id, type: "assistant" }, + ]) + + const next = userMessage("message-3", { sessionID: "root" }) + store.apply({ type: "message.updated", properties: { info: next } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id, next.id]) + + store.apply({ type: "message.removed", properties: { sessionID: "root", messageID: next.id } }) + expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) + }) + test("backfills an assistant-only initial page through its user root", async () => { const user = userMessage("message-1") const assistants = [assistantMessage("message-2", user.id), assistantMessage("message-3", user.id)] diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 5a892f7915..6bf0f47f5c 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,5 +1,6 @@ import { Binary } from "@opencode-ai/core/util/binary" import { retry } from "@opencode-ai/core/util/retry" +import type { MessageApi, OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, @@ -8,15 +9,18 @@ import type { QuestionRequest, Session, SessionStatus, - SnapshotFileDiff, Todo, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { batch } from "solid-js" import { createStore, produce, reconcile } from "solid-js/store" -import { diffs as cleanDiffs, message as cleanMessage } from "@/utils/diffs" +import { message as cleanMessage } from "@/utils/diffs" import { sessionNotFoundError } from "@/utils/server-errors" import { rootSession } from "@/utils/session-route" +import { normalizeSessionInfo } from "@/utils/session" +import { normalizeSessionMessages } from "@/utils/session-message" import { dropSessionCaches, pickSessionCacheEvictions, SESSION_CACHE_LIMIT } from "./global-sync/session-cache" +import { createV2SessionReducer, type V2SessionReduction } from "./server-session-v2-reducer" const cmp = (a: string, b: string) => (a < b ? -1 : a > b ? 1 : 0) const cmpMessage = (a: Message, b: Message) => a.time.created - b.time.created || cmp(a.id, b.id) @@ -36,10 +40,37 @@ type OptimisticItem = { type MessagePage = { session: Message[] part: { id: string; part: Part[] }[] + source?: SessionMessageInfo[] + sourceMode?: "latest" | "older" + projectSource?: boolean cursor?: string complete: boolean } +function legacyMessageSource(items: { info: Message; parts: Part[] }[]): SessionMessageInfo[] { + return items + .slice() + .sort((a, b) => cmp(a.info.id, b.info.id)) + .map((item) => { + if (item.info.role === "user") { + return { + id: item.info.id, + type: "user" as const, + text: item.parts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), + time: item.info.time, + } + } + return { + id: item.info.id, + type: "assistant" as const, + agent: item.info.agent ?? item.info.mode, + model: { id: item.info.modelID, providerID: item.info.providerID, variant: item.info.variant }, + content: [], + time: item.info.time, + } + }) +} + // Most markers describe the current HTTP attempt; deltaParts persists non-durable stream state across retries. type MessageLoadState = { touchedMessages: Set @@ -52,6 +83,7 @@ type MessageLoadState = { optimisticParts: Map> orphanParents: Set clearedMessageParts: Set + touchedSource: Set } type MessageLoadBaseline = Pick< @@ -137,15 +169,25 @@ function reconcileFetched( return [...result.values()].sort((a, b) => cmp(a.id, b.id)) } -export function createServerSession(client: OpencodeClient, options?: { retry?: typeof retry }) { +type ServerSessionOptions = { retry?: typeof retry; protocol?: Promise<"v1" | "v2"> } + +export function createServerSession( + client: OpencodeClient, + sessionApiOrOptions?: SessionApi | ServerSessionOptions, + messageApi?: MessageApi, + currentOptions?: ServerSessionOptions, +) { + const sessionApi = messageApi ? (sessionApiOrOptions as SessionApi) : undefined + const options = messageApi ? currentOptions : (sessionApiOrOptions as ServerSessionOptions | undefined) const [data, setData] = createStore({ info: {} as Record, session_status: {} as Record, - session_diff: {} as Record, + session_diff: {} as Record, todo: {} as Record, permission: {} as Record, question: {} as Record, message: {} as Record, + session_message: {} as Record, part: {} as Record, part_text_accum_delta: {} as Record, session_working(id: string) { @@ -154,9 +196,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: }) const requests = new Map>() const inflight = new Map>() - const inflightDiff = new Map>() const inflightTodo = new Map>() const optimistic = new Map>() + const v2 = createV2SessionReducer() const messageLoads = new Map() const pendingParts = new Map>>() const orphanParts = new Map>() @@ -191,6 +233,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: at: {} as Record, }) + const indexLegacyMessage = (message: Message) => { + const current = data.session_message[message.sessionID] ?? [] + if (current.some((item) => item.id === message.id)) return + setData( + "session_message", + message.sessionID, + reconcile([...current, ...legacyMessageSource([{ info: message, parts: [] }])]), + ) + } + const remember = (session: Session) => { setData("info", session.id, reconcile(session)) infoSeen.delete(session.id) @@ -200,7 +252,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...pinned.keys(), ...requests.keys(), ...inflight.keys(), - ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -242,27 +293,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: const pending = requests.get(sessionID) if (pending) return pending const active = generation(sessionID) - const request = client.session.get({ sessionID }).then((result) => { - if (!result.data) throw sessionNotFoundError(sessionID) - if (generations.get(sessionID) !== active) return result.data - return remember(result.data) + const request = sessionApi + ? sessionApi.get({ sessionID }).then(normalizeSessionInfo) + : client.session.get({ sessionID }).then((result) => { + if (!result.data) throw sessionNotFoundError(sessionID) + return result.data + }) + const resolved = request.then((result) => { + if (generations.get(sessionID) !== active) return result + return remember(result) }) - requests.set(sessionID, request) + requests.set(sessionID, resolved) const cleanup = () => { - if (requests.get(sessionID) === request) requests.delete(sessionID) + if (requests.get(sessionID) === resolved) requests.delete(sessionID) if ( generations.get(sessionID) === active && !data.info[sessionID] && !requests.has(sessionID) && !messageLoads.has(sessionID) && !inflight.has(sessionID) && - !inflightDiff.has(sessionID) && !inflightTodo.has(sessionID) ) generations.delete(sessionID) } - void request.then(cleanup, cleanup) - return request + void resolved.then(cleanup, cleanup) + return resolved } const peekLineage = (sessionID: string) => { @@ -419,9 +474,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: clearOptimistic(sessionID) requests.delete(sessionID) inflight.delete(sessionID) - inflightDiff.delete(sessionID) inflightTodo.delete(sessionID) messageLoads.delete(sessionID) + v2.clear(sessionID) pendingParts.delete(sessionID) orphanParts.delete(sessionID) removedMessages.delete(sessionID) @@ -449,7 +504,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ...pinned.keys(), ...requests.keys(), ...inflight.keys(), - ...inflightDiff.keys(), ...inflightTodo.keys(), ...messageLoads.keys(), ...optimistic.keys(), @@ -470,6 +524,25 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: ) const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { + if (messageApi && (await options?.protocol) !== "v1") { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" }) + }) + const source = [...response.data].reverse() + const normalized = normalizeSessionMessages(sessionID, source) + return { + session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + source, + sourceMode: before ? ("older" as const) : ("latest" as const), + projectSource: true, + cursor: response.cursor.next ?? undefined, + complete: response.data.length === 0, + } + } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.messages({ sessionID, limit, before }) @@ -481,12 +554,24 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: id: item.info.id, part: item.parts.filter((part) => !!part?.id).sort((a, b) => cmp(a.id, b.id)), })), + source: legacyMessageSource(items), + sourceMode: before ? ("older" as const) : ("latest" as const), cursor: response.response.headers.get("x-next-cursor") ?? undefined, complete: !response.response.headers.get("x-next-cursor"), } } const fetchMessage = async (sessionID: string, messageID: string, onAttempt?: () => void) => { + if (sessionApi && (await options?.protocol) !== "v1") { + const response = await (options?.retry ?? retry)(() => { + onAttempt?.() + return sessionApi.message({ sessionID, messageID }) + }) + const normalized = normalizeSessionMessages(sessionID, [response]) + const message = normalized.messages[0] + if (!message) throw new Error(`Message not found: ${messageID}`) + return { message, parts: normalized.parts.get(messageID) ?? [] } + } const response = await (options?.retry ?? retry)(() => { onAttempt?.() return client.session.message({ sessionID, messageID }) @@ -571,7 +656,31 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: preserveUnfetched: boolean | ((message: Message) => boolean), cleanupOrphans: boolean, ) => { - const merged = mergeOptimisticPage(page, [...(optimistic.get(sessionID)?.values() ?? [])]) + const source = page.source + ? (() => { + const incoming = new Map(page.source.map((message) => [message.id, message])) + const existing = data.session_message[sessionID] ?? [] + const current = existing.filter((message) => !incoming.has(message.id)) + const live = new Map(existing.map((message) => [message.id, message])) + return (page.sourceMode === "older" ? [...page.source, ...current] : [...current, ...page.source]).map( + (message) => (load?.touchedSource.has(message.id) ? (live.get(message.id) ?? message) : message), + ) + })() + : undefined + const projected = + page.projectSource && source + ? (() => { + const normalized = normalizeSessionMessages(sessionID, source) + return { + ...page, + session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), + part: [...normalized.parts.entries()] + .map(([id, part]) => ({ id, part: part.sort((a, b) => cmp(a.id, b.id)) })) + .sort((a, b) => cmp(a.id, b.id)), + } + })() + : page + const merged = mergeOptimisticPage(projected, [...(optimistic.get(sessionID)?.values() ?? [])]) merged.observed.forEach((item) => { if (!load?.clearedMessageParts.has(item.messageID)) confirmOptimistic(sessionID, item.messageID, item.parts) }) @@ -583,6 +692,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: preserveUnfetched, }) batch(() => { + if (source) setData("session_message", sessionID, reconcile(source)) const messageIDs = replaceMessages(sessionID, messages) replaceParts(sessionID, merged.part, messageIDs, load) const orphans = orphanParts.get(sessionID) @@ -613,6 +723,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: optimisticParts: new Map(), orphanParents: new Set(), clearedMessageParts: new Set(), + touchedSource: new Set(), } messageLoads.set(sessionID, load) setMeta("loading", sessionID, true) @@ -747,6 +858,109 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return properties.part.sessionID } + const projectV2 = (reduction: V2SessionReduction) => { + reduction.touched.forEach((messageID) => messageLoads.get(reduction.sessionID)?.touchedSource.add(messageID)) + setData("session_message", reduction.sessionID, reconcile(reduction.messages)) + if (reduction.touched.length === 0) return + + const touched = new Set(reduction.touched) + let parentID: string | undefined + for (const message of reduction.messages) { + if (message.type === "user" || (message.type === "synthetic" && message.description?.trim())) + parentID = message.id + if (message.type === "shell") { + if (touched.has(message.id)) touched.add(`${message.id}:assistant`) + parentID = undefined + } + if (message.type === "assistant" && touched.has(message.id) && parentID) touched.add(parentID) + if (message.type === "compaction" && touched.has(message.id) && parentID) touched.add(parentID) + } + + const normalized = normalizeSessionMessages(reduction.sessionID, reduction.messages) + batch(() => { + for (const message of normalized.messages) { + if (!touched.has(message.id)) continue + apply({ type: "message.updated", properties: { sessionID: reduction.sessionID, info: message } }) + } + for (const messageID of touched) { + const next = normalized.parts.get(messageID) ?? [] + const nextIDs = new Set(next.map((part) => part.id)) + for (const part of next) { + apply({ type: "message.part.updated", properties: { sessionID: reduction.sessionID, part } }) + } + for (const part of data.part[messageID] ?? []) { + if (nextIDs.has(part.id)) continue + apply({ + type: "message.part.removed", + properties: { sessionID: reduction.sessionID, messageID, partID: part.id }, + }) + } + } + }) + } + + const hydrateV2Message = (sessionID: string, messageID: string) => { + if (!sessionApi) return + void sessionApi + .message({ sessionID, messageID }) + .then((message) => { + const current = data.session_message[sessionID] ?? [] + const messages = [...current.filter((item) => item.id !== message.id), message].sort((a, b) => cmp(a.id, b.id)) + projectV2({ sessionID, messages, touched: [message.id] }) + }) + .catch(() => {}) + } + + const applyV2 = (event: OpenCodeEvent) => { + if (!("data" in event) || !("sessionID" in event.data) || typeof event.data.sessionID !== "string") return + const sessionID = event.data.sessionID + const reduction = v2.reduce(data.session_message[sessionID] ?? [], event) + if (reduction) { + projectV2(reduction) + if (reduction.missing) hydrateV2Message(sessionID, reduction.missing) + } + + const info = data.info[sessionID] + if (event.type === "session.renamed" && info) + remember({ ...info, title: event.data.title, time: { ...info.time, updated: event.created } }) + if (event.type === "session.moved" && info) + remember({ + ...info, + projectID: event.data.projectID ?? info.projectID, + workspaceID: event.data.location.workspaceID, + directory: event.data.location.directory, + path: event.data.subpath, + time: { ...info.time, updated: event.created }, + }) + if (event.type === "session.usage.updated" && info) + remember({ ...info, cost: event.data.cost, tokens: event.data.tokens }) + if (event.type === "session.archived") { + if (info) remember({ ...info, time: { ...info.time, archived: event.created, updated: event.created } }) + evict([sessionID]) + } + if (event.type === "session.execution.started") setData("session_status", sessionID, { type: "busy" }) + if ( + event.type === "session.execution.succeeded" || + event.type === "session.execution.failed" || + event.type === "session.execution.interrupted" + ) + setData("session_status", sessionID, { type: "idle" }) + if (event.type === "session.retry.scheduled") + setData("session_status", sessionID, { + type: "retry", + attempt: event.data.attempt, + message: event.data.error.message, + next: event.data.at, + }) + if (event.type === "session.forked") void resolve(sessionID, { force: true }).catch(() => {}) + if ( + event.type === "session.revert.staged" || + event.type === "session.revert.cleared" || + event.type === "session.revert.committed" + ) + void resolve(sessionID, { force: true }).catch(() => {}) + } + const apply = (event: { type: string; properties?: unknown }) => { const eventID = eventSessionID(event) if (eventID) { @@ -770,7 +984,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: return } case "session.deleted": { - const sessionID = (event.properties as { info: Session }).info.id + const properties = event.properties as { sessionID?: string; info?: Session } + const sessionID = properties.info?.id ?? properties.sessionID + if (!sessionID) return infoSeen.delete(sessionID) setData( "info", @@ -779,11 +995,6 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: evict([sessionID]) return } - case "session.diff": { - const props = event.properties as { sessionID: string; diff: SnapshotFileDiff[] } - setData("session_diff", props.sessionID, reconcile(cleanDiffs(props.diff), { key: "file" })) - return - } case "todo.updated": { const props = event.properties as { sessionID: string; todos: Todo[] } setData("todo", props.sessionID, reconcile(props.todos, { key: "id" })) @@ -796,6 +1007,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } case "message.updated": { const info = cleanMessage((event.properties as { info: Message }).info) + indexLegacyMessage(info) const load = messageLoads.get(info.sessionID) load?.touchedMessages.add(info.id) load?.removedMessages.delete(info.id) @@ -828,6 +1040,9 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: } case "message.removed": { const props = event.properties as { sessionID: string; messageID: string } + setData("session_message", props.sessionID, (messages) => + messages?.filter((message) => message.id !== props.messageID), + ) const load = messageLoads.get(props.sessionID) load?.touchedMessages.add(props.messageID) load?.removedMessages.add(props.messageID) @@ -1140,23 +1355,16 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: setData(produce((draft) => deleteMessageParts(draft, input.messageID))) }, }, - diff(sessionID: string, options?: { force?: boolean }) { + async todo(sessionID: string, request?: { force?: boolean }) { touch(sessionID) - if (data.session_diff[sessionID] !== undefined && !options?.force) return Promise.resolve() - return runInflight(inflightDiff, sessionID, () => { - const active = generation(sessionID) - return retry(() => client.session.diff({ sessionID })).then((result) => { - if (generations.get(sessionID) !== active) return - setData("session_diff", sessionID, reconcile(cleanDiffs(result.data), { key: "file" })) - }) - }) - }, - todo(sessionID: string, options?: { force?: boolean }) { - touch(sessionID) - if (data.todo[sessionID] !== undefined && !options?.force) return Promise.resolve() + if (data.todo[sessionID] !== undefined && !request?.force) return + if ((await options?.protocol) === "v2") { + setData("todo", sessionID, []) + return + } return runInflight(inflightTodo, sessionID, () => { const active = generation(sessionID) - return retry(() => client.session.todo({ sessionID })).then((result) => { + return (options?.retry ?? retry)(() => client.session.todo({ sessionID })).then((result) => { if (generations.get(sessionID) !== active) return setData("todo", sessionID, reconcile(result.data ?? [], { key: "id" })) }) @@ -1190,6 +1398,7 @@ export function createServerSession(client: OpencodeClient, options?: { retry?: if (count && count > 1) pinned.set(sessionID, count - 1) }, apply, + applyV2, } } diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 93e9c41755..3614d57f66 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,6 +1,108 @@ import { describe, expect, test } from "bun:test" +import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" +import type { + McpApi, + McpListInput, + McpResourceCatalogInput, + SessionApi, + SessionInfo, + SessionListInput, +} from "@opencode-ai/client/promise" +import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" +import { + loadActiveSessionsQuery, + loadMcpQuery, + loadMcpResourcesQuery, + seedActiveSessionStatuses, +} from "./server-sync" +import { ServerScope } from "@/utils/server-scope" +import { createServerSession } from "./server-session" + +describe("MCP queries", () => { + test("loads current servers for the requested location", async () => { + const calls: unknown[] = [] + const queryClient = new QueryClient() + const result = await queryClient.fetchQuery( + loadMcpQuery(ServerScope.local, "/project", { + list: async (input: McpListInput = {}) => { + calls.push(input) + return { + location: { directory: "/project", project: { id: "project", directory: "/project" } }, + data: [ + { name: "docs", status: { status: "connected" } }, + { name: "search", status: { status: "pending" } }, + ], + } + }, + } as unknown as McpApi), + ) + + expect(calls).toEqual([{ location: { directory: "/project" } }]) + expect(result).toEqual({ docs: { status: "connected" }, search: { status: "pending" } }) + }) + + test("loads and keys the current resource catalog", async () => { + const calls: unknown[] = [] + const queryClient = new QueryClient() + const result = await queryClient.fetchQuery( + loadMcpResourcesQuery(ServerScope.local, "/project", { + resource: { + catalog: async (input: McpResourceCatalogInput = {}) => { + calls.push(input) + return { + location: { directory: "/project", project: { id: "project", directory: "/project" } }, + data: { + resources: [{ server: "docs", name: "Guide", uri: "docs://guide" }], + templates: [], + }, + } + }, + }, + } as unknown as McpApi), + ) + + expect(calls).toEqual([{ location: { directory: "/project" } }]) + expect(result).toEqual({ "docs:docs://guide": { server: "docs", name: "Guide", uri: "docs://guide" } }) + }) +}) + +describe("active session query", () => { + test("loads active sessions once per server cache", async () => { + let calls = 0 + const queryClient = new QueryClient() + const options = loadActiveSessionsQuery(ServerScope.local, { + active: async () => { + calls++ + return { ses_running: { type: "running" } } + }, + }) + + expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) + expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) + expect(calls).toBe(1) + expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"]) + }) + + test("does not overwrite statuses already written by events", () => { + const session = createServerSession({} as OpencodeClient) + session.set("session_status", "ses_retry", { type: "retry", attempt: 2, message: "retrying", next: 10 }) + + seedActiveSessionStatuses(session, { + ses_running: { type: "running" }, + ses_retry: { type: "running" }, + }) + + expect(session.data.session_status.ses_running).toEqual({ type: "busy" }) + expect(session.data.session_status.ses_retry).toEqual({ + type: "retry", + attempt: 2, + message: "retrying", + next: 10, + }) + }) +}) describe("pickDirectoriesToEvict", () => { test("keeps pinned stores and evicts idle stores", () => { @@ -23,46 +125,57 @@ describe("pickDirectoriesToEvict", () => { }) }) -describe("loadRootSessionsWithFallback", () => { - test("uses limited roots query when supported", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] +describe("loadRootSessions", () => { + test("loads and normalizes a limited page of root sessions", async () => { + const calls: SessionListInput[] = [] - const result = await loadRootSessionsWithFallback({ + const result = await loadRootSessions({ + api: { + list: async (query = {}) => { + calls.push(query) + return { data: [sessionInfo("session-1")], cursor: {} } + }, + } satisfies Pick, directory: "dir", limit: 10, - list: async (query) => { - calls.push(query) - return { data: [] } - }, }) - expect(result.data).toEqual([]) + expect(result.data).toEqual([ + expect.objectContaining({ id: "session-1", directory: "dir", slug: "session-1", version: "" }), + ]) expect(result.limited).toBe(true) - expect(calls).toEqual([{ directory: "dir", roots: true, limit: 10 }]) + expect(calls).toEqual([{ directory: "dir", parentID: null, limit: 10, order: "desc" }]) }) - test("falls back to full roots query on limited-query failure", async () => { - const calls: Array<{ directory: string; roots: true; limit?: number }> = [] - - const result = await loadRootSessionsWithFallback({ - directory: "dir", - limit: 25, - list: async (query) => { - calls.push(query) - if (query.limit) throw new Error("unsupported") - return { data: [] } - }, - }) - - expect(result.data).toEqual([]) - expect(result.limited).toBe(false) - expect(calls).toEqual([ - { directory: "dir", roots: true, limit: 25 }, - { directory: "dir", roots: true }, - ]) + test("propagates list failures", () => { + expect( + loadRootSessions({ + api: { + list: async () => { + throw new Error("failed") + }, + } satisfies Pick, + directory: "dir", + limit: 25, + }), + ).rejects.toThrow("failed") }) }) +function sessionInfo(id: string) { + return { + id, + projectID: "project-1", + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: id, + location: { directory: "dir" }, + } as SessionInfo +} + describe("estimateRootSessionTotal", () => { test("keeps exact total for full fetches", () => { expect(estimateRootSessionTotal({ count: 42, limit: 10, limited: false })).toBe(42) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 05806fba54..5ce530bbb0 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -1,10 +1,10 @@ import type { Config, - McpResource, OpencodeClient, Path, Project, ProviderAuthResponse, + SessionStatus, } from "@opencode-ai/sdk/v2/client" import { showToast } from "@/utils/toast" import { getFilename } from "@opencode-ai/core/util/path" @@ -18,6 +18,7 @@ import { bootstrapGlobal, clearProviderRev, loadAgentsQuery, + loadCommands, loadGlobalConfigQuery, loadPathQuery, loadProjectsQuery, @@ -26,12 +27,13 @@ import { } from "./global-sync/bootstrap" import { createChildStoreManager } from "./global-sync/child-store" import { applyDirectoryEvent, applyGlobalEvent } from "./global-sync/event-reducer" -import { estimateRootSessionTotal, loadRootSessionsWithFallback } from "./global-sync/session-load" +import { estimateRootSessionTotal, loadRootSessions, loadRootSessionsV1 } from "./global-sync/session-load" import { trimSessions } from "./global-sync/session-trim" import type { ProjectMeta } from "./global-sync/types" import { SESSION_RECENT_LIMIT } from "./global-sync/types" import { formatServerError } from "@/utils/server-errors" import { queryOptions, useMutation, useQueries, useQuery, useQueryClient } from "@tanstack/solid-query" +import type { SolidQueryOptions } from "@tanstack/solid-query" import { createRefreshQueue } from "./global-sync/queue" import { directoryKey } from "./global-sync/utils" import { PathKey } from "@/utils/path-key" @@ -45,8 +47,18 @@ import { retry } from "@opencode-ai/core/util/retry" import type { ServerScope } from "@/utils/server-scope" import { createHomeSessionIndexCache } from "./global-sync/home-session-index" import { persisted } from "@/utils/persist" +import type { ServerApi } from "@/utils/server" +import type { + McpListInput, + McpListOutput, + McpResource, + McpResourceCatalogInput, + McpResourceCatalogOutput, + McpServer, + SessionActiveOutput, +} from "@opencode-ai/client/promise" import { toggleMcp } from "./global-sync/mcp" -import { createServerSession } from "./server-session" +import { createServerSession, type ServerSession } from "./server-session" type GlobalStore = { ready: boolean @@ -59,16 +71,76 @@ type GlobalStore = { reload: undefined | "pending" | "complete" } -export const loadMcpQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions({ +type McpListApi = { + readonly list: (input?: McpListInput) => Promise +} + +type McpResourceApi = { + readonly resource: { + readonly catalog: (input?: McpResourceCatalogInput) => Promise + } +} + +type ApiQueryOptions = SolidQueryOptions & { + initialData?: undefined + queryKey: K +} + +type SessionActiveApi = { + readonly active: () => Promise +} + +export const loadMcpQuery = ( + scope: ServerScope, + directory: string, + api: McpListApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcp"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcp"] + >({ queryKey: [scope, directory, "mcp"] as const, - queryFn: () => sdk.mcp.status().then((r) => r.data ?? {}), + queryFn: async () => { + if ((await protocol) === "v1" && legacy) return (await legacy.mcp.status()).data ?? {} + return api + .list({ location: { directory } }) + .then((result) => Object.fromEntries(result.data.map((server) => [server.name, server.status]))) + }, }) -export const loadMcpResourcesQuery = (scope: ServerScope, directory: string, sdk: OpencodeClient) => - queryOptions>({ +export const loadMcpResourcesQuery = ( + scope: ServerScope, + directory: string, + api: McpResourceApi, + legacy?: OpencodeClient, + protocol?: Promise<"v1" | "v2">, +): ApiQueryOptions, readonly [ServerScope, string, "mcpResources"]> => + queryOptions< + Record, + Error, + Record, + readonly [ServerScope, string, "mcpResources"] + >({ queryKey: [scope, directory, "mcpResources"] as const, - queryFn: () => sdk.experimental.resource.list().then((r) => r.data ?? {}), + queryFn: async () => { + if ((await protocol) === "v1" && legacy) { + return Object.fromEntries( + Object.entries((await legacy.experimental.resource.list()).data ?? {}).map(([key, resource]) => [ + key, + { ...resource, server: resource.client }, + ]), + ) + } + return api.resource + .catalog({ location: { directory } }) + .then((result) => + Object.fromEntries(result.data.resources.map((resource) => [`${resource.server}:${resource.uri}`, resource])), + ) + }, placeholderData: {}, }) @@ -78,22 +150,51 @@ export const loadLspQuery = (scope: ServerScope, directory: string, sdk: Opencod queryFn: () => sdk.lsp.status().then((r) => r.data ?? []), }) +export const loadActiveSessionsQuery = ( + scope: ServerScope, + api: SessionActiveApi, +): ApiQueryOptions => + queryOptions({ + queryKey: [scope, "activeSessions"] as const, + queryFn: () => api.active(), + enabled: false, + staleTime: Number.POSITIVE_INFINITY, + gcTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnReconnect: false, + refetchOnWindowFocus: false, + }) + +export function seedActiveSessionStatuses( + session: Pick, + active: SessionActiveOutput | Record, +) { + for (const sessionID of Object.keys(active)) { + if (session.data.session_status[sessionID] !== undefined) continue + const status = active[sessionID] + session.set("session_status", sessionID, status?.type === "running" ? { type: "busy" } : status) + } +} + function makeQueryOptionsApi( scope: ServerScope, serverSDK: () => OpencodeClient, + serverAPI: ServerApi, sdkFor: (dir: PathKey) => OpencodeClient, + protocol: Promise<"v1" | "v2">, ) { return { globalConfig: () => loadGlobalConfigQuery(scope, serverSDK()), - projects: () => loadProjectsQuery(scope, serverSDK()), + projects: () => loadProjectsQuery(scope, serverAPI.project), providers: (directory: PathKey | null) => - loadProvidersQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - path: (directory: PathKey | null) => - loadPathQuery(scope, directory, directory === null ? serverSDK() : sdkFor(directory)), - agents: (directory: PathKey) => loadAgentsQuery(scope, directory, sdkFor(directory)), - references: (directory: PathKey) => loadReferencesQuery(scope, directory, sdkFor(directory)), - mcp: (directory: PathKey) => loadMcpQuery(scope, directory, sdkFor(directory)), - mcpResources: (directory: PathKey) => loadMcpResourcesQuery(scope, directory, sdkFor(directory)), + loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), + path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path), + agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), + references: (directory: PathKey) => + loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), + mcp: (directory: PathKey) => loadMcpQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), + mcpResources: (directory: PathKey) => + loadMcpResourcesQuery(scope, directory, serverAPI.mcp, sdkFor(directory), protocol), lsp: (directory: PathKey) => loadLspQuery(scope, directory, sdkFor(directory)), sessions: (directory: PathKey) => ({ queryKey: [scope, directory, "loadSessions"] as const }), } @@ -122,11 +223,44 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return sdk } - const queryOptionsApi = makeQueryOptionsApi(serverSDK.scope, () => serverSDK.client, sdkFor) + const session = createServerSession(serverSDK.client, serverSDK.api.session, serverSDK.api.message, { + protocol: serverSDK.protocol, + }) + const queryOptionsApi = makeQueryOptionsApi( + serverSDK.scope, + () => serverSDK.client, + serverSDK.api, + sdkFor, + serverSDK.protocol, + ) const [configQuery, providerQuery, pathQuery] = useQueries(() => ({ queries: [queryOptionsApi.globalConfig(), queryOptionsApi.providers(null), queryOptionsApi.path(null)], })) + const activeSessionsQuery = useQuery(() => + loadActiveSessionsQuery(serverSDK.scope, { + active: async () => { + if ((await serverSDK.protocol) === "v1") { + const statuses = (await serverSDK.client.session.status()).data ?? {} + for (const [sessionID, status] of Object.entries(statuses)) { + session.set("session_status", sessionID, reconcile(status)) + void session.resolve(sessionID).catch(() => undefined) + } + return Object.fromEntries( + Object.entries(statuses).flatMap(([sessionID, status]) => + status.type === "idle" ? [] : [[sessionID, { type: "running" as const }]], + ), + ) + } + const active = await serverSDK.api.session.active() + seedActiveSessionStatuses(session, active) + for (const sessionID of Object.keys(active)) { + void session.resolve(sessionID).catch(() => undefined) + } + return active + }, + }), + ) const [globalStore, setGlobalStore] = createStore({ get ready() { @@ -183,6 +317,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { queryFn: async () => { await bootstrapGlobal({ serverSDK: serverSDK.client, + serverAPI: serverSDK.api, + protocol: serverSDK.protocol, scope: serverSDK.scope, requestFailedTitle: language.t("common.requestFailed"), translate: language.t, @@ -212,8 +348,6 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { bootstrapInstance, }) - const session = createServerSession(serverSDK.client) - const children = createChildStoreManager({ owner, scope: serverSDK.scope, @@ -224,17 +358,15 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { void bootstrapInstance(directory) }, onMcp: (directory, setStore) => { - void retry(() => - sdkFor(directory) - .command.list() - .then((x) => setStore("command", x.data ?? [])), - ).catch((err) => { - showToast({ - variant: "error", - title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), - description: formatServerError(err, language.t), + void loadCommands(directory, serverSDK.api.command, sdkFor(directory), serverSDK.protocol) + .then((commands) => setStore("command", commands)) + .catch((err) => { + showToast({ + variant: "error", + title: language.t("toast.project.reloadFailed.title", { project: getFilename(directory) }), + description: formatServerError(err, language.t), + }) }) - }) }, onDispose: (directory) => { const key = directoryKey(directory) @@ -279,11 +411,12 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { .fetchQuery({ ...queryOptionsApi.sessions(key), queryFn: () => - loadRootSessionsWithFallback({ - directory, - limit, - list: (query) => serverSDK.client.session.list(query), - }) + serverSDK.protocol + .then((protocol) => + protocol === "v1" + ? loadRootSessionsV1({ client: sdkFor(directory), directory, limit }) + : loadRootSessions({ api: serverSDK.api.session, directory, limit }), + ) .then((x) => { const nonArchived = (x.data ?? []) .filter((s) => !!s?.id) @@ -353,6 +486,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { provider: globalStore.provider, }, sdk, + api: serverSDK.api, store: child[0], setStore: child[1], vcsCache: cache, @@ -360,6 +494,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { translate: language.t, queryClient, session, + protocol: serverSDK.protocol, }) }) @@ -371,12 +506,31 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return promise } + const indexSession = (info: Parameters[0]) => { + const key = directoryKey(info.directory) + const existing = children.children[key] + if (!existing) return + applyDirectoryEvent({ + event: { type: "session.created", properties: { info } }, + directory: key, + store: existing[0], + setStore: existing[1], + push: queue.push, + retainedLimit: sessionMeta.get(key)?.limit, + sessionContent: false, + permission: session.data.permission, + loadLsp() {}, + }) + } + const unsub = serverSDK.event.listen((e) => { const directory = e.name const key = directoryKey(directory) const event = e.details + const eventType: string = event.type const recent = bootingRoot || Date.now() - bootedAt < 1500 + if (event.current) session.applyV2(event.current) session.apply(event) if (event.type === "session.created" || event.type === "session.updated" || event.type === "session.deleted") { homeSessions.apply(event) @@ -384,6 +538,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { homeSessions.refresh(event.type) if (directory === "global") { + if (eventType === "server.connected" && activeSessionsQuery.data === undefined && !activeSessionsQuery.isFetching) + void activeSessionsQuery.refetch() applyGlobalEvent({ event, project: globalStore.project, @@ -393,7 +549,14 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { }, setGlobalProject: setProjects, }) - if (event.type === "server.connected" || event.type === "global.disposed") { + if ( + eventType === "config.updated" || + eventType === "catalog.updated" || + eventType === "agent.updated" || + eventType === "project.directories.updated" + ) + bootstrap.refetch() + if (eventType === "server.connected" || eventType === "global.disposed") { if (recent) return for (const directory of Object.keys(children.children)) { if (!children.active(directory)) continue @@ -403,9 +566,30 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { return } + if (event.current?.type === "session.moved") { + const info = session.get(event.current.data.sessionID) + if (info) indexSession(info) + } + if (event.current?.type === "session.forked") + void session + .resolve(event.current.data.sessionID, { force: true }) + .then(indexSession) + .catch(() => {}) + const existing = children.children[key] if (!existing) return children.mark(key) + if ( + event.current?.type === "session.moved" || + event.current?.type === "session.archived" || + event.current?.type === "session.forked" || + eventType === "command.updated" || + eventType === "config.updated" || + eventType === "agent.updated" + ) + queue.push(key) + if (eventType === "mcp.status.changed") void queryClient.invalidateQueries(queryOptionsApi.mcp(key)) + if (eventType === "mcp.resources.changed") void queryClient.invalidateQueries(queryOptionsApi.mcpResources(key)) const [store, setStore] = existing applyDirectoryEvent({ event, @@ -502,14 +686,23 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { toggle: async (directory: string, name: string) => { const key = directoryKey(directory) const sdk = sdkFor(key) - const status = children.child(key, { bootstrap: false })[0].mcp[name].status + const status = children.child(key, { bootstrap: false })[0].mcp[name]?.status + if (!status) return await toggleMcp({ status, connect: async () => { - await sdk.mcp.connect({ name }) + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.connect({ name }) + return + } + await serverSDK.api.mcp.connect({ server: name, location: { directory: key } }) }, disconnect: async () => { - await sdk.mcp.disconnect({ name }) + if ((await serverSDK.protocol) === "v1") { + await sdk.mcp.disconnect({ name }) + return + } + await serverSDK.api.mcp.disconnect({ server: name, location: { directory: key } }) }, authenticate: async () => { await sdk.mcp.auth.authenticate({ name }) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 3ce2841676..067a796945 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -532,7 +532,6 @@ export default function Page() { const info = createMemo(() => (params.id ? sync().session.get(params.id) : undefined)) const isChildSession = createMemo(() => !!info()?.parentID) - const diffs = createMemo(() => (params.id ? list(sync().data.session_diff[params.id]) : [])) const canReview = createMemo(() => !!sync().project) const reviewTab = createMemo(() => isDesktop()) const tabState = createSessionTabs({ @@ -690,8 +689,8 @@ export default function Page() { queryFn: mode ? () => sdk() - .client.vcs.diff({ mode }) - .then((result) => list(result.data)) + .api.vcs.diff({ location: { directory: sdk().directory }, mode: mode === "git" ? "working" : mode }) + .then((result) => result.data) .catch((error) => { console.debug("[session-review] failed to load vcs diff", { mode, error }) return [] @@ -738,8 +737,12 @@ export default function Page() { retry: 2, queryFn: () => sdk() - .client.vcs.diff({ mode, directory: scope, context }) - .then((result) => result.data ?? []), + .api.vcs.diff({ + location: { directory: scope }, + mode: mode === "git" ? "working" : mode, + context, + }) + .then((result) => result.data), }) .then((diffs) => diffs.find((diff) => diff.file === file)) @@ -946,10 +949,11 @@ export default function Page() { ) const stopVcs = sdk().event.listen((evt) => { - if (evt.details.type !== "file.watcher.updated") return + const details = evt.details as { type: string; properties?: unknown } + if (details.type !== "file.watcher.updated" && details.type !== "filesystem.changed") return const props = - typeof evt.details.properties === "object" && evt.details.properties - ? (evt.details.properties as Record) + typeof details.properties === "object" && details.properties + ? (details.properties as Record) : undefined const file = typeof props?.file === "string" ? props.file : undefined if (!file || file.startsWith(".git/")) return @@ -1464,44 +1468,6 @@ export default function Page() { requestAnimationFrame(() => attempt(0)) }) - createEffect(() => { - const id = params.id - if (!id) return - - if (!wantsReview()) return - if (sync().data.session_diff[id] !== undefined) return - if (sync().status === "loading") return - - void sync().session.diff(id) - }) - - createEffect( - on( - () => [sessionKey(), wantsReview()] as const, - ([key, wants]) => { - if (diffFrame !== undefined) cancelAnimationFrame(diffFrame) - if (diffTimer !== undefined) window.clearTimeout(diffTimer) - diffFrame = undefined - diffTimer = undefined - if (!wants) return - - const id = params.id - if (!id) return - if (!untrack(() => sync().data.session_diff[id] !== undefined)) return - - diffFrame = requestAnimationFrame(() => { - diffFrame = undefined - diffTimer = window.setTimeout(() => { - diffTimer = undefined - if (sessionKey() !== key) return - void sync().session.diff(id, { force: true }) - }, 0) - }) - }, - { defer: true }, - ), - ) - let treeDir: string | undefined createEffect(() => { const dir = sdk().directory @@ -1757,7 +1723,7 @@ export default function Page() { setFollowup("failed", input.sessionID, undefined) const ok = await sendFollowupDraft({ - client: sdk().client, + api: sdk().api.session, sync: sync(), serverSync: serverSync(), draft: item, @@ -1853,13 +1819,13 @@ export default function Page() { const halt = (sessionID: string) => busy(sessionID) ? sdk() - .client.session.abort({ sessionID }) + .api.session.interrupt({ sessionID }) .catch(() => {}) : Promise.resolve() const revertMutation = useMutation(() => ({ mutationFn: async (input: { sessionID: string; messageID: string }) => { - const client = sdk().client + const session = sdk().api.session const target = sync() const last = target.session.get(input.sessionID)?.revert const value = draft(input.messageID) @@ -1869,10 +1835,8 @@ export default function Page() { roll(input.sessionID, { messageID: input.messageID }, target) prompt.set(value) }, - request: () => halt(input.sessionID).then(() => client.session.revert(input)), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + request: () => halt(input.sessionID).then(() => session.revert.stage(input)), + complete: () => undefined, rollback: () => roll(input.sessionID, last, target), fail, }) @@ -1884,7 +1848,7 @@ export default function Page() { const sessionID = params.id if (!sessionID) return - const client = sdk().client + const session = sdk().api.session const target = sync() const next = userMessages().find((item) => item.id > id) const last = target.session.get(sessionID)?.revert @@ -1901,11 +1865,9 @@ export default function Page() { }, request: () => !next - ? halt(sessionID).then(() => client.session.unrevert({ sessionID })) - : halt(sessionID).then(() => client.session.revert({ sessionID, messageID: next.id })), - complete: (result) => { - if (result.data) merge(result.data, target) - }, + ? halt(sessionID).then(() => session.revert.clear({ sessionID })) + : halt(sessionID).then(() => session.revert.stage({ sessionID, messageID: next.id }).then(() => undefined)), + complete: () => undefined, rollback: () => roll(sessionID, last, target), fail, }) diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 4907eb41eb..908664cdec 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -2,7 +2,10 @@ import { describe, expect, test } from "bun:test" import { createApiForServer, createSdkForServer } from "./server" import { createCompatibleApi } from "./server-compat" -function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) { +function setup( + protocol: "v1" | "v2" | Promise<"v1" | "v2">, + responses?: { vcs?: { branch: string; default_branch: string } }, +) { const requests: Request[] = [] const fetcher = Object.assign( async (input: string | URL | Request, init?: RequestInit) => { @@ -32,6 +35,8 @@ function setup(protocol: "v1" | "v2" | Promise<"v1" | "v2">) { delivery: "steer", }) } + if (request.method === "GET" && new URL(request.url).pathname === "/vcs") + return Response.json(responses?.vcs ?? {}) if (request.method === "GET") return Response.json([]) return new Response(undefined, { status: 204 }) }, @@ -110,4 +115,12 @@ describe("createCompatibleApi", () => { expect(new URL(requests[0]!.url).pathname).toBe("/experimental/session") }) + + test("projects the V1 default branch", async () => { + const { api } = setup("v1", { vcs: { branch: "feature", default_branch: "dev" } }) + + expect(await api.vcs.get({ location: { directory: "/repo" } })).toMatchObject({ + data: { branch: "feature", defaultBranch: "dev" }, + }) + }) }) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 1772516900..88282742b9 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -318,7 +318,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { ...input.current.vcs, async get(value?: Parameters[0]) { const result = await legacy(value?.location).vcs.get() - return located({ branch: result.data?.branch, defaultBranch: undefined }, value?.location) + return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location) }, async status(value?: Parameters[0]) { const result = await legacy(value?.location).vcs.status() diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts new file mode 100644 index 0000000000..a4455c15fe --- /dev/null +++ b/packages/app/src/utils/session-message.test.ts @@ -0,0 +1,200 @@ +import { describe, expect, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "./session-message" + +describe("normalizeSessionMessages", () => { + test("projects current turns into stable legacy rendering records", () => { + const source = [ + { id: "msg_1", type: "agent-switched", agent: "build", time: { created: 1 } }, + { + id: "msg_2", + type: "model-switched", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + time: { created: 2 }, + }, + { + id: "msg_3", + type: "user", + text: "inspect this", + files: [ + { + data: "aGVsbG8=", + mime: "text/plain", + name: "note.txt", + source: { type: "inline" }, + }, + ], + agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }], + time: { created: 3 }, + }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "claude", providerID: "anthropic", variant: "high" }, + content: [ + { type: "reasoning", text: "Thinking", time: { created: 4, completed: 5 } }, + { type: "text", text: "Result" }, + { + type: "tool", + id: "call_1", + name: "read", + state: { + status: "completed", + input: { filePath: "note.txt" }, + structured: { title: "note.txt" }, + content: [{ type: "text", text: "hello" }], + }, + time: { created: 5, ran: 6, completed: 7 }, + }, + ], + cost: 0.1, + tokens: { input: 10, output: 5, reasoning: 2, cache: { read: 1, write: 0 } }, + time: { created: 4, completed: 7 }, + }, + { + id: "msg_5", + type: "compaction", + status: "completed", + reason: "auto", + summary: "summary", + recent: "recent", + time: { created: 8 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toHaveLength(2) + expect(result.messages[0]).toMatchObject({ + id: "msg_3", + role: "user", + agent: "build", + model: { providerID: "anthropic", modelID: "claude", variant: "high" }, + }) + expect(result.messages[1]).toMatchObject({ id: "msg_4", role: "assistant", parentID: "msg_3", cost: 0.1 }) + expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([ + "msg_3:text:0", + "msg_3:file:0", + "msg_3:agent:0", + "msg_5:compaction", + ]) + expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"]) + expect(result.parts.get("msg_4")?.[2]).toMatchObject({ + type: "tool", + tool: "read", + state: { status: "completed", output: "hello" }, + }) + }) + + test("does not invent a parent for an assistant-only page", () => { + const source = [ + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "orphan" }], + time: { created: 2 }, + }, + ] satisfies SessionMessageInfo[] + + expect(normalizeSessionMessages("ses_1", source).messages).toEqual([]) + }) + + test("projects a current shell message into a renderable standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "printf hello", + status: "exited", + exit: 0, + output: { output: "hello", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.messages).toEqual([ + expect.objectContaining({ id: "msg_shell", role: "user" }), + expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }), + ]) + expect(result.parts.get("msg_shell")).toEqual([ + expect.objectContaining({ type: "text", text: "printf hello" }), + ]) + expect(result.parts.get("msg_shell:assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "bash", + state: expect.objectContaining({ + status: "completed", + input: { command: "printf hello" }, + output: "hello", + title: "Shell", + }), + }), + ]) + }) + + test("adapts current edit fields for the legacy edit renderer", () => { + const source = [ + { id: "msg_user", type: "user", text: "edit it", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [ + { + type: "tool", + id: "call_edit", + name: "edit", + state: { + status: "completed", + input: { path: "/repo/README.md", oldString: "old", newString: "new" }, + content: [{ type: "text", text: "Edited file successfully" }], + structured: { + files: [ + { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + status: "modified", + }, + ], + replacements: 1, + }, + }, + time: { created: 2, ran: 3, completed: 4 }, + }, + ], + time: { created: 2, completed: 4 }, + }, + ] satisfies SessionMessageInfo[] + + const result = normalizeSessionMessages("ses_1", source) + + expect(result.parts.get("msg_assistant")).toEqual([ + expect.objectContaining({ + type: "tool", + tool: "edit", + state: expect.objectContaining({ + status: "completed", + input: expect.objectContaining({ path: "/repo/README.md", filePath: "/repo/README.md" }), + metadata: expect.objectContaining({ + filediff: { + file: "README.md", + patch: "@@ -1 +1 @@\n-old\n+new", + additions: 1, + deletions: 1, + }, + }), + }), + }), + ]) + }) +}) diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts new file mode 100644 index 0000000000..71eebb864e --- /dev/null +++ b/packages/app/src/utils/session-message.ts @@ -0,0 +1,348 @@ +import type { + SessionMessageAssistant, + SessionMessageAssistantTool, + SessionMessageInfo, + SessionMessageShell, + SessionMessageUser, +} from "@opencode-ai/client/promise" +import type { AssistantMessage, FilePart, Message, Part, ToolPart, UserMessage } from "@opencode-ai/sdk/v2" +import { Option, Schema } from "effect" + +const emptyTokens = { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } } +const emptyModel: { id: string; providerID: string; variant?: string } = { id: "", providerID: "" } +const decodeToolInput = Schema.decodeUnknownOption(Schema.UnknownFromJsonString) + +function record(value: unknown): value is Record { + return !!value && typeof value === "object" && !Array.isArray(value) +} + +function normalizeToolInput(name: string, input: Record) { + if (!["edit", "write"].includes(name) || typeof input.path !== "string" || typeof input.filePath === "string") + return input + return { ...input, filePath: input.path } +} + +function normalizeToolMetadata(name: string, metadata: Record) { + if (name !== "edit" || !Array.isArray(metadata.files)) return metadata + const file = metadata.files.find(record) + if (!file || typeof file.file !== "string") return metadata + return { + ...metadata, + filediff: { + file: file.file, + patch: typeof file.patch === "string" ? file.patch : undefined, + additions: typeof file.additions === "number" ? file.additions : 0, + deletions: typeof file.deletions === "number" ? file.deletions : 0, + }, + } +} + +export function normalizeSessionMessages(sessionID: string, source: readonly SessionMessageInfo[]) { + const messages: Message[] = [] + const parts = new Map() + let agent = "" + let model = emptyModel + let parentID: string | undefined + + source.forEach((message) => { + if (message.type === "agent-switched") { + agent = message.agent + return + } + if (message.type === "model-switched") { + model = message.model + return + } + if (message.type === "user") { + parentID = message.id + messages.push(userMessage(sessionID, message, agent, model)) + parts.set(message.id, userParts(sessionID, message)) + return + } + if (message.type === "synthetic" && message.description?.trim()) { + parentID = message.id + messages.push({ + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.description, true)]) + return + } + if (message.type === "shell") { + messages.push(...shellMessages(sessionID, message, agent, model)) + parts.set(message.id, [textPart(sessionID, message.id, 0, message.command)]) + parts.set(`${message.id}:assistant`, [shellPart(sessionID, message)]) + parentID = undefined + return + } + if (message.type === "assistant") { + agent = message.agent + model = message.model + if (!parentID) return + const parent = messages.findLast((item) => item.id === parentID) + if (parent?.role === "user") { + parent.agent = message.agent + parent.model = { + providerID: message.model.providerID, + modelID: message.model.id, + variant: message.model.variant, + } + } + messages.push(assistantMessage(sessionID, parentID, message)) + parts.set(message.id, assistantParts(sessionID, message)) + return + } + if (message.type !== "compaction" || !parentID) return + parts.set(parentID, [ + ...(parts.get(parentID) ?? []), + { + id: `${message.id}:compaction`, + sessionID, + messageID: parentID, + type: "compaction", + auto: message.reason === "auto", + }, + ]) + }) + + return { messages, parts } +} + +function shellMessages( + sessionID: string, + message: SessionMessageShell, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): [UserMessage, AssistantMessage] { + return [ + { + id: message.id, + sessionID, + role: "user", + time: { created: message.time.created }, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + }, + { + id: `${message.id}:assistant`, + sessionID, + role: "assistant", + time: message.time, + parentID: message.id, + modelID: model.id, + providerID: model.providerID, + variant: model.variant, + mode: agent, + agent, + path: { cwd: "", root: "" }, + cost: 0, + tokens: emptyTokens, + }, + ] +} + +function shellPart(sessionID: string, message: SessionMessageShell): ToolPart { + const input = { command: message.command } + const start = message.time.created + const state: ToolPart["state"] = + message.status === "running" + ? { status: "running", input, time: { start } } + : { + status: "completed", + input, + output: message.output?.output ?? "", + title: "Shell", + metadata: { + status: message.status, + exit: message.exit, + truncated: message.output?.truncated, + }, + time: { start, end: message.time.completed ?? start }, + } + return { + id: `${message.id}:tool`, + sessionID, + messageID: `${message.id}:assistant`, + type: "tool", + callID: message.shellID, + tool: "bash", + state, + } +} + +export function sessionMessagePartID(messageID: string, type: "text" | "reasoning", ordinal: number) { + return `${messageID}:${type}:${ordinal}` +} + +function userMessage( + sessionID: string, + message: SessionMessageUser, + agent: string, + model: { id: string; providerID: string; variant?: string }, +): UserMessage { + return { + id: message.id, + sessionID, + role: "user", + time: message.time, + agent, + model: { providerID: model.providerID, modelID: model.id, variant: model.variant }, + } +} + +function userParts(sessionID: string, message: SessionMessageUser): Part[] { + return [ + textPart(sessionID, message.id, 0, message.text), + ...(message.files ?? []).map( + (file, index): FilePart => ({ + id: `${message.id}:file:${index}`, + sessionID, + messageID: message.id, + type: "file", + mime: file.mime, + filename: file.name, + url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + }), + ), + ...(message.agents ?? []).map( + (item, index): Part => ({ + id: `${message.id}:agent:${index}`, + sessionID, + messageID: message.id, + type: "agent", + name: item.name, + source: item.mention + ? { value: item.mention.text, start: item.mention.start, end: item.mention.end } + : undefined, + }), + ), + ] +} + +function assistantMessage(sessionID: string, parentID: string, message: SessionMessageAssistant): AssistantMessage { + const error = message.error + ? message.error.type.toLowerCase().includes("abort") || message.error.type.toLowerCase().includes("interrupt") + ? { name: "MessageAbortedError" as const, data: { message: message.error.message } } + : { name: "UnknownError" as const, data: { message: message.error.message } } + : undefined + return { + id: message.id, + sessionID, + role: "assistant", + time: message.time, + error, + parentID, + modelID: message.model.id, + providerID: message.model.providerID, + variant: message.model.variant, + mode: message.agent, + agent: message.agent, + path: { cwd: "", root: "" }, + cost: message.cost ?? 0, + tokens: message.tokens ?? emptyTokens, + finish: message.finish, + } +} + +function assistantParts(sessionID: string, message: SessionMessageAssistant): Part[] { + const ordinals = { text: 0, reasoning: 0 } + return message.content.flatMap((content): Part[] => { + if (content.type === "text") { + const part = textPart(sessionID, message.id, ordinals.text++, content.text) + return content.text.trim() ? [part] : [] + } + if (content.type === "reasoning") { + const part: Part = { + id: sessionMessagePartID(message.id, "reasoning", ordinals.reasoning++), + sessionID, + messageID: message.id, + type: "reasoning", + text: content.text, + metadata: content.state, + time: { + start: content.time?.created ?? message.time.created, + end: content.time?.completed, + }, + } + return content.text.trim() ? [part] : [] + } + return [toolPart(sessionID, message.id, content)] + }) +} + +function textPart(sessionID: string, messageID: string, ordinal: number, text: string, synthetic?: boolean): Part { + return { + id: sessionMessagePartID(messageID, "text", ordinal), + sessionID, + messageID, + type: "text", + text, + synthetic, + } +} + +function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssistantTool): ToolPart { + const start = tool.time.ran ?? tool.time.created + const state = (() => { + if (tool.state.status === "streaming") { + const value = Option.getOrUndefined(decodeToolInput(tool.state.input)) + const input = normalizeToolInput(tool.name, record(value) ? value : {}) + return { status: "pending" as const, input, raw: tool.state.input } + } + if (tool.state.status === "running") { + return { + status: "running" as const, + input: normalizeToolInput(tool.name, tool.state.input), + metadata: normalizeToolMetadata(tool.name, tool.state.structured), + time: { start }, + } + } + if (tool.state.status === "error") { + return { + status: "error" as const, + input: normalizeToolInput(tool.name, tool.state.input), + error: tool.state.error.message, + metadata: normalizeToolMetadata(tool.name, tool.state.structured), + time: { start, end: tool.time.completed ?? start }, + } + } + const attachments = tool.state.content.flatMap((item, index): FilePart[] => + item.type === "file" + ? [ + { + id: `${tool.id}:file:${index}`, + sessionID, + messageID, + type: "file", + mime: item.mime, + filename: item.name, + url: item.uri, + }, + ] + : [], + ) + return { + status: "completed" as const, + input: normalizeToolInput(tool.name, tool.state.input), + output: tool.state.content.flatMap((item) => (item.type === "text" ? [item.text] : [])).join("\n"), + title: tool.name, + metadata: normalizeToolMetadata(tool.name, tool.state.structured), + time: { start, end: tool.time.completed ?? start }, + attachments: attachments.length ? attachments : undefined, + } + })() + return { + id: tool.id, + sessionID, + messageID, + type: "tool", + callID: tool.id, + tool: tool.name, + state, + metadata: { providerState: tool.providerState, providerResultState: tool.providerResultState }, + } +} diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts new file mode 100644 index 0000000000..b15c23b660 --- /dev/null +++ b/packages/app/src/utils/session.test.ts @@ -0,0 +1,94 @@ +import { describe, expect, test } from "bun:test" +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import { listAllSessions, normalizeSessionInfo } from "./session" + +describe("normalizeSessionInfo", () => { + test("adapts a current session to the app session shape", () => { + const result = normalizeSessionInfo({ + id: "session-1", + projectID: "project-1", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, + title: "New session", + location: { directory: "/repo/worktree", workspaceID: "workspace-1" }, + subpath: "worktree", + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot", files: [] }, + } as SessionInfo) + + expect(result).toEqual({ + id: "session-1", + slug: "session-1", + projectID: "project-1", + workspaceID: "workspace-1", + directory: "/repo/worktree", + path: "worktree", + parentID: undefined, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + title: "New session", + agent: "build", + model: { id: "gpt-5", providerID: "openai", variant: "high" }, + version: "", + time: { created: 1, updated: 1 }, + revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, + }) + }) +}) + +describe("listAllSessions", () => { + test("loads every page in server order and retains the query", async () => { + const calls: SessionListInput[] = [] + const pages = new Map([ + [undefined, { data: [sessionInfo("session-3"), sessionInfo("session-2")], cursor: { next: "next" } }], + ["next", { data: [sessionInfo("session-1", true)], cursor: {} }], + ]) + const api = { + list: async (query = {}) => { + calls.push(query) + return pages.get(query.cursor) ?? { data: [], cursor: {} } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", order: "desc" }) + + expect(result.map((session) => session.id)).toEqual(["session-3", "session-2", "session-1"]) + expect(result[2]?.time.archived).toBe(2) + expect(calls).toEqual([ + { directory: "/repo", order: "desc", limit: 100, cursor: undefined }, + { directory: "/repo", order: "desc", limit: 100, cursor: "next" }, + ]) + }) + + test("requests the terminal empty page when the server returns a next cursor", async () => { + const cursors: Array = [] + const api = { + list: async (query = {}) => { + cursors.push(query.cursor) + if (query.cursor) return { data: [], cursor: { next: "unused" } } + return { data: [sessionInfo("session-1")], cursor: { next: "terminal" } } + }, + } satisfies Pick + + const result = await listAllSessions(api, { directory: "/repo", limit: 25 }) + + expect(result.map((session) => session.id)).toEqual(["session-1"]) + expect(cursors).toEqual([undefined, "terminal"]) + }) +}) + +function sessionInfo(id: string, archived = false) { + return { + id, + projectID: "project-1", + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1, archived: archived ? 2 : undefined }, + title: id, + location: { directory: "/repo" }, + } as SessionInfo +} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts new file mode 100644 index 0000000000..faf847967b --- /dev/null +++ b/packages/app/src/utils/session.ts @@ -0,0 +1,37 @@ +import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" +import type { Session } from "@opencode-ai/sdk/v2/client" + +export function normalizeSessionInfo(input: SessionInfo | Session): Session { + if (!("location" in input)) return input + return { + id: input.id, + slug: input.id, + projectID: input.projectID, + workspaceID: input.location.workspaceID, + directory: input.location.directory, + path: input.subpath, + parentID: input.parentID, + cost: input.cost, + tokens: input.tokens, + title: input.title, + agent: input.agent, + model: input.model, + version: "", + time: input.time, + revert: input.revert && { + messageID: input.revert.messageID, + partID: input.revert.partID, + snapshot: input.revert.snapshot, + }, + } +} + +export async function listAllSessions(api: Pick, input: Omit) { + const load = async (cursor?: string): Promise => { + const result = await api.list({ ...input, limit: input.limit ?? 100, cursor }) + const sessions = result.data.map(normalizeSessionInfo) + if (result.data.length === 0 || !result.cursor.next) return sessions + return [...sessions, ...(await load(result.cursor.next))] + } + return load() +} From adba484df45a799d274d056112086f1588c8d961 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 02:50:38 +0000 Subject: [PATCH 19/48] chore: generate --- .../src/context/global-sync/bootstrap.test.ts | 1 - .../app/src/context/global-sync/utils.test.ts | 7 +- .../context/server-session-v2-reducer.test.ts | 22 +- .../src/context/server-session-v2-reducer.ts | 214 ++++++++++++------ packages/app/src/context/server-sync.test.ts | 7 +- .../app/src/utils/session-message.test.ts | 4 +- 6 files changed, 164 insertions(+), 91 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index dceab47d86..de2baa704c 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -124,7 +124,6 @@ describe("bootstrapDirectory", () => { expect(store.status).toBe("complete") expect(mcpReads).toEqual([]) }) - }) describe("query keys", () => { diff --git a/packages/app/src/context/global-sync/utils.test.ts b/packages/app/src/context/global-sync/utils.test.ts index 83989244a0..69ca494992 100644 --- a/packages/app/src/context/global-sync/utils.test.ts +++ b/packages/app/src/context/global-sync/utils.test.ts @@ -1,5 +1,10 @@ import { describe, expect, test } from "bun:test" -import type { AgentListOutput, ModelDefaultOutput, ModelListOutput, ProviderListOutput } from "@opencode-ai/client/promise" +import type { + AgentListOutput, + ModelDefaultOutput, + ModelListOutput, + ProviderListOutput, +} from "@opencode-ai/client/promise" import { directoryKey, normalizeAgentList, normalizePermissionRequest, normalizeProviderList } from "./utils" describe("normalizeAgentList", () => { diff --git a/packages/app/src/context/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts index 578d636ef6..a1db63b121 100644 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -25,7 +25,12 @@ describe("v2 session reducer", () => { input: { type: "user", delivery: "steer", data: { text: "hello" } }, }, }) - apply({ ...base, id: "evt_promoted", type: "session.input.promoted", data: { sessionID: "ses_1", inputID: "msg_user" } }) + apply({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }) apply({ ...base, id: "evt_step", @@ -136,12 +141,15 @@ describe("v2 session reducer", () => { }) test("requests hydration when promotion admission was missed", () => { - const result = createV2SessionReducer().reduce([], event({ - ...base, - id: "evt_promoted", - type: "session.input.promoted", - data: { sessionID: "ses_1", inputID: "msg_user" }, - })) + const result = createV2SessionReducer().reduce( + [], + event({ + ...base, + id: "evt_promoted", + type: "session.input.promoted", + data: { sessionID: "ses_1", inputID: "msg_user" }, + }), + ) expect(result).toMatchObject({ sessionID: "ses_1", missing: "msg_user", touched: [] }) }) diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts index 10c6676419..3b9719c098 100644 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -103,40 +103,63 @@ export function createV2SessionReducer() { time: { created: event.created }, }) case "session.shell.ended": - return updateMessage(source, (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, (item) => ({ - ...item, - status: event.data.shell.status, - exit: event.data.shell.exit, - output: event.data.output, - time: { ...item.time, completed: event.created }, - }), sessionID) + return updateMessage( + source, + (item): item is Shell => item.type === "shell" && item.shellID === event.data.shell.id, + (item) => ({ + ...item, + status: event.data.shell.status, + exit: event.data.shell.exit, + output: event.data.output, + time: { ...item.time, completed: event.created }, + }), + sessionID, + ) case "session.step.started": { const current = source.findLast((item): item is Assistant => item.type === "assistant" && !item.time.completed) - const completed = current && current.id !== event.data.assistantMessageID - ? update(source, current.id, (item) => item.type === "assistant" ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } : item) - : [...source] + const completed = + current && current.id !== event.data.assistantMessageID + ? update(source, current.id, (item) => + item.type === "assistant" + ? { ...item, retry: undefined, time: { ...item.time, completed: event.created } } + : item, + ) + : [...source] const existing = completed.find((item) => item.id === event.data.assistantMessageID) if (existing?.type === "assistant") - return result(update(completed, existing.id, (item) => item.type === "assistant" ? { - ...item, - agent: event.data.agent, - model: event.data.model, - retry: undefined, - error: undefined, - finish: undefined, - snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, - time: { ...item.time, completed: undefined }, - } : item), current && current.id !== existing.id ? [current.id, existing.id] : [existing.id]) - return result([...completed, { - id: event.data.assistantMessageID, - type: "assistant", - metadata: event.metadata, - agent: event.data.agent, - model: event.data.model, - content: [], - snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, - time: { created: event.created }, - }], current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID]) + return result( + update(completed, existing.id, (item) => + item.type === "assistant" + ? { + ...item, + agent: event.data.agent, + model: event.data.model, + retry: undefined, + error: undefined, + finish: undefined, + snapshot: event.data.snapshot ? { ...item.snapshot, start: event.data.snapshot } : item.snapshot, + time: { ...item.time, completed: undefined }, + } + : item, + ), + current && current.id !== existing.id ? [current.id, existing.id] : [existing.id], + ) + return result( + [ + ...completed, + { + id: event.data.assistantMessageID, + type: "assistant", + metadata: event.metadata, + agent: event.data.agent, + model: event.data.model, + content: [], + snapshot: event.data.snapshot ? { start: event.data.snapshot } : undefined, + time: { created: event.created }, + }, + ], + current ? [current.id, event.data.assistantMessageID] : [event.data.assistantMessageID], + ) } case "session.step.ended": return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ @@ -144,9 +167,10 @@ export function createV2SessionReducer() { finish: event.data.finish, cost: event.data.cost, tokens: event.data.tokens, - snapshot: event.data.snapshot || event.data.files - ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } - : item.snapshot, + snapshot: + event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, time: { ...item.time, completed: event.created }, })) case "session.step.failed": @@ -157,9 +181,10 @@ export function createV2SessionReducer() { retry: undefined, cost: event.data.cost ?? item.cost, tokens: event.data.tokens ?? item.tokens, - snapshot: event.data.snapshot || event.data.files - ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } - : item.snapshot, + snapshot: + event.data.snapshot || event.data.files + ? { ...item.snapshot, end: event.data.snapshot, files: event.data.files } + : item.snapshot, time: { ...item.time, completed: event.created }, })) case "session.text.started": @@ -188,29 +213,46 @@ export function createV2SessionReducer() { }), })) case "session.reasoning.delta": - return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({ - ...item, - text: item.text + event.data.delta, - })) + return updateContent( + source, + event.data.assistantMessageID, + sessionID, + "reasoning", + event.data.ordinal, + (item) => ({ + ...item, + text: item.text + event.data.delta, + }), + ) case "session.reasoning.ended": - return updateContent(source, event.data.assistantMessageID, sessionID, "reasoning", event.data.ordinal, (item) => ({ - ...item, - text: event.data.text, - state: event.data.state ?? item.state, - time: { created: item.time?.created ?? event.created, completed: event.created }, - })) + return updateContent( + source, + event.data.assistantMessageID, + sessionID, + "reasoning", + event.data.ordinal, + (item) => ({ + ...item, + text: event.data.text, + state: event.data.state ?? item.state, + time: { created: item.time?.created ?? event.created, completed: event.created }, + }), + ) case "session.tool.input.started": return updateAssistant(source, event.data.assistantMessageID, sessionID, (item) => ({ ...item, content: item.content.some((content) => content.type === "tool" && content.id === event.data.callID) ? item.content - : [...item.content, { - type: "tool", - id: event.data.callID, - name: event.data.name, - state: { status: "streaming", input: "" }, - time: { created: event.created }, - }], + : [ + ...item.content, + { + type: "tool", + id: event.data.callID, + name: event.data.name, + state: { status: "streaming", input: "" }, + time: { created: event.created }, + }, + ], })) case "session.tool.input.delta": return updateTool(source, event.data.assistantMessageID, event.data.callID, sessionID, (tool) => @@ -295,12 +337,21 @@ export function createV2SessionReducer() { time: { created: event.created }, }) case "session.compaction.delta": - return updateMessage>(source, (item): item is Extract => item.type === "compaction" && item.status === "running", (item) => ({ - ...item, - summary: item.summary + event.data.text, - }), sessionID) + return updateMessage>( + source, + (item): item is Extract => + item.type === "compaction" && item.status === "running", + (item) => ({ + ...item, + summary: item.summary + event.data.text, + }), + sessionID, + ) case "session.compaction.ended": { - const current = source.findLast((item): item is Extract => item.type === "compaction" && item.status === "running") + const current = source.findLast( + (item): item is Extract => + item.type === "compaction" && item.status === "running", + ) if (!current) return append({ id: messageID(event.id), @@ -312,16 +363,22 @@ export function createV2SessionReducer() { recent: event.data.recent, time: { created: event.created }, }) - return result(update(source, current.id, () => ({ - ...current, - status: "completed", - reason: event.data.reason, - summary: event.data.text, - recent: event.data.recent, - })), [current.id]) + return result( + update(source, current.id, () => ({ + ...current, + status: "completed", + reason: event.data.reason, + summary: event.data.text, + recent: event.data.recent, + })), + [current.id], + ) } case "session.compaction.failed": { - const current = source.findLast((item): item is Extract => item.type === "compaction" && item.status === "running") + const current = source.findLast( + (item): item is Extract => + item.type === "compaction" && item.status === "running", + ) const failed: Extract = { id: current?.id ?? event.data.inputID ?? messageID(event.id), type: "compaction", @@ -332,7 +389,10 @@ export function createV2SessionReducer() { time: current?.time ?? { created: event.created }, } if (!current) return append(failed) - return result(update(source, current.id, () => failed), [failed.id]) + return result( + update(source, current.id, () => failed), + [failed.id], + ) } default: return @@ -362,7 +422,7 @@ function update( id: string, apply: (item: SessionMessageInfo) => SessionMessageInfo, ) { - return source.map((item) => item.id === id ? apply(item) : item) + return source.map((item) => (item.id === id ? apply(item) : item)) } function updateMessage( @@ -373,7 +433,11 @@ function updateMessage( ): V2SessionReduction { const current = source.findLast(matches) if (!current) return { sessionID, messages: [...source], touched: [] } - return { sessionID, messages: update(source, current.id, (item) => matches(item) ? apply(item) : item), touched: [current.id] } + return { + sessionID, + messages: update(source, current.id, (item) => (matches(item) ? apply(item) : item)), + touched: [current.id], + } } function updateAssistant( @@ -384,7 +448,7 @@ function updateAssistant( ): V2SessionReduction { return { sessionID, - messages: update(source, id, (item) => item.type === "assistant" ? apply(item) : item), + messages: update(source, id, (item) => (item.type === "assistant" ? apply(item) : item)), touched: source.some((item) => item.id === id && item.type === "assistant") ? [id] : [], } } @@ -395,7 +459,9 @@ function updateContent( sessionID: string, type: T, ordinal: number, - apply: (item: Extract) => Extract, + apply: ( + item: Extract, + ) => Extract, ) { return updateAssistant(source, messageID, sessionID, (assistant) => { let index = -1 @@ -414,11 +480,13 @@ function updateTool( messageID: string, callID: string, sessionID: string, - apply: (item: Extract) => Extract, + apply: ( + item: Extract, + ) => Extract, ) { return updateAssistant(source, messageID, sessionID, (assistant) => ({ ...assistant, - content: assistant.content.map((item) => item.type === "tool" && item.id === callID ? apply(item) : item), + content: assistant.content.map((item) => (item.type === "tool" && item.id === callID ? apply(item) : item)), })) } diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 3614d57f66..ba838bc05a 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -11,12 +11,7 @@ import type { import { QueryClient } from "@tanstack/solid-query" import { canDisposeDirectory, pickDirectoriesToEvict } from "./global-sync/eviction" import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/session-load" -import { - loadActiveSessionsQuery, - loadMcpQuery, - loadMcpResourcesQuery, - seedActiveSessionStatuses, -} from "./server-sync" +import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index a4455c15fe..4f55f3f2c6 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -122,9 +122,7 @@ describe("normalizeSessionMessages", () => { expect.objectContaining({ id: "msg_shell", role: "user" }), expect.objectContaining({ id: "msg_shell:assistant", role: "assistant", parentID: "msg_shell" }), ]) - expect(result.parts.get("msg_shell")).toEqual([ - expect.objectContaining({ type: "text", text: "printf hello" }), - ]) + expect(result.parts.get("msg_shell")).toEqual([expect.objectContaining({ type: "text", text: "printf hello" })]) expect(result.parts.get("msg_shell:assistant")).toEqual([ expect.objectContaining({ type: "tool", From db88c423355a935d2fd266add07715c772f40ab7 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:39:40 +0800 Subject: [PATCH 20/48] fix(app): hydrate v1 session progress (#38606) --- packages/app/src/context/server-sync.test.ts | 3 ++- packages/app/src/context/server-sync.tsx | 6 +++--- 2 files changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index ba838bc05a..9f625c9432 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -64,7 +64,7 @@ describe("MCP queries", () => { }) describe("active session query", () => { - test("loads active sessions once per server cache", async () => { + test("loads active sessions immediately and once per server cache", async () => { let calls = 0 const queryClient = new QueryClient() const options = loadActiveSessionsQuery(ServerScope.local, { @@ -77,6 +77,7 @@ describe("active session query", () => { expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) expect(await queryClient.fetchQuery(options)).toEqual({ ses_running: { type: "running" } }) expect(calls).toBe(1) + expect(options.enabled).toBe(true) expect([...options.queryKey]).toEqual([ServerScope.local, "activeSessions"]) }) diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 5ce530bbb0..109a7bf7d6 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -157,7 +157,7 @@ export const loadActiveSessionsQuery = ( queryOptions({ queryKey: [scope, "activeSessions"] as const, queryFn: () => api.active(), - enabled: false, + enabled: true, staleTime: Number.POSITIVE_INFINITY, gcTime: Number.POSITIVE_INFINITY, refetchOnMount: false, @@ -242,8 +242,8 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { active: async () => { if ((await serverSDK.protocol) === "v1") { const statuses = (await serverSDK.client.session.status()).data ?? {} - for (const [sessionID, status] of Object.entries(statuses)) { - session.set("session_status", sessionID, reconcile(status)) + seedActiveSessionStatuses(session, statuses) + for (const sessionID of Object.keys(statuses)) { void session.resolve(sessionID).catch(() => undefined) } return Object.fromEntries( From ce9a875181b8ac7507e7eb84245b28ed31d75477 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 11:56:48 +0800 Subject: [PATCH 21/48] feat(app): render current session timeline (#38466) --- ...session-parent-hydration-benchmark.spec.ts | 33 ++++- .../session/timeline/message-timeline.tsx | 19 ++- .../src/pages/session/timeline/projection.ts | 56 ++------ .../session/timeline/rows-current.test.ts | 122 ++++++++++++++++++ .../app/src/pages/session/timeline/rows.ts | 50 +++++++ .../src/components/message-part.tsx | 12 +- .../src/components/tool-error-card.tsx | 2 + 7 files changed, 234 insertions(+), 60 deletions(-) create mode 100644 packages/app/src/pages/session/timeline/rows-current.test.ts diff --git a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts index 2a214831da..838af17c93 100644 --- a/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts +++ b/packages/app/e2e/performance/timeline/session-parent-hydration-benchmark.spec.ts @@ -41,7 +41,12 @@ const assistants = Array.from({ length: 14 }, (_, index) => { const messages = [user, ...assistants] const target = fixture.sessions.find((session) => session.id === fixture.targetID)! const lastID = userID -const lastPartID = assistants.at(-1)!.parts.at(-1)!.id +const lastAssistant = assistants.at(-1)! +const lastPart = lastAssistant.parts.at(-1)! +const lastPartID = + lastPart.type === "tool" + ? lastPart.id + : `${lastAssistant.info.id}:${lastPart.type}:${lastAssistant.parts.filter((part) => part.type === lastPart.type).length - 1}` benchmark("hydrates an orphaned latest turn after a cold session click", async ({ browser, report }, testInfo) => { benchmark.setTimeout(180_000) @@ -107,9 +112,25 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { return { items: items.slice(start, end), cursor: start > 0 ? items[start]!.info.id : undefined } }, }) - await page.route(`**/session/${fixture.targetID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: JSON.stringify(target) }), - ) + await page.route(`**/session/${fixture.targetID}`, (route) => { + const current = new URL(route.request().url()).pathname.startsWith("/api/") + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify( + current + ? { + data: { + ...target, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: target.directory }, + }, + } + : target, + ), + }) + }) await installStressSessionTabs(page, { sessionIDs: [fixture.sourceID] }) await page.goto(stressSessionHref(fixture.sourceID)) await expectSessionTitle(page, fixture.expected.sourceTitle) @@ -144,8 +165,8 @@ async function trial(page: Page, mode: ParentHydrationBenchmarkMode) { parent: requests.filter((request) => request.type === "parent").length, } if (mode === "candidate") { - expect(requestCounts.parent).toBe(1) - expect(historyGates).toBe(1) + expect(requestCounts.parent).toBe(0) + expect(historyGates).toBe(0) } return { metrics, requestCounts, historyGateCount: historyGates } } diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 2959e9f8a9..ddc6a4fade 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -283,6 +283,14 @@ export function MessageTimeline(props: { return sync().data.session_status[id] ?? idle }) const sessionMessages = createMemo(() => (sessionID() ? (sync().data.message[sessionID()!] ?? []) : [])) + const projectedMessages = createMemo(() => { + const id = sessionID() + if (!id) return [] + const visible = new Set(props.userMessages.map((message) => message.id)) + const boundary = sessionMessages().find((message) => message.role === "user" && !visible.has(message.id))?.id + const messages = sync().data.session_message[id] ?? [] + return boundary ? messages.filter((message) => message.id < boundary) : messages + }) const info = createMemo(() => { const id = sessionID() if (!id) return @@ -324,7 +332,7 @@ export function MessageTimeline(props: { const showHeader = createMemo(() => !!(titleValue() || parentID())) const projection = createTimelineProjection({ messages: sessionMessages, - userMessages: () => props.userMessages, + sessionMessages: projectedMessages, parts: getMsgParts, status: sessionStatus, showReasoningSummaries: settings.general.showReasoningSummaries, @@ -664,8 +672,7 @@ export function MessageTimeline(props: { })) const titleMutation = useMutation(() => ({ - mutationFn: (input: { id: string; title: string }) => - sdk().client.session.update({ sessionID: input.id, title: input.title }), + mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { @@ -809,7 +816,7 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk() - .client.session.update({ sessionID, time: { archived: Date.now() } }) + .api.session.archive({ sessionID }) .then(() => { sync().set( produce((draft) => { @@ -838,8 +845,8 @@ export function MessageTimeline(props: { const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) const result = await sdk() - .client.session.delete({ sessionID }) - .then((x) => x.data) + .api.session.remove({ sessionID }) + .then(() => true) .catch((err) => { showToast({ title: language.t("session.delete.failed.title"), diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index ea8ea4f132..b430dba4da 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -1,16 +1,14 @@ -import { Binary } from "@opencode-ai/core/util/binary" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import type { AssistantMessage, Message, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" -import { createMemo, mapArray, type Accessor } from "solid-js" +import { createMemo, type Accessor } from "solid-js" import { reuseTimelineRows } from "./row-reconciliation" import { Timeline, TimelineRow } from "./rows" export { reuseTimelineRows } from "./row-reconciliation" -const emptyAssistantMessages: AssistantMessage[] = [] - export function createTimelineProjection(input: { messages: Accessor - userMessages: Accessor + sessionMessages: Accessor parts: (messageID: string) => Part[] status: Accessor showReasoningSummaries: Accessor @@ -30,47 +28,19 @@ export function createTimelineProjection(input: { }) return result }) - const activeMessageID = createMemo(() => { - const parentID = input - .messages() - .findLast( - (message): message is AssistantMessage => - message.role === "assistant" && typeof message.time.completed !== "number", - )?.parentID - if (parentID) { - const messages = input.messages() - const result = Binary.search(messages, parentID, (message) => message.id) - const message = result.found ? messages[result.index] : messages.find((item) => item.id === parentID) - if (message?.role === "user") return message.id - } - - if (input.status().type === "idle") return - return input.messages().findLast((message) => message.role === "user")?.id - }) - const messageRowMemos = createMemo( - mapArray(input.userMessages, (userMessage, indexAccessor) => - createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - Timeline.constructMessageRows( - userMessage, - input.parts, - assistantMessagesByParent().get(userMessage.id) ?? emptyAssistantMessages, - indexAccessor(), - input.showReasoningSummaries(), - input.status().type, - activeMessageID() === userMessage.id, - input.inlineComments(), - ), - ), - ), + const projection = createMemo(() => + Timeline.constructSessionMessageRows( + input.sessionMessages(), + (messageID) => messageByID().get(messageID) as UserMessage | AssistantMessage | undefined, + input.parts, + input.showReasoningSummaries(), + input.status().type, + input.inlineComments(), ), ) + const activeMessageID = createMemo(() => projection().activeMessageID) const rows = createMemo((previous: TimelineRow.TimelineRow[] | undefined) => - reuseTimelineRows( - previous, - messageRowMemos().flatMap((memo) => memo()), - ), + reuseTimelineRows(previous, projection().rows), ) const rowByKey = createMemo(() => new Map(rows().map((row) => [TimelineRow.key(row), row] as const))) const messageRowIndex = createMemo(() => { diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts new file mode 100644 index 0000000000..f5c74f5acb --- /dev/null +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, mock, test } from "bun:test" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" +import { normalizeSessionMessages } from "@/utils/session-message" + +mock.module("@opencode-ai/session-ui/message-part", () => ({ + renderable: () => true, + groupParts: (refs: Array<{ messageID: string; part: { id: string } }>) => + refs.map((ref) => ({ + type: "part" as const, + key: ref.part.id, + ref: { messageID: ref.messageID, partID: ref.part.id }, + })), +})) + +const { Timeline, TimelineRow } = await import("./rows") + +describe("current session timeline rows", () => { + test("derives turns and tagged rows from chronological current messages", () => { + const source = [ + { id: "msg_1", type: "user", text: "first", time: { created: 1 } }, + { + id: "msg_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "answer" }], + time: { created: 2, completed: 3 }, + }, + { id: "msg_3", type: "user", text: "second", time: { created: 4 } }, + { + id: "msg_4", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "reasoning", text: "working" }], + time: { created: 5 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "busy", + true, + ) + + expect(result.activeMessageID).toBe("msg_3") + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_1", + "assistant-part:msg_1:msg_2:text:0", + "turn-gap:msg_3", + "user-message:msg_3", + "assistant-part:msg_3:msg_4:reasoning:0", + ]) + }) + + test("renders a current shell message as a standalone turn", () => { + const source = [ + { + id: "msg_shell", + type: "shell", + shellID: "shell_1", + command: "pwd", + status: "exited", + exit: 0, + output: { output: "/repo", cursor: 5, size: 5, truncated: false }, + time: { created: 1, completed: 2 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + ) + + expect(result.activeMessageID).toBe("msg_shell") + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_shell", + "assistant-part:msg_shell:msg_shell:tool", + ]) + }) + + test("associates assistants with a projected parent missing from the source page", () => { + const source = [ + { id: "msg_user", type: "user", text: "question", time: { created: 1 } }, + { + id: "msg_assistant", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "answer" }], + time: { created: 2, completed: 3 }, + }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const messages = new Map(normalized.messages.map((message) => [message.id, message])) + + const result = Timeline.constructSessionMessageRows( + [source[1]!], + (messageID) => messages.get(messageID), + (messageID) => normalized.parts.get(messageID) ?? [], + true, + "idle", + true, + ) + + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_user", + "assistant-part:msg_user:msg_assistant:text:0", + ]) + }) +}) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index a9c9a2d288..2f05910d9e 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -1,4 +1,5 @@ import { parseCommentNote, readCommentMetadata } from "@/utils/comment-note" +import type { SessionMessageInfo } from "@opencode-ai/client/promise" import { AssistantMessage, Part, SessionStatus, UserMessage } from "@opencode-ai/sdk/v2" import { groupParts, renderable, type PartGroup } from "@opencode-ai/session-ui/message-part" import { TimelineRow, type SummaryDiff } from "./timeline-row" @@ -31,6 +32,55 @@ export type TimelineRowMap = { } export namespace Timeline { + export function constructSessionMessageRows( + messages: SessionMessageInfo[], + getMessage: (messageID: string) => UserMessage | AssistantMessage | undefined, + getMessageParts: (messageID: string) => Part[], + showReasoning: boolean, + status: SessionStatus["type"], + inlineComments: boolean, + ) { + const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => { + const projected = getMessage(message.id) + if (message.type === "shell" && projected?.role === "user") { + const assistant = getMessage(`${message.id}:assistant`) + return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }] + } + return projected?.role === "user" ? [{ user: projected, assistants: [] }] : [] + }) + const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn])) + messages.forEach((message) => { + const projected = getMessage(message.id) + if (projected?.role !== "assistant") return + const existing = turnByUserID.get(projected.parentID) + if (existing) { + existing.assistants.push(projected) + return + } + const user = getMessage(projected.parentID) + if (user?.role !== "user") return + const turn = { user, assistants: [projected] } + turns.push(turn) + turnByUserID.set(user.id, turn) + }) + const activeMessageID = turns.at(-1)?.user.id + return { + activeMessageID, + rows: turns.flatMap((turn, index) => + constructMessageRows( + turn.user, + getMessageParts, + turn.assistants, + index, + showReasoning, + status, + turn.user.id === activeMessageID, + inlineComments, + ), + ), + } + } + export function constructMessageRows( userMessage: UserMessage, getMessageParts: (messageID: string) => Part[], diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index ce2f7b25c3..55275f3ab9 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -521,6 +521,7 @@ export function getToolInfo( } } case "bash": + case "shell": return { icon: "console", title: i18n.t("ui.tool.shell"), @@ -538,6 +539,7 @@ export function getToolInfo( title: i18n.t("ui.messagePart.title.write"), subtitle: input.filePath ? getFilename(input.filePath) : undefined, } + case "patch": case "apply_patch": return { icon: "code-lines", @@ -729,8 +731,8 @@ export function renderable(part: PartType, showReasoningSummaries = true) { } function toolDefaultOpen(tool: string, shell = false, edit = false) { - if (tool === "bash") return shell - if (tool === "edit" || tool === "write" || tool === "apply_patch") return edit + if (tool === "bash" || tool === "shell") return shell + if (tool === "edit" || tool === "write" || tool === "patch" || tool === "apply_patch") return edit } export function partDefaultOpen(part: PartType, shell = false, edit = false) { @@ -1506,7 +1508,7 @@ export function registerTool(input: { name: string; render?: ToolComponent }) { } export function getTool(name: string) { - return state[name]?.render + return state[name === "apply_patch" ? "patch" : name === "bash" ? "shell" : name]?.render } export const ToolRegistry = { @@ -2101,7 +2103,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "bash", + name: "shell", render(props) { const i18n = useI18n() const pending = () => props.status === "pending" || props.status === "running" @@ -2337,7 +2339,7 @@ ToolRegistry.register({ }) ToolRegistry.register({ - name: "apply_patch", + name: "patch", render(props) { const i18n = useI18n() const fileComponent = useFileComponent() diff --git a/packages/session-ui/src/components/tool-error-card.tsx b/packages/session-ui/src/components/tool-error-card.tsx index 35720a2753..4313d48aef 100644 --- a/packages/session-ui/src/components/tool-error-card.tsx +++ b/packages/session-ui/src/components/tool-error-card.tsx @@ -51,6 +51,8 @@ export function ToolErrorCard(props: ToolErrorCardProps) { webfetch: "ui.tool.webfetch", websearch: "ui.tool.websearch", bash: "ui.tool.shell", + shell: "ui.tool.shell", + patch: "ui.tool.patch", apply_patch: "ui.tool.patch", question: "ui.tool.questions", } From 090a26a301b00e2bfde513f4c155aeb36430e376 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 03:58:09 +0000 Subject: [PATCH 22/48] chore: generate --- packages/app/src/pages/session/timeline/message-timeline.tsx | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index ddc6a4fade..497ebceb87 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -672,7 +672,8 @@ export function MessageTimeline(props: { })) const titleMutation = useMutation(() => ({ - mutationFn: (input: { id: string; title: string }) => sdk().api.session.rename({ sessionID: input.id, title: input.title }), + mutationFn: (input: { id: string; title: string }) => + sdk().api.session.rename({ sessionID: input.id, title: input.title }), onSuccess: (_, input) => { sync().set( produce((draft) => { From 29af2e39ff7e35e24ea6ece72dbdafabbaaaf15d Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 12:58:20 +0800 Subject: [PATCH 23/48] feat(app): migrate session interactions (#38461) --- .../remote-session-settings.spec.ts | 6 +- .../regression/session-request-docks.spec.ts | 7 +- .../subagent-child-navigation.spec.ts | 11 +- packages/app/e2e/utils/mock-server.ts | 6 + packages/app/src/components/dialog-fork.tsx | 10 +- .../components/prompt-input/submit.test.ts | 164 +++++++++++++----- packages/app/src/context/permission.tsx | 38 ++-- .../src/pages/home-session-archive.test.ts | 4 +- .../app/src/pages/home-session-archive.ts | 14 +- packages/app/src/pages/home.tsx | 2 +- .../composer/session-composer-controls.ts | 3 +- .../composer/session-composer-state.ts | 2 +- .../composer/session-question-dock.tsx | 5 +- .../pages/session/use-session-commands.tsx | 21 ++- 14 files changed, 189 insertions(+), 104 deletions(-) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index c17ae5c1c6..40491c867e 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, @@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, }, { origin: serverA, - directory: directoryA, + directory: undefined, sessionID: childSessionA.id, permissionID: "permission-background-a-child", body: { response: "once" }, diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 714d6ca96f..cd829ad95c 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === "/question/question-request/reject") + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === "/question/question-request/reply", ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) diff --git a/packages/app/e2e/regression/subagent-child-navigation.spec.ts b/packages/app/e2e/regression/subagent-child-navigation.spec.ts index 19d2c29af0..019cc156ec 100644 --- a/packages/app/e2e/regression/subagent-child-navigation.spec.ts +++ b/packages/app/e2e/regression/subagent-child-navigation.spec.ts @@ -1,6 +1,6 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page } from "@playwright/test" -import { mockOpenCodeServer } from "../utils/mock-server" +import { currentSession, mockOpenCodeServer } from "../utils/mock-server" import { expectSessionTitle } from "../utils/waits" const directory = "C:/OpenCode/SubagentNavigation" @@ -72,16 +72,19 @@ async function setup(page: Page, events?: () => EventPayload[]) { events, eventRetry: events ? 16 : undefined, }) - // The child session resolves via /session/:id but is absent from the /session list, + // The child session resolves by ID but is absent from the session list, // matching a subagent session that has not been loaded into the list cache yet. await page.route( - (url) => url.pathname === "/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), + (url) => url.pathname === "/api/session" && url.port === (process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"), (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify([session(parentID, parentTitle, 1700000000000)]), + body: JSON.stringify({ + data: [currentSession(session(parentID, parentTitle, 1700000000000))], + cursor: {}, + }), }), ) await configurePage(page) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 78f60bbbca..84a38771e6 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -205,6 +205,12 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (/^\/api\/session\/[^/]+\/permission\/[^/]+\/reply$/.test(path) && route.request().method() === "POST") { return route.fulfill({ status: 204, headers: { "access-control-allow-origin": "*" } }) } + if (/^\/question\/[^/]+\/(reply|reject)$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } + if (/^\/session\/[^/]+\/permissions\/[^/]+$/.test(path) && route.request().method() === "POST") { + return json(route, true) + } if ( /^\/api\/session\/[^/]+\/(archive|rename|interrupt|revert\/clear|revert\/commit)$/.test(path) && route.request().method() === "POST" diff --git a/packages/app/src/components/dialog-fork.tsx b/packages/app/src/components/dialog-fork.tsx index 601f03084c..5187d980ea 100644 --- a/packages/app/src/components/dialog-fork.tsx +++ b/packages/app/src/components/dialog-fork.tsx @@ -69,15 +69,11 @@ export const DialogFork: Component = () => { const dir = base64Encode(sdk().directory) sdk() - .client.session.fork({ sessionID, messageID: item.id }) + .api.session.fork({ sessionID, messageID: item.id }) .then((forked) => { - if (!forked.data) { - showToast({ title: language.t("common.requestFailed") }) - return - } dialog.close() - prompt.set(restored, undefined, { dir, id: forked.data.id }) - navigate(`/${dir}/session/${forked.data.id}`) + prompt.set(restored, undefined, { dir, id: forked.id }) + navigate(`/${dir}/session/${forked.id}`) }) .catch((err: unknown) => { const message = err instanceof Error ? err.message : String(err) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index 834fc4795a..ac06916464 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -7,6 +7,11 @@ let createPromptSubmit: typeof import("./submit").createPromptSubmit const createdClients: string[] = [] const createdSessions: string[] = [] +const sessionCreateInputs: Array<{ + agent?: string + model?: { id: string; providerID: string; variant?: string } + location?: { directory: string } +}> = [] const enabledAutoAccept: Array<{ server: string; sessionID: string; directory: string }> = [] const optimistic: Array<{ directory?: string @@ -19,11 +24,15 @@ const optimistic: Array<{ }> = [] const optimisticSeeded: boolean[] = [] const storedSessions: Record> = {} -const sessionDirectories: Record = {} const promoted: Array<{ directory: string; sessionID: string }> = [] -const sentShell: string[] = [] +const sentShell: Array<{ sessionID: string; id?: string; command: string }> = [] const syncedDirectories: string[] = [] const promotedDrafts: Array<{ draftID: string; server: string; sessionId: string }> = [] +const sentPrompts: string[] = [] +const promptInputs: unknown[] = [] +const sentCommands: unknown[] = [] +const commands: Array<{ name: string }> = [] +let serverSessionSyncs = 0 let params: { id?: string } = {} let search: { draftId?: string } = {} @@ -32,7 +41,7 @@ let variant: string | undefined let permissionServer = "server-a" let createSessionGate: Promise | undefined -const promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] +let promptValue: Prompt = [{ type: "text", content: "ls", start: 0, end: 2 }] const [promptStore, setPromptStore] = createStore({ prompt: promptValue, cursor: 0, @@ -64,23 +73,39 @@ const prompt = { const clientFor = (directory: string) => { createdClients.push(directory) return { - session: { - create: async () => { - await createSessionGate - createdSessions.push(directory) - return { - data: { + api: { + session: { + create: async (input: (typeof sessionCreateInputs)[number]) => { + await createSessionGate + const location = input.location?.directory ?? directory + createdSessions.push(location) + sessionCreateInputs.push(input) + return { id: `session-${createdSessions.length}`, + projectID: "project", + agent: input.agent, + model: input.model, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 1, updated: 1 }, title: `New session ${createdSessions.length}`, - }, - } + location: { directory: location }, + } + }, + prompt: async (input: unknown) => { + sentPrompts.push(directory) + promptInputs.push(input) + return { data: undefined } + }, + command: async (input: unknown) => { + sentCommands.push(input) + }, + shell: async (input: { sessionID: string; id?: string; command: string }) => { + sentShell.push(input) + }, }, - shell: async () => { - sentShell.push(directory) - return { data: undefined } - }, - prompt: async () => ({ data: undefined }), - promptAsync: async () => ({ data: undefined }), + }, + session: { command: async () => ({ data: undefined }), abort: async () => ({ data: undefined }), }, @@ -90,27 +115,6 @@ const clientFor = (directory: string) => { } } -const api = { - session: { - async create(input: { location: { directory: string } }) { - await createSessionGate - createdSessions.push(input.location.directory) - const session = { - id: `session-${createdSessions.length}`, - title: `New session ${createdSessions.length}`, - } - sessionDirectories[session.id] = input.location.directory - return session - }, - async shell(input: { sessionID: string }) { - sentShell.push(sessionDirectories[input.sessionID] ?? "/repo/main") - }, - async prompt() {}, - async command() {}, - async interrupt() {}, - }, -} - beforeAll(async () => { const rootClient = clientFor("/repo/main") @@ -193,8 +197,8 @@ beforeAll(async () => { const sdk = { scope: "local", directory: "/repo/main", - api, client: rootClient, + api: rootClient.api, url: "http://localhost:4096", createClient(opts: any) { return clientFor(opts.directory) @@ -206,7 +210,7 @@ beforeAll(async () => { mock.module("@/context/sync", () => ({ useSync: () => () => ({ - data: { command: [] }, + data: { command: commands }, session: { optimistic: { add: (value: { @@ -233,6 +237,9 @@ beforeAll(async () => { session: { remember: () => undefined, set: () => undefined, + sync: async () => { + serverSessionSyncs++ + }, }, child: (directory: string) => { syncedDirectories.push(directory) @@ -274,11 +281,17 @@ beforeAll(async () => { beforeEach(() => { createdClients.length = 0 createdSessions.length = 0 + sessionCreateInputs.length = 0 enabledAutoAccept.length = 0 optimistic.length = 0 optimisticSeeded.length = 0 promoted.length = 0 promotedDrafts.length = 0 + sentPrompts.length = 0 + promptInputs.length = 0 + sentCommands.length = 0 + commands.length = 0 + promptValue = [{ type: "text", content: "ls", start: 0, end: 2 }] params = {} search = {} sentShell.length = 0 @@ -287,8 +300,8 @@ beforeEach(() => { variant = undefined permissionServer = "server-a" createSessionGate = undefined + serverSessionSyncs = 0 for (const key of Object.keys(storedSessions)) delete storedSessions[key] - for (const key of Object.keys(sessionDirectories)) delete sessionDirectories[key] }) describe("prompt submit worktree selection", () => { @@ -321,8 +334,24 @@ describe("prompt submit worktree selection", () => { expect(createdClients).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) expect(createdSessions).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) - expect(sentShell).toEqual(["/repo/worktree-a", "/repo/worktree-b"]) + expect(sessionCreateInputs).toEqual([ + { + agent: "agent", + model: { id: "model", providerID: "provider", variant: undefined }, + location: { directory: "/repo/worktree-a" }, + }, + { + agent: "agent", + model: { id: "model", providerID: "provider", variant: undefined }, + location: { directory: "/repo/worktree-b" }, + }, + ]) + expect(sentShell).toEqual([ + expect.objectContaining({ sessionID: "session-1", id: expect.stringMatching(/^evt_/), command: "ls" }), + expect.objectContaining({ sessionID: "session-2", id: expect.stringMatching(/^evt_/), command: "ls" }), + ]) expect(syncedDirectories).toEqual(["/repo/worktree-a", "/repo/worktree-a", "/repo/worktree-b", "/repo/worktree-b"]) + expect(serverSessionSyncs).toBe(0) expect(promoted).toEqual([ { directory: "/repo/worktree-a", sessionID: "session-1" }, { directory: "/repo/worktree-b", sessionID: "session-2" }, @@ -443,6 +472,7 @@ describe("prompt submit worktree selection", () => { const event = { preventDefault: () => undefined } as unknown as Event await submit.handleSubmit(event) + await Bun.sleep(0) expect(optimistic).toHaveLength(1) expect(optimistic[0]).toMatchObject({ @@ -451,6 +481,53 @@ describe("prompt submit worktree selection", () => { model: { providerID: "provider", modelID: "model", variant: "high" }, }, }) + expect(sentPrompts).toEqual(["/repo/main"]) + expect(promptInputs[0]).toMatchObject({ + sessionID: "session-1", + text: "ls", + files: [], + agents: [], + }) + expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") + }) + + test("submits slash commands through the current session API", async () => { + params = { id: "session-1" } + variant = "high" + commands.push({ name: "review" }) + promptValue = [{ type: "text", content: "/review staged changes", start: 0, end: 22 }] + + const submit = createPromptSubmit({ + prompt, + info: () => ({ id: "session-1" }), + imageAttachments: () => [], + commentCount: () => 0, + autoAccept: () => false, + mode: () => "normal", + working: () => false, + editor: () => undefined, + queueScroll: () => undefined, + promptLength: (value) => value.reduce((sum, part) => sum + ("content" in part ? part.content.length : 0), 0), + addToHistory: () => undefined, + resetHistoryNavigation: () => undefined, + setMode: () => undefined, + setPopover: () => undefined, + }) + + await submit.handleSubmit({ preventDefault: () => undefined } as unknown as Event) + + expect(sentCommands).toEqual([ + { + sessionID: "session-1", + id: expect.stringMatching(/^msg_/), + command: "review", + arguments: "staged changes", + agent: "agent", + model: { id: "model", providerID: "provider", variant: "high" }, + files: [], + }, + ]) + expect(serverSessionSyncs).toBe(0) }) test("uses an injected model selection", async () => { @@ -511,7 +588,8 @@ describe("prompt submit worktree selection", () => { await submit.handleSubmit(event) - expect(storedSessions["/repo/worktree-a"]).toEqual([{ id: "session-1", title: "New session 1" }]) + expect(storedSessions["/repo/worktree-a"]).toHaveLength(1) + expect(storedSessions["/repo/worktree-a"]?.[0]).toMatchObject({ id: "session-1", title: "New session 1" }) expect(optimisticSeeded).toEqual([true]) }) }) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 496d1ab4a2..388e4534a1 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -13,6 +13,7 @@ import { type DraftTab, useTabs } from "./tabs" import { useSettings } from "./settings" import { requireServerKey } from "@/utils/session-route" import type { ServerScope } from "@/utils/server-scope" +import { normalizePermissionRequest } from "./global-sync/utils" import { acceptKey, directoryAcceptKey, @@ -243,9 +244,20 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } const respond: PermissionRespondFn = (request) => { if (meta.disposed) return - input.sdk.client.permission.respond(request).catch(() => { - responded.delete(request.permissionID) - }) + input.sdk.api.permission + .reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response }) + .catch(() => { + responded.delete(request.permissionID) + }) + } + + const list = async (directory: string) => { + if ((await input.sdk.protocol) === "v1") { + return (await input.sdk.client.permission.list({ directory })).data ?? [] + } + return input.sdk.api.permission.request + .list({ location: { directory } }) + .then((result) => result.data.map(normalizePermissionRequest)) } function respondOnce(permission: PermissionRequest, directory?: string) { @@ -343,14 +355,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (!isAutoAcceptingDirectory(directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue - void respondPending(perm, directory, () => isAutoAcceptingDirectory(directory)) + for (const permission of permissions) { + void respondPending(permission, directory, () => isAutoAcceptingDirectory(directory)) } }) .catch(() => undefined) @@ -377,16 +387,14 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } }), ) - input.sdk.client.permission - .list({ directory }) - .then((x) => { + list(directory) + .then((permissions) => { if (meta.disposed) return if (enableVersion.get(key) !== version) return if (!isAutoAccepting(sessionID, directory)) return - for (const perm of x.data ?? []) { - if (!perm?.id) continue + for (const permission of permissions) { void respondPending( - perm, + permission, directory, () => enableVersion.get(key) === version && isAutoAccepting(sessionID, directory), ) diff --git a/packages/app/src/pages/home-session-archive.test.ts b/packages/app/src/pages/home-session-archive.test.ts index 0ad30afcd2..2d04e808f4 100644 --- a/packages/app/src/pages/home-session-archive.test.ts +++ b/packages/app/src/pages/home-session-archive.test.ts @@ -19,7 +19,7 @@ test("archiving a Home session removes its open titlebar tab", async () => { await archiveHomeSession({ server: remote, session: { id: "ses_1", directory: "/workspace" }, - update: async () => undefined, + archive: async () => undefined, remove: () => { removed = true }, @@ -37,7 +37,7 @@ test("reports archive failures without removing the session", async () => { await archiveHomeSession({ server: remote, session: { id: "ses_1", directory: "/workspace" }, - update: async () => Promise.reject(failure), + archive: async () => Promise.reject(failure), remove: () => { removed = true }, diff --git a/packages/app/src/pages/home-session-archive.ts b/packages/app/src/pages/home-session-archive.ts index 7e6634ed7a..bafca66e72 100644 --- a/packages/app/src/pages/home-session-archive.ts +++ b/packages/app/src/pages/home-session-archive.ts @@ -6,25 +6,15 @@ type HomeSession = { directory: string } -type SessionUpdate = { - directory: string - sessionID: string - time: { archived: number } -} - export async function archiveHomeSession(input: { server: ServerConnection.Key session: HomeSession - update: (value: SessionUpdate) => Promise + archive: (sessionID: string) => Promise remove: () => void onError?: (error: unknown) => void }) { await input - .update({ - directory: input.session.directory, - sessionID: input.session.id, - time: { archived: Date.now() }, - }) + .archive(input.session.id) .then(() => { input.remove() notifySessionTabsRemoved({ diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 03924ec973..da7cad7e19 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -606,7 +606,7 @@ export function NewHome() { await archiveHomeSession({ server: ServerConnection.key(conn), session, - update: (value) => ctx.sdk.client.session.update(value), + archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }), remove: () => setStore( produce((draft) => { diff --git a/packages/app/src/pages/session/composer/session-composer-controls.ts b/packages/app/src/pages/session/composer/session-composer-controls.ts index f52b7f4b42..4ae7827e21 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -45,7 +45,8 @@ export function createPromptInputController(input: { model: { selection: input.model ?? local.model, paid: providers.paid().length > 0, - loading: agentsQuery.isLoading || providersQuery.isLoading || globalProvidersQuery.isLoading, + loading: + (local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading, }, session: { id: input.sessionID(), diff --git a/packages/app/src/pages/session/composer/session-composer-state.ts b/packages/app/src/pages/session/composer/session-composer-state.ts index 45f5e4cb26..f54e0c9e4f 100644 --- a/packages/app/src/pages/session/composer/session-composer-state.ts +++ b/packages/app/src/pages/session/composer/session-composer-state.ts @@ -82,7 +82,7 @@ export function createSessionComposerController(options?: { closeMs?: number | ( setStore("responding", perm.id) sdk() - .client.permission.respond({ sessionID: perm.sessionID, permissionID: perm.id, response }) + .api.permission.reply({ sessionID: perm.sessionID, requestID: perm.id, reply: response }) .catch((err: unknown) => { const description = err instanceof Error ? err.message : String(err) showToast({ title: language.t("common.requestFailed"), description }) diff --git a/packages/app/src/pages/session/composer/session-question-dock.tsx b/packages/app/src/pages/session/composer/session-question-dock.tsx index 445a9f47a0..941424e247 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -223,7 +223,8 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit } const replyMutation = useMutation(() => ({ - mutationFn: (answers: QuestionAnswer[]) => sdk().client.question.reply({ requestID: props.request.id, answers }), + mutationFn: (answers: QuestionAnswer[]) => + sdk().api.question.reply({ sessionID: props.request.sessionID, requestID: props.request.id, answers }), onMutate: () => { props.onSubmit() }, @@ -235,7 +236,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit })) const rejectMutation = useMutation(() => ({ - mutationFn: () => sdk().client.question.reject({ requestID: props.request.id }), + mutationFn: () => sdk().api.question.reject({ sessionID: props.request.sessionID, requestID: props.request.id }), onMutate: () => { props.onSubmit() }, diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 275e6ec4bc..12dd96a5e6 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -5,7 +5,6 @@ import { previewSelectedLines } from "@opencode-ai/session-ui/pierre/selection-b import { useFile, selectionFromLines, type FileSelection, type SelectedLineRange } from "@/context/file" import { useLanguage } from "@/context/language" import { useLayout } from "@/context/layout" -import { useLocal } from "@/context/local" import { usePermission } from "@/context/permission" import { usePrompt } from "@/context/prompt" import { useSDK } from "@/context/sdk" @@ -19,6 +18,7 @@ import { extractPromptFromParts } from "@/utils/prompt" import { UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" +import { useLocal } from "@/context/local" export type SessionCommandContext = { navigateMessageByOffset: (offset: number) => void @@ -40,7 +40,6 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const dialog = useDialog() const file = useFile() const language = useLanguage() - const local = useLocal() const permission = usePermission() const prompt = usePrompt() const sdk = useSDK() @@ -48,6 +47,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sync = useSync() const terminal = useTerminal() const layout = useLayout() + const local = useLocal() const navigate = useNavigate() const { params, sessionKey, tabs, view } = useSessionLayout() const sessionOwnership = createSessionOwnership(sessionKey) @@ -306,7 +306,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const directory = sdk().directory const promptSession = prompt.capture() const revert = info()?.revert?.messageID @@ -316,13 +316,13 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const parts = sync().data.part[message.id] if (sync().data.session_working(sessionID)) { - await client.session.abort({ sessionID }).catch(() => {}) + await session.interrupt({ sessionID }).catch(() => {}) } await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: message.id }), + request: () => session.revert.stage({ sessionID, messageID: message.id }), updatePrompt: (promptSession) => { if (parts) promptSession.set(extractPromptFromParts(parts, { directory })) }, @@ -334,7 +334,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { const sessionID = params.id if (!sessionID) return const owner = sessionOwnership.capture() - const client = sdk().client + const session = sdk().api.session const messages = userMessages() const promptSession = prompt.capture() @@ -346,7 +346,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.unrevert({ sessionID }), + request: () => session.revert.clear({ sessionID }), updatePrompt: (promptSession) => promptSession.reset(), updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id >= revertMessageID)), }) @@ -356,7 +356,7 @@ export const useSessionCommands = (actions: SessionCommandContext) => { await runCommand({ owner, prompt: promptSession, - request: () => client.session.revert({ sessionID, messageID: next.id }), + request: () => session.revert.stage({ sessionID, messageID: next.id }), updatePrompt: () => undefined, updateViewport: () => setActiveMessage(findLast(messages, (x) => x.id < next.id)), }) @@ -375,10 +375,9 @@ export const useSessionCommands = (actions: SessionCommandContext) => { return } - await sdk().client.session.summarize({ + await sdk().api.session.compact({ sessionID, - modelID: model.id, - providerID: model.provider.id, + model: { providerID: model.provider.id, modelID: model.id }, }) } From 386afb77e0e1d9d61e1d4cea906f0108776c7c15 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 04:59:34 +0000 Subject: [PATCH 24/48] chore: generate --- packages/app/e2e/regression/session-request-docks.spec.ts | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index cd829ad95c..714d6ca96f 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,8 +42,7 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === "/question/question-request/reject") - rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -65,9 +64,7 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => - request.method() === "POST" && - new URL(request.url()).pathname === "/question/question-request/reply", + (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) From 589ef16128b0d787e389ee9b2544b53089ef5b0a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:05:40 +0800 Subject: [PATCH 25/48] refactor(app): split home view controllers (#38607) --- packages/app/src/app.tsx | 3 +- .../src/components/server/server-row-menu.tsx | 71 +- packages/app/src/pages/home.tsx | 1954 +---------------- .../app/src/pages/home/home-controller.ts | 108 + .../pages/home/home-projects-controller.tsx | 128 ++ .../app/src/pages/home/home-projects-view.tsx | 608 +++++ packages/app/src/pages/home/home-projects.tsx | 40 + .../src/pages/home/home-scroll-controller.ts | 145 ++ .../home/home-session-search-controller.ts | 114 + .../pages/home/home-sessions-controller.tsx | 314 +++ .../app/src/pages/home/home-sessions-view.tsx | 550 +++++ packages/app/src/pages/home/home-sessions.tsx | 48 + packages/app/src/pages/home/legacy-home.tsx | 142 ++ .../src/pages/layout/session-tab-avatar.tsx | 22 +- 14 files changed, 2312 insertions(+), 1935 deletions(-) create mode 100644 packages/app/src/pages/home/home-controller.ts create mode 100644 packages/app/src/pages/home/home-projects-controller.tsx create mode 100644 packages/app/src/pages/home/home-projects-view.tsx create mode 100644 packages/app/src/pages/home/home-projects.tsx create mode 100644 packages/app/src/pages/home/home-scroll-controller.ts create mode 100644 packages/app/src/pages/home/home-session-search-controller.ts create mode 100644 packages/app/src/pages/home/home-sessions-controller.tsx create mode 100644 packages/app/src/pages/home/home-sessions-view.tsx create mode 100644 packages/app/src/pages/home/home-sessions.tsx create mode 100644 packages/app/src/pages/home/legacy-home.tsx diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index b4496ba7de..25d2e3749a 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -67,7 +67,8 @@ import { legacySessionHref, legacySessionServer, requireServerKey, sessionHref } import { createSessionLineage } from "@/pages/session/session-lineage" import { SessionPage, SessionRouteErrorBoundary, TargetSessionRouteContent } from "@/pages/session" -import { NewHome, LegacyHome } from "@/pages/home" +import { NewHome } from "@/pages/home" +import { LegacyHome } from "@/pages/home/legacy-home" const NewSession = lazy(() => import("@/pages/new-session")) diff --git a/packages/app/src/components/server/server-row-menu.tsx b/packages/app/src/components/server/server-row-menu.tsx index 9d5f5e5a32..0a2920dec7 100644 --- a/packages/app/src/components/server/server-row-menu.tsx +++ b/packages/app/src/components/server/server-row-menu.tsx @@ -15,9 +15,47 @@ export const ServerRowMenu: Component<{ }> = (props) => { const language = useLanguage() const key = ServerConnection.key(props.server) - const builtin = ServerConnection.builtin(props.server) - const isDefault = () => props.controller.defaultKey() === key + return ( + props.controller.setDefault(key)} + onRemoveDefault={() => props.controller.setDefault(null)} + onRemove={() => props.controller.handleRemove(key)} + open={props.open} + onOpenChange={props.onOpenChange} + /> + ) +} +export function serverMenuLabels(language: ReturnType) { + return { + more: language.t("common.moreOptions"), + server: language.t("settings.section.server"), + edit: language.t("dialog.server.menu.edit"), + default: language.t("dialog.server.menu.default"), + defaultRemove: language.t("dialog.server.menu.defaultRemove"), + delete: language.t("dialog.server.menu.delete"), + } +} + +export const ServerRowMenuView: Component<{ + server: ServerConnection.Any + labels: ReturnType + canDefault: boolean + isDefault: boolean + onEdit: (server: ServerConnection.Http) => void + onSetDefault: () => void + onRemoveDefault: () => void + onRemove: () => void + open?: boolean + onOpenChange?: (open: boolean) => void +}> = (props) => { + const builtin = () => ServerConnection.builtin(props.server) + const httpServer = () => (props.server.type === "http" ? props.server : undefined) return ( } - aria-label={language.t("common.moreOptions")} + aria-label={props.labels.more} /> - {language.t("settings.section.server")} + {props.labels.server} props.onEdit(props.server as ServerConnection.Http)} + disabled={builtin() || !httpServer()} + onSelect={() => { + const server = httpServer() + if (server) props.onEdit(server) + }} > - {language.t("dialog.server.menu.edit")} + {props.labels.edit} - - props.controller.setDefault(key)}> - {language.t("dialog.server.menu.default")} - + + {props.labels.default} - - props.controller.setDefault(null)}> - {language.t("dialog.server.menu.defaultRemove")} - + + {props.labels.defaultRemove} - props.controller.handleRemove(key)}> - {language.t("dialog.server.menu.delete")} + + {props.labels.delete} diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index da7cad7e19..4fadb8a68f 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -1,1926 +1,50 @@ -import type { Session } from "@opencode-ai/sdk/v2/client" -import { - type ComponentProps, - createEffect, - createMemo, - createResource, - createRoot, - createSignal, - For, - Match, - on, - onCleanup, - onMount, - Show, - startTransition, - Switch, -} from "solid-js" -import { makeEventListener } from "@solid-primitives/event-listener" -import { createStore, produce } from "solid-js/store" -import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" -import { isSortable, useSortable } from "@dnd-kit/solid/sortable" -import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" -import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers" -import { RestrictToElement } from "@dnd-kit/dom/modifiers" -import { useQuery } from "@tanstack/solid-query" -import { Button } from "@opencode-ai/ui/button" -import { Logo } from "@opencode-ai/ui/logo" -import { Spinner } from "@opencode-ai/ui/spinner" import { ScrollView } from "@opencode-ai/ui/scroll-view" -import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" -import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" -import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { getProjectAvatarVariant, useLayout, type HomeProjectSelection, type LocalProject } from "@/context/layout" -import { useNavigate } from "@solidjs/router" -import { base64Encode } from "@opencode-ai/core/util/encode" -import { Icon } from "@opencode-ai/ui/icon" -import { usePlatform } from "@/context/platform" -import { DateTime } from "luxon" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { useDirectoryPicker } from "@/components/directory-picker" -import { useSettingsCommand } from "@/components/settings-dialog" -import { DialogSelectServer, useServerManagementController } from "@/components/dialog-select-server" -import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" -import { ServerConnection, serverName, useServer } from "@/context/server" -import { sessionHasOpenTab, useTabs } from "@/context/tabs" -import { useServerSync } from "@/context/server-sync" -import { useLanguage } from "@/context/language" -import { useNotification } from "@/context/notification" -import { - closeHomeProject, - displayName, - errorMessage, - getProjectAvatarSource, - homeProjectDirectories, - projectForSession, - toggleHomeProjectSelection, -} from "@/pages/layout/helpers" -import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" -import { sessionTitle } from "@/utils/session-title" -import { pathKey } from "@/utils/path-key" -import { useGlobal } from "@/context/global" -import { useCommand } from "@/context/command" -import { Binary } from "@opencode-ai/core/util/binary" -import { ServerRowMenu } from "@/components/server/server-row-menu" -import { ServerHealthIndicator } from "@/components/server/server-row" -import { type ServerHealth } from "@/utils/server-health" -import { Persist, persisted } from "@/utils/persist" -import { useMarked } from "@opencode-ai/ui/context/marked" -import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" -import { archiveHomeSession } from "./home-session-archive" -import { shouldOpenSessionInBackground } from "./home-session-open" -import { showToast } from "@/utils/toast" -import { fileManagerApp } from "@/utils/file-manager" -import { - loadHomeSessionIndex, - retainHomeSessions, - type HomeSessionEvents, -} from "@/context/global-sync/home-session-index" - -const HOME_SESSION_LIMIT = 64 -const HOME_SESSION_HEADER_STICKY_TOP = 12 -const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 -const HOME_SESSION_HEADER_FADE_DISTANCE = 16 - -function containHomeWheel(event: WheelEvent, viewport: HTMLElement) { - if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return - if (!(event.target instanceof Element)) return - - const scrollable = event.target.closest("[data-scrollable]") - if ( - scrollable !== viewport && - scrollable && - (event.deltaY < 0 - ? scrollable.scrollTop > 0 - : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight) - ) - return - - event.preventDefault() -} -const SHOW_HOME_SESSION_ARCHIVE = false -const HOME_ROW_LAYOUT = - "flex min-w-0 w-full shrink-0 cursor-default items-center rounded-[6px] bg-transparent text-left transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out focus-visible:outline-none" -const HOME_ROW_BASE = `${HOME_ROW_LAYOUT} border-0` -const HOME_ROW = `${HOME_ROW_BASE} [font-weight:530] text-v2-text-text-muted hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover` -const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" -const HOME_PROJECT_NAV_ROW = `${HOME_ROW_LAYOUT} h-7 gap-2 px-1.5 [font-weight:440] text-v2-text-text-muted hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base hover:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:bg-v2-background-bg-layer-03 data-[selected]:text-v2-text-text-base data-[selected]:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] data-[selected]:hover:bg-v2-background-bg-layer-03 focus-visible:bg-v2-background-bg-layer-01 focus-visible:text-v2-text-text-base focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]` -const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" - -type HomeSessionRecord = { - session: Session - project: LocalProject - projectName: string -} - -type HomeSessionGroup = { - id: "today" | "yesterday" | "older" - title: string - sessions: HomeSessionRecord[] -} - -const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" -const HOME_SEARCH_RESULT_ROW = - "flex h-10 w-full shrink-0 cursor-default items-center gap-2 border-0 py-3 pl-[18px] pr-6 text-left transition-[background-color] duration-[120ms] ease-in-out hover:bg-v2-overlay-simple-overlay-hover focus-visible:bg-v2-overlay-simple-overlay-hover focus-visible:outline-none" -const HOME_SEARCH_RESULT_TITLE = - "min-w-0 overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-base [font-weight:530]" -const HOME_SEARCH_RESULT_META = - "min-w-0 flex-[1_1_auto] overflow-hidden text-ellipsis whitespace-nowrap text-[13px] leading-4 tracking-[-0.04px] text-v2-text-text-muted [font-weight:440]" - -let pendingHomeNavigation: { server: ServerConnection.Key; href: string } | undefined - -function buildHomeSessionRecords(input: { - sessions: () => Session[] - projectDirectories: () => string[] - projects: () => LocalProject[] - projectByID: () => Map -}) { - const directories = new Set(input.projectDirectories().map(pathKey)) - const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory))) - return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .flatMap((session) => { - const directory = pathKey(session.directory) - const project = - input - .projects() - .find( - (item) => - pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), - ) ?? projectForSession(session, input.projects(), input.projectByID()) - if (!project) return [] - return { - session, - project, - projectName: displayName(project), - } - }) -} - -function matchesHomeSessionSearch(record: HomeSessionRecord, query: string) { - return `${record.session.title} ${record.projectName}`.toLowerCase().includes(query) -} - -function homeSessionSearchKey(record: HomeSessionRecord) { - return `${pathKey(record.session.directory)}:${record.session.id}` -} - -function useHomeSessionHeaderOpacity(groups: () => HomeSessionGroup[]) { - let viewport: HTMLDivElement | undefined - let content: HTMLDivElement | undefined - let positionFrame: number | undefined - let resizeObserver: ResizeObserver | undefined - let stickyTop = HOME_SESSION_HEADER_STICKY_TOP - const headerRefs = new Map() - const headerOffsets = new Map() - const [state, setState] = createStore({ - titleOpacity: {} as Partial>, - }) - - createEffect(() => { - const items = groups() - const ids = new Set(items.map((group) => group.id)) - headerRefs.forEach((_, id) => { - if (!ids.has(id)) headerRefs.delete(id) - }) - headerOffsets.forEach((_, id) => { - if (!ids.has(id)) headerOffsets.delete(id) - }) - if (items.length === 0) { - content = undefined - bindResizeObserver() - } - queuePositionUpdate() - }) - - onCleanup(() => { - if (positionFrame !== undefined) cancelAnimationFrame(positionFrame) - resizeObserver?.disconnect() - }) - - function setViewport(el: HTMLDivElement) { - viewport = el - bindResizeObserver() - queuePositionUpdate() - } - - function setContentRef(el: HTMLDivElement) { - content = el - bindResizeObserver() - queuePositionUpdate() - } - - function setHeaderRef(id: HomeSessionGroup["id"], el: HTMLDivElement) { - headerRefs.set(id, el) - queuePositionUpdate() - } - - function queuePositionUpdate() { - if (typeof requestAnimationFrame === "undefined") { - updatePositionCache() - return - } - if (positionFrame !== undefined) return - positionFrame = requestAnimationFrame(() => { - positionFrame = undefined - updatePositionCache() - }) - } - - function updatePositionCache() { - if (!viewport) return - const header = groups() - .map((group) => headerRefs.get(group.id)) - .find((el) => el !== undefined) - if (header && typeof getComputedStyle === "function") { - const top = Number.parseFloat(getComputedStyle(header).top) - if (Number.isFinite(top)) stickyTop = top - } - groups().forEach((group) => { - const el = headerRefs.get(group.id) - if (!el) return - headerOffsets.set(group.id, el.offsetTop) - }) - update(viewport.scrollTop) - } - - function update(scrollTop: number) { - const items = groups() - items.forEach((group, index) => { - const nextOffset = items - .slice(index + 1) - .map((item) => headerOffsets.get(item.id)) - .find((offset) => offset !== undefined) - const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT - const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop - const opacity = - nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) - setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000) - }) - } - - function titleOpacity(id: HomeSessionGroup["id"]) { - return state.titleOpacity[id] ?? 1 - } - - function bindResizeObserver() { - resizeObserver?.disconnect() - if (typeof ResizeObserver === "undefined") return - resizeObserver = new ResizeObserver(() => queuePositionUpdate()) - if (viewport) resizeObserver.observe(viewport) - if (content) resizeObserver.observe(content) - } - - return { setViewport, setContentRef, setHeaderRef, update, titleOpacity } -} - -// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session -// tab in the background without navigating, matching browser conventions. -function isBackgroundOpen(event: MouseEvent) { - return shouldOpenSessionInBackground({ - button: event.button, - mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), - meta: event.metaKey, - ctrl: event.ctrlKey, - shift: event.shiftKey, - alt: event.altKey, - }) -} - -type OpenSessionOptions = { background?: boolean } +import { createHomeController } from "./home/home-controller" +import { createHomeProjectsController } from "./home/home-projects-controller" +import { HomeUtilityNav } from "./home/home-projects-view" +import { HomeProjects } from "./home/home-projects" +import { createHomeScrollController } from "./home/home-scroll-controller" +import { createHomeSessionSearchController } from "./home/home-session-search-controller" +import { createHomeSessionsController } from "./home/home-sessions-controller" +import { HomeSessions } from "./home/home-sessions" export function NewHome() { - const sync = useServerSync() - const layout = useLayout() - const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const dialog = useDialog() - const navigate = useNavigate() - const server = useServer() - const language = useLanguage() - const global = useGlobal() - const tabs = useTabs() - const command = useCommand() - const notification = useNotification() - const marked = useMarked() - const openSettings = useSettingsCommand() - let focusSessionSearch: (() => void) | undefined - let sessionViewport: HTMLDivElement | undefined - const [sessionThumbTrack, setSessionThumbTrack] = createSignal() - const [sessionHoverTarget, setSessionHoverTarget] = createSignal() - const [state, setState] = createStore({ - search: "", - searchFocused: false, - }) - const selection = layout.home.selection - - const focusedServer = createMemo( - () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, - ) - const focusedServerCtx = createMemo(() => { - const conn = focusedServer() - if (!conn) return - return global.ensureServerCtx(conn) - }) - const focusedSync = () => focusedServerCtx()?.sync ?? sync() - const homeSessions = () => focusedSync().homeSessions - const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) - const recentlyClosed = createMemo( - () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(), - ) - const homedir = createMemo(() => focusedSync().data.path.home ?? "") - const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) - const newSessionProject = createMemo( - () => - selectedProject() ?? - projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? - projects()[0], - ) - const directories = (project: LocalProject) => [project.worktree, ...(project.sandboxes ?? [])] - const projectDirectories = createMemo(() => { - const project = selectedProject() - if (!project) return projects().flatMap(directories) - return directories(project) - }) - const search = createMemo(() => state.search.trim()) - const searchPlaceholder = createMemo(() => { - const project = selectedProject() - if (project) { - return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) }) - } - if (global.servers.list().length > 1) { - const conn = focusedServer() - if (conn) { - return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) }) - } - } - return language.t("home.sessions.search.placeholder") - }) - const sessionEventLoad = useQuery(() => ({ - queryKey: homeSessions().eventsKey, - queryFn: async (): Promise => ({ sequence: 0, entries: [] }), - initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, - enabled: false, - })) - const sessionLoad = useQuery(() => ({ - queryKey: homeSessions().indexKey, - enabled: !!focusedServerCtx(), - queryFn: async ({ signal }) => { - const ctx = focusedServerCtx() - if (!ctx) return { sessions: [], eventSequence: 0 } - const cache = homeSessions() - const eventSequence = cache.eventSequence() - const index = await loadHomeSessionIndex( - (input, options) => ctx.sdk.client.v2.session.list(input, options), - eventSequence, - signal, - ) - cache.complete(eventSequence) - return index - }, - retry: false, - staleTime: 30_000, - refetchOnMount: true, - refetchOnReconnect: true, - })) - - const projectByID = createMemo( - () => new Map(projects().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), - ) - const indexedSessions = createMemo(() => - retainHomeSessions( - homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), - HOME_SESSION_LIMIT, - Date.now(), - ), - ) - const allRecords = createMemo(() => - buildHomeSessionRecords({ - sessions: indexedSessions, - projectDirectories, - projects, - projectByID, - }), - ) - const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) - const searchResults = createMemo(() => { - const query = search().toLowerCase() - if (!query) return [] - return allRecords().filter((record) => matchesHomeSessionSearch(record, query)) - }) - const searchOpen = createMemo(() => state.searchFocused && search().length > 0) - const groups = createMemo(() => groupSessions(records(), language)) - const sessionHeaderOpacity = useHomeSessionHeaderOpacity(groups) - const prefetched = new Set() - - createEffect(() => { - const ctx = focusedServerCtx() - if (!ctx) return - records() - .slice(0, 2) - .forEach((record) => { - const key = `${ServerConnection.key(focusedServer()!)}\0${record.session.id}` - if (prefetched.has(key)) return - prefetched.add(key) - createRoot((dispose) => { - try { - void ctx.sync.session - .sync(record.session.id) - .then(() => { - return Promise.all( - (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => - (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { - if (part.type !== "text" || !part.text) return [] - return preloadMarkdown(part.text, part.id, marked) - }), - ), - ) - }) - .catch(() => {}) - .finally(dispose) - } catch { - dispose() - } - }) - }) - }) - - function setSelection(next: HomeProjectSelection) { - layout.home.setSelection(next) - } - - function closeSearch() { - setState("search", "") - setState("searchFocused", false) - } - - function selectSearchSession(session: Session, options?: OpenSessionOptions) { - openSession(session, options) - // Background opens keep the search visible so several results can be - // opened in a row. - if (!options?.background) closeSearch() - } - - command.register("home", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const conn = focusedServer() - if (!conn) return - const ctx = global.ensureServerCtx(conn) - const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") - void dialog.show(() => ( - { - if (!entry.sessionID || !entry.directory || !entry.server) return - const sessionID = entry.sessionID - const server = entry.server - const directory = entry.project?.worktree ?? entry.directory - ctx.projects.open(directory) - ctx.projects.touch(directory) - void startTransition(() => { - const tab = tabs.addSessionTab({ server, sessionId: sessionID }) - tabs.select(tab) - }) - }} - /> - )) - }, - }, - { - id: "home.sessions.search.focus", - title: searchPlaceholder(), - keybind: "mod+f", - hidden: true, - onSelect: () => focusSessionSearch?.(), - }, - ]) - - createEffect(() => { - const list = global.servers.list() - if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return - const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] - if (conn) setSelection({ server: ServerConnection.key(conn) }) - }) - - createEffect(() => { - const pending = pendingHomeNavigation - if (!pending || pending.server !== server.key) return - pendingHomeNavigation = undefined - navigate(pending.href) - }) - - function focusServer(conn: ServerConnection.Any) { - setSelection({ server: ServerConnection.key(conn) }) - } - - function selectProject(conn: ServerConnection.Any, directory: string) { - const key = ServerConnection.key(conn) - if (global.servers.health[key]?.healthy === false) return - if ( - !global - .ensureServerCtx(conn) - .projects.list() - .some((project) => project.worktree === directory) - ) - return - setSelection(toggleHomeProjectSelection(selection(), key, directory)) - } - - function addProjects(conn: ServerConnection.Any, directories: string[]) { - const directory = directories[0] - if (!directory) return - const ctx = global.ensureServerCtx(conn) - directories.forEach(ctx.projects.open) - ctx.projects.touch(directory) - setSelection({ server: ServerConnection.key(conn), directory }) - } - - function openNewSession() { - const conn = focusedServer() - const project = newSessionProject() - if (!conn || !project) return - openProjectNewSession(conn, project.worktree) - } - - function openProjectNewSession(conn: ServerConnection.Any, directory: string) { - const ctx = global.ensureServerCtx(conn) - ctx.projects.open(directory) - ctx.projects.touch(directory) - tabs.newDraft({ server: ServerConnection.key(conn), directory }) - } - - function editProject(conn: ServerConnection.Any, project: LocalProject) { - void import("@/components/dialog-edit-project-v2").then((x) => { - void dialog.show(() => ) - }) - } - - function unseenCount(conn: ServerConnection.Any, project: LocalProject) { - const state = notification.ensureServerState(ServerConnection.key(conn)) - return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) - } - - function clearNotifications(conn: ServerConnection.Any, project: LocalProject) { - const state = notification.ensureServerState(ServerConnection.key(conn)) - directories(project) - .filter((directory) => state.project.unseenCount(directory) > 0) - .forEach((directory) => state.project.markViewed(directory)) - } - - function openSession(session: Session, options?: OpenSessionOptions) { - const directoryKey = pathKey(session.directory) - const project = - projects().find( - (item) => - pathKey(item.worktree) === directoryKey || - item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), - ) ?? projectForSession(session, projects(), projectByID()) - const conn = focusedServer() - if (!conn) return - const directory = project?.worktree ?? session.directory - const ctx = global.ensureServerCtx(conn) - ctx.projects.open(directory) - if (options?.background) { - tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) - return - } - ctx.projects.touch(directory) - startTransition(() => { - const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) - tabs.select(tab) - }) - } - - async function archiveSession(session: Session) { - const conn = focusedServer() - const ctx = focusedServerCtx() - if (!conn || !ctx) return - const [, setStore] = ctx.sync.child(session.directory) - await archiveHomeSession({ - server: ServerConnection.key(conn), - session, - archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }), - remove: () => - setStore( - produce((draft) => { - const match = Binary.search(draft.session, session.id, (s) => s.id) - if (match.found) draft.session.splice(match.index, 1) - }), - ), - onError: (error) => - showToast({ - title: language.t("common.requestFailed"), - description: errorMessage(error, language.t("common.requestFailed")), - }), - }) - } - - function chooseProject(conn: ServerConnection.Any) { - if (global.servers.health[ServerConnection.key(conn)]?.healthy === false) return - - function resolve(result: string | string[] | null) { - addProjects(conn, homeProjectDirectories(result)) - } - - pickDirectory({ - server: conn, - title: language.t("command.project.open"), - multiple: true, - onSelect: resolve, - }) - } - + const home = createHomeController() + const projects = createHomeProjectsController(home) + const sessions = createHomeSessionsController(home) + const search = createHomeSessionSearchController(home, sessions) + const scroll = createHomeScrollController(sessions.data.groups) return ( -

    +
    { - sessionViewport = el - sessionHeaderOpacity.setViewport(el) - }} - onScroll={(event) => sessionHeaderOpacity.update(event.currentTarget.scrollTop)} - onWheel={(event) => { - if (!sessionViewport) return - if (event.target instanceof Node && sessionViewport.contains(event.target)) return - containHomeWheel(event, sessionViewport) - }} - > -
    - addProjects(conn, [directory])} - chooseProject={(conn) => void chooseProject(conn)} - editProject={editProject} - closeProject={(conn, directory) => { - const next = closeHomeProject( - selection(), - ServerConnection.key(conn), - global.ensureServerCtx(conn).projects, - directory, - ) - if (next) setSelection(next) - }} - clearNotifications={clearNotifications} - unseenCount={unseenCount} - openSettings={openSettings} - openHelp={() => platform.openLink("https://opencode.ai/desktop-feedback")} - language={language} - onWheel={(event) => { - if (sessionViewport) containHomeWheel(event, sessionViewport) - }} - /> - -
    -
    { - if (sessionViewport) containHomeWheel(event, sessionViewport) - }} - > - { - focusSessionSearch = focus - }} - onInput={(value) => setState("search", value)} - onFocus={() => setState("searchFocused", true)} - onClose={closeSearch} - onSelect={selectSearchSession} - /> - 0 && newSessionProject()}> -
    - - {language.t("command.session.new")} - -
    -
    -
    - {/* Sticky chrome for the portaled session scrollbar — matches old sessions ScrollView bounds */} - -
    - platform.openLink("https://opencode.ai/desktop-feedback")} - language={language} - /> -
    -
    -
    - ) -} - -function HomeProjectColumn(props: { - projects: LocalProject[] - recentlyClosed: LocalProject[] - homedir: string - selected: HomeProjectSelection - focusServer: (server: ServerConnection.Any) => void - selectProject: (server: ServerConnection.Any, directory: string) => void - openNewSession: (server: ServerConnection.Any, directory: string) => void - openRecentProject: (server: ServerConnection.Any, directory: string) => void - chooseProject: (server: ServerConnection.Any) => void - editProject: (server: ServerConnection.Any, project: LocalProject) => void - closeProject: (server: ServerConnection.Any, directory: string) => void - clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - unseenCount: (server: ServerConnection.Any, project: LocalProject) => number - openSettings: () => void - openHelp: () => void - language: ReturnType - onWheel: (event: WheelEvent) => void -}) { - const global = useGlobal() - const dialog = useDialog() - const controller = useServerManagementController({ navigateOnAdd: false }) - const [_state, setState, _, ready] = persisted( - Persist.global("home.servers", ["home.servers.v1"]), - createStore({ collapsed: {} as Record }), - ) - const [state] = createResource( - () => ready.promise ?? Promise.resolve(), - (p) => p.then(() => _state), - { initialValue: _state }, - ) - - return ( - - ) -} - -function HomeUtilityNav(props: { - class?: string - openSettings: () => void - openHelp: () => void - language: ReturnType -}) { - return ( -
    - - -
    - ) -} - -function HomeServerRow(props: { - server: ServerConnection.Any - selected: boolean - collapsed: boolean - health: ServerHealth | undefined - controller: ReturnType - focusServer: (server: ServerConnection.Any) => void - chooseProject: (server: ServerConnection.Any) => void - openEdit: (server: ServerConnection.Http) => void - toggleCollapsed: () => void - language: ReturnType -}) { - const global = useGlobal() - const [state, setState] = createStore({ menuOpen: false }) - const healthy = () => !!props.health?.healthy - const canToggle = () => healthy() && global.ensureServerCtx(props.server).projects.list().length > 0 - return ( -
    - -
    - setState("menuOpen", open)} - /> - - } - aria-label={props.language.t("home.project.add")} - disabled={props.health?.healthy === false} - onClick={() => props.chooseProject(props.server)} - /> - -
    -
    - ) -} - -type HomeProjectListProps = { - server: ServerConnection.Any - projects: LocalProject[] - selected: HomeProjectSelection - selectProject: (server: ServerConnection.Any, directory: string) => void - openNewSession: (server: ServerConnection.Any, directory: string) => void - editProject: (server: ServerConnection.Any, project: LocalProject) => void - closeProject: (server: ServerConnection.Any, directory: string) => void - clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - unseenCount: (server: ServerConnection.Any, project: LocalProject) => number - language: ReturnType -} - -function HomeProjectList(props: HomeProjectListProps) { - const global = useGlobal() - let listRef!: HTMLDivElement - const projects = () => global.ensureServerCtx(props.server).projects - - return ( - [ - ...defaults.filter((sensor) => sensor !== PointerSensor), - PointerSensor.configure({ - activationConstraints: (event) => - event.pointerType === "touch" - ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })] - : [new PointerActivationConstraints.Distance({ value: 4 })], - preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"), - }), - ]} - modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]} - plugins={(defaults) => [ - ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback), - AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }), - Feedback.configure({ dropAnimation: null }), - ]} - onDragEnd={(event) => { - const source = event.operation.source - if (event.canceled || !isSortable(source)) return - if (source.initialIndex !== source.index) projects().move(source.id.toString(), source.index) - if (props.selected.server !== ServerConnection.key(props.server)) - props.selectProject(props.server, source.id.toString()) - }} - > -
    - {/* Keyed on worktree strings: the enriched project objects are - recreated on every store or sync update, so iterating them directly - remounts all rows — killing any in-flight drag activation (the - row's sortable unregisters on unmount) and discarding animations. - String keys keep row elements alive and move them on reorder. */} - project.worktree)}> - {(worktree, index) => } - -
    -
    - ) -} - -function HomeProjectSlot( - props: HomeProjectListProps & { - worktree: string - index: () => number - }, -) { - const project = createMemo(() => props.projects.find((item) => item.worktree === props.worktree)) - - return ( - - {(item) => ( - - )} - - ) -} - -function HomeProjectEmpty(props: { - server: ServerConnection.Any - recentlyClosed: LocalProject[] - homedir: string - chooseProject: (server: ServerConnection.Any) => void - openRecentProject: (server: ServerConnection.Any, directory: string) => void - language: ReturnType -}) { - const global = useGlobal() - const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false - return ( -
    - - 0}> -
    -
    {props.language.t("home.recentlyClosed")}
    -
    - - {(project) => ( - - )} - -
    -
    - ) -} - -function HomeRecentlyClosedRow(props: { - project: LocalProject - server: ServerConnection.Any - homedir: string - openRecentProject: (server: ServerConnection.Any, directory: string) => void - language: ReturnType -}) { - const global = useGlobal() - const unreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false - const path = () => { - const home = props.homedir - const worktree = props.project.worktree - if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` - return worktree - } - return ( - - - - ) -} - -function HomeProjectRow(props: { - project: LocalProject - server: ServerConnection.Any - index: () => number - serverSelected: boolean - selected: boolean - unseenCount: number - selectProject: (server: ServerConnection.Any, directory: string) => void - openNewSession: (server: ServerConnection.Any, directory: string) => void - editProject: (server: ServerConnection.Any, project: LocalProject) => void - closeProject: (server: ServerConnection.Any, directory: string) => void - clearNotifications: (server: ServerConnection.Any, project: LocalProject) => void - language: ReturnType -}) { - const global = useGlobal() - const platform = usePlatform() - const serverUnreachable = () => global.servers.health[ServerConnection.key(props.server)]?.healthy === false - const [state, setState] = createStore({ menuOpen: false }) - const sortable = useSortable({ - get id() { - return props.project.worktree - }, - get index() { - return props.index() - }, - }) - let pointerDownSelected: boolean | undefined - const canRevealInFileManager = () => - platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(props.server) - const fileManagerActionLabel = () => - props.language.t( - fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown").actionLabel, - ) - const revealInFileManager = () => { - if (!platform.openPath) return - platform.openPath(props.project.worktree).catch((err: unknown) => - showToast({ - title: props.language.t("common.requestFailed"), - description: errorMessage(err, props.language.t("common.requestFailed")), - }), - ) - } - return ( -
    - -
    - setState("menuOpen", open)} - > - } - aria-label={props.language.t("common.moreOptions")} - /> - - - props.openNewSession(props.server, props.project.worktree)}> - {props.language.t("command.session.new")} - - props.editProject(props.server, props.project)}> - {props.language.t("dialog.project.edit.title")} - - - {fileManagerActionLabel()} - - props.clearNotifications(props.server, props.project)} - > - {props.language.t("sidebar.project.clearNotifications")} - - - props.closeProject(props.server, props.project.worktree)}> - {props.language.t("common.close")} - - - - - } - aria-label={props.language.t("command.session.new")} - onClick={() => props.openNewSession(props.server, props.project.worktree)} - /> -
    -
    - ) -} - -function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) { - const name = createMemo(() => displayName(props.project)) - return ( - - ) -} - -function HomeSessionLeading(props: { - project: LocalProject - session: Session - server: ServerConnection.Key - revealProjectOnHover: boolean -}) { - const tabs = useTabs() - const hasOpenTab = createMemo(() => sessionHasOpenTab(tabs.store, props.server, props.session)) - return ( -
    - - - -
    - ) -} - -function HomeSessionSearch(props: { - value: string - placeholder: string - open: boolean - loading: boolean - results: HomeSessionRecord[] - showProjectName: boolean - server: ServerConnection.Key - noResultsLabel: string - bindFocus: (focus: () => void) => void - onInput: (value: string) => void - onFocus: () => void - onClose: () => void - onSelect: (session: Session, options?: OpenSessionOptions) => void -}) { - const language = useLanguage() - const [store, setStore] = createStore({ active: "" }) - let root: HTMLDivElement | undefined - let input: HTMLInputElement | undefined - let listRef: HTMLDivElement | undefined - - const focusInput = () => { - input?.focus() - props.onFocus() - } - - onMount(() => { - props.bindFocus(focusInput) - }) - - const syncActive = (results: HomeSessionRecord[]) => { - if (results.length === 0) { - setStore("active", "") - return - } - if (!results.some((record) => homeSessionSearchKey(record) === store.active)) { - setStore("active", homeSessionSearchKey(results[0])) - } - } - - createEffect(() => syncActive(props.results)) - - createEffect( - on( - () => props.value, - () => syncActive(props.results), - ), - ) - - const scrollActiveIntoView = () => { - const key = store.active - if (!key || !listRef) return - const element = listRef.querySelector(`[data-key="${key}"]`) - element?.scrollIntoView({ block: "nearest" }) - } - - const moveActive = (delta: number) => { - const results = props.results - if (results.length === 0) return - const index = results.findIndex((record) => homeSessionSearchKey(record) === store.active) - const start = index === -1 ? 0 : index - const next = (start + delta + results.length) % results.length - setStore("active", homeSessionSearchKey(results[next])) - scrollActiveIntoView() - } - - const selectActive = () => { - const record = props.results.find((item) => homeSessionSearchKey(item) === store.active) - if (!record) return - props.onSelect(record.session) - } - - onCleanup( - makeEventListener(document, "pointerdown", (event) => { - if (!props.open) return - const target = event.target - if (!(target instanceof Node)) return - if (root?.contains(target)) return - props.onClose() - }), - ) - - return ( -
    -
    - -
    -
    -
    - - -
    - } - > - 0} - fallback={ -

    - {props.noResultsLabel} -

    - } - > -
    -

    - {language.t("home.sessions.search.sessions")} -

    - (listRef = el)}> -
    - - {(record) => ( - setStore("active", homeSessionSearchKey(record))} - onSelect={(session, options) => props.onSelect(session, options)} - /> - )} - -
    -
    -
    -
    - -
    -
    -
    - - -
    -
    - ) -} - -function HomeSessionSearchResultRow(props: { - record: HomeSessionRecord - showProjectName: boolean - server: ServerConnection.Key - selected: boolean - onHighlight: () => void - onSelect: (session: Session, options?: OpenSessionOptions) => void -}) { - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const showProjectName = () => props.showProjectName && props.record.projectName - - const key = () => homeSessionSearchKey(props.record) - - return ( - - ) -} - -function HomeSessionGroupHeader(props: { - title: string - titleOpacity: number - ref: ComponentProps<"div">["ref"] - elevated?: boolean -}) { - return ( -
    - -
    - ) -} - -function HomeSessionRow(props: { - record: HomeSessionRecord - showProjectName: boolean - server: ServerConnection.Key - openSession: (session: Session, options?: OpenSessionOptions) => void - archiveSession: (session: Session) => Promise -}) { - const language = useLanguage() - const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) - const showProjectName = () => props.showProjectName && props.record.projectName - - return ( -
    - - -
    - - } - aria-label={language.t("common.archive")} - onClick={(event) => { - event.preventDefault() - event.stopPropagation() - void props.archiveSession(props.record.session) - }} - /> - -
    -
    -
    - ) -} - -function HomeSessionsEmpty(props: { onNewSession?: () => void }) { - const language = useLanguage() - return ( -
    -
    - {language.t("home.sessions.empty")} -
    -

    - {language.t("home.sessions.empty.description")} -

    - - {(onNewSession) => ( - - {language.t("command.session.new")} - - )} - -
    - ) -} - -function HomeSessionSkeleton(props: { label: string }) { - return ( -
    -
    - -
    - - ) -} - -function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { - const now = DateTime.local() - const yesterday = now.minus({ days: 1 }) - const todaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), - ) - const yesterdaySessions = records.filter((record) => - DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), - ) - const olderSessions = records.filter((record) => { - const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) - return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") - }) - const olderTitle = - todaySessions.length === 0 && yesterdaySessions.length === 0 - ? language.t("sidebar.project.recentSessions") - : language.t("home.sessions.group.older") - - return [ - { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, - { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, - { id: "older" as const, title: olderTitle, sessions: olderSessions }, - ].filter((group) => group.sessions.length > 0) -} - -export function LegacyHome() { - const sync = useServerSync() - const platform = usePlatform() - const pickDirectory = useDirectoryPicker() - const dialog = useDialog() - const navigate = useNavigate() - const global = useGlobal() - const server = useServer() - const language = useLanguage() - const homedir = createMemo(() => sync().data.path.home) - const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) - const recent = createMemo(() => { - return sync() - .data.project.slice() - .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) - .slice(0, 5) - }) - - const serverDotClass = createMemo(() => { - const healthy = global.servers.health[server.key]?.healthy - if (healthy === true) return "bg-icon-success-base" - if (healthy === false) return "bg-icon-critical-base" - return "bg-border-weak-base" - }) - - function openProject(server: ServerConnection.Any, directory: string) { - const serverCtx = global.ensureServerCtx(server) - serverCtx.projects.open(directory) - serverCtx.projects.touch(directory) - navigate(`/${base64Encode(directory)}`) - } - - function chooseProject() { - if (serverUnreachable()) return - const s = server.current - if (!s) return - - const resolve = (result: string | string[] | null) => { - if (Array.isArray(result)) { - for (const directory of result) { - openProject(s, directory) - } - } else if (result) { - openProject(s, result) - } - } - - pickDirectory({ - server: s, - title: language.t("command.project.open"), - multiple: true, - onSelect: resolve, - }) - } - - return ( -
    - - - - 0}> -
    -
    -
    {language.t("home.recentProjects")}
    - -
    -
      - - {(project) => ( - - )} - -
    -
    -
    - -
    -
    {language.t("common.loading")}
    - -
    -
    - -
    - -
    -
    {language.t("home.empty.title")}
    -
    {language.t("home.empty.description")}
    -
    - -
    -
    -
    + class={` + mx-auto grid min-h-full w-full max-w-[1080px] grid-rows-[auto_minmax(0,1fr)_auto] gap-4 px-3 + lg:grid-cols-[280px_minmax(0,720px)] lg:grid-rows-1 lg:gap-8 lg:px-6 + `} + > + + + +
    +
    ) } diff --git a/packages/app/src/pages/home/home-controller.ts b/packages/app/src/pages/home/home-controller.ts new file mode 100644 index 0000000000..5e7e50cfc4 --- /dev/null +++ b/packages/app/src/pages/home/home-controller.ts @@ -0,0 +1,108 @@ +import { useGlobal } from "@/context/global" +import { type HomeProjectSelection, useLayout } from "@/context/layout" +import { ServerConnection, useServer } from "@/context/server" +import { useServerSync } from "@/context/server-sync" +import { useTabs } from "@/context/tabs" +import { toggleHomeProjectSelection } from "@/pages/layout/helpers" +import { createEffect, createMemo } from "solid-js" + +export function createHomeController() { + const sync = useServerSync() + const layout = useLayout() + const server = useServer() + const global = useGlobal() + const tabs = useTabs() + const selection = layout.home.selection + const focusedServer = createMemo( + () => global.servers.list().find((conn) => ServerConnection.key(conn) === selection().server) ?? server.current, + ) + const focusedServerCtx = createMemo(() => { + const conn = focusedServer() + if (!conn) return undefined + return global.ensureServerCtx(conn) + }) + const focusedSync = () => focusedServerCtx()?.sync ?? sync() + const projects = createMemo(() => focusedServerCtx()?.projects.list() ?? layout.projects.list()) + const recentlyClosed = createMemo( + () => focusedServerCtx()?.projects.recentlyClosed() ?? layout.projects.recentlyClosed(), + ) + const homedir = createMemo(() => focusedSync().data.path.home ?? "") + const selectedProject = createMemo(() => projects().find((project) => project.worktree === selection().directory)) + const newSessionProject = createMemo( + () => + selectedProject() ?? + projects().find((project) => project.worktree === focusedServerCtx()?.projects.last()) ?? + projects()[0], + ) + + createEffect(() => { + const list = global.servers.list() + if (list.some((conn) => ServerConnection.key(conn) === selection().server)) return + const conn = list.find((conn) => ServerConnection.key(conn) === server.key) ?? list[0] + if (conn) setSelection({ server: ServerConnection.key(conn) }) + }) + + function setSelection(next: HomeProjectSelection) { + layout.home.setSelection(next) + } + + function openProjectNewSession(conn: ServerConnection.Any, directory: string) { + const ctx = global.ensureServerCtx(conn) + ctx.projects.open(directory) + ctx.projects.touch(directory) + void tabs.newDraft({ server: ServerConnection.key(conn), directory }) + } + + return { + selection: { + value: selection, + set: setSelection, + focusServer: (conn: ServerConnection.Any) => setSelection({ server: ServerConnection.key(conn) }), + }, + server: { + list: global.servers.list, + health: (conn: ServerConnection.Any) => global.servers.health[ServerConnection.key(conn)], + context: (conn: ServerConnection.Any) => global.ensureServerCtx(conn), + focused: focusedServer, + focusedContext: focusedServerCtx, + focusedSync, + }, + project: { + list: projects, + recentlyClosed, + homedir, + selected: selectedProject, + newSession: newSessionProject, + forServer: (conn: ServerConnection.Any) => global.ensureServerCtx(conn).projects.list(), + select: (conn: ServerConnection.Any, directory: string) => { + const key = ServerConnection.key(conn) + if (global.servers.health[key]?.healthy === false) return + if ( + !global + .ensureServerCtx(conn) + .projects.list() + .some((project) => project.worktree === directory) + ) + return + setSelection(toggleHomeProjectSelection(selection(), key, directory)) + }, + add: (conn: ServerConnection.Any, directories: string[]) => { + const directory = directories[0] + if (!directory) return + const ctx = global.ensureServerCtx(conn) + directories.forEach((item) => ctx.projects.open(item)) + ctx.projects.touch(directory) + setSelection({ server: ServerConnection.key(conn), directory }) + }, + openNewSession: () => { + const conn = focusedServer() + const project = newSessionProject() + if (!conn || !project) return + openProjectNewSession(conn, project.worktree) + }, + openProjectNewSession, + }, + } +} + +export type HomeController = ReturnType diff --git a/packages/app/src/pages/home/home-projects-controller.tsx b/packages/app/src/pages/home/home-projects-controller.tsx new file mode 100644 index 0000000000..3e6b6d306b --- /dev/null +++ b/packages/app/src/pages/home/home-projects-controller.tsx @@ -0,0 +1,128 @@ +import { useDirectoryPicker } from "@/components/directory-picker" +import { useServerManagementController } from "@/components/dialog-select-server" +import { useSettingsCommand } from "@/components/settings-dialog" +import { DialogServerV2 } from "@/components/settings-v2/dialog-server-v2" +import { type LocalProject } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { useNotification } from "@/context/notification" +import { usePlatform } from "@/context/platform" +import { ServerConnection } from "@/context/server" +import { closeHomeProject, errorMessage, homeProjectDirectories } from "@/pages/layout/helpers" +import { Persist, persisted } from "@/utils/persist" +import { showToast } from "@/utils/toast" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { createResource } from "solid-js" +import { createStore } from "solid-js/store" +import type { HomeController } from "./home-controller" + +export function createHomeProjectsController(home: HomeController) { + const platform = usePlatform() + const pickDirectory = useDirectoryPicker() + const dialog = useDialog() + const language = useLanguage() + const notification = useNotification() + const openSettings = useSettingsCommand() + const serverManagement = useServerManagementController({ navigateOnAdd: false }) + const [_state, setState, _, ready] = persisted( + Persist.global("home.servers", ["home.servers.v1"]), + createStore({ collapsed: {} as Record }), + ) + const [state] = createResource( + () => ready.promise ?? Promise.resolve(), + (promise) => promise.then(() => _state), + { initialValue: _state }, + ) + function directories(project: LocalProject) { + return [project.worktree, ...(project.sandboxes ?? [])] + } + + function canRevealProject(conn: ServerConnection.Any) { + return platform.platform === "desktop" && !!platform.openPath && ServerConnection.local(conn) + } + + return { + copy: { + language, + }, + selection: { + value: home.selection.value, + }, + server: { + list: home.server.list, + health: home.server.health, + projects: home.project.forServer, + collapsed: (conn: ServerConnection.Any) => state().collapsed[ServerConnection.key(conn)] ?? false, + toggleCollapsed: (conn: ServerConnection.Any) => { + const key = ServerConnection.key(conn) + setState("collapsed", key, !state().collapsed[key]) + }, + canDefault: serverManagement.canDefault, + defaultKey: serverManagement.defaultKey, + setDefault: (conn: ServerConnection.Any | undefined) => + serverManagement.setDefault(conn ? ServerConnection.key(conn) : null), + remove: (conn: ServerConnection.Any) => serverManagement.handleRemove(ServerConnection.key(conn)), + edit: (conn: ServerConnection.Http) => dialog.show(() => ), + focus: home.selection.focusServer, + }, + project: { + list: home.project.list, + recentlyClosed: home.project.recentlyClosed, + homedir: home.project.homedir, + select: home.project.select, + add: home.project.add, + openNewSession: home.project.openProjectNewSession, + edit: (conn: ServerConnection.Any, project: LocalProject) => { + void import("@/components/dialog-edit-project-v2").then(({ DialogEditProjectV2 }) => { + void dialog.show(() => ) + }) + }, + unseenCount: (conn: ServerConnection.Any, project: LocalProject) => { + const state = notification.ensureServerState(ServerConnection.key(conn)) + return directories(project).reduce((total, directory) => total + state.project.unseenCount(directory), 0) + }, + clearNotifications: (conn: ServerConnection.Any, project: LocalProject) => { + const state = notification.ensureServerState(ServerConnection.key(conn)) + directories(project) + .filter((directory) => state.project.unseenCount(directory) > 0) + .forEach((directory) => state.project.markViewed(directory)) + }, + choose: (conn: ServerConnection.Any) => { + if (home.server.health(conn)?.healthy === false) return + pickDirectory({ + server: conn, + title: language.t("command.project.open"), + multiple: true, + onSelect: (result) => home.project.add(conn, homeProjectDirectories(result)), + }) + }, + close: (conn: ServerConnection.Any, directory: string) => { + const next = closeHomeProject( + home.selection.value(), + ServerConnection.key(conn), + home.server.context(conn).projects, + directory, + ) + if (next) home.selection.set(next) + }, + move: (conn: ServerConnection.Any, worktree: string, index: number) => { + home.server.context(conn).projects.move(worktree, index) + }, + canReveal: canRevealProject, + reveal: (conn: ServerConnection.Any, project: LocalProject) => { + if (!platform.openPath || !canRevealProject(conn)) return + platform.openPath(project.worktree).catch((cause: unknown) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + ) + }, + }, + utility: { + settings: openSettings, + help: () => platform.openLink("https://opencode.ai/desktop-feedback"), + }, + } +} + +export type HomeProjectsController = ReturnType diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx new file mode 100644 index 0000000000..4dc39117c3 --- /dev/null +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -0,0 +1,608 @@ +import { type Accessor, createMemo, For, type JSX, onCleanup, Show, splitProps } from "solid-js" +import { createStore } from "solid-js/store" +import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" +import { isSortable, useSortable } from "@dnd-kit/solid/sortable" +import { AutoScroller, Feedback, PointerActivationConstraints } from "@dnd-kit/dom" +import { RestrictToVerticalAxis } from "@dnd-kit/abstract/modifiers" +import { RestrictToElement } from "@dnd-kit/dom/modifiers" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { ProjectAvatar } from "@opencode-ai/ui/v2/project-avatar-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { getProjectAvatarVariant, type HomeProjectSelection, type LocalProject } from "@/context/layout" +import { ServerConnection } from "@/context/server" +import { useLanguage } from "@/context/language" +import { usePlatform } from "@/context/platform" +import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" +import { ServerRowMenuView, serverMenuLabels } from "@/components/server/server-row-menu" +import { ServerHealthIndicator } from "@/components/server/server-row" +import { type ServerHealth } from "@/utils/server-health" +import { fileManagerApp } from "@/utils/file-manager" + +const HOME_PROJECT_NAV_LABEL = "min-w-0 flex-1 overflow-hidden text-ellipsis whitespace-nowrap" + +const serverContextMenuID = (server: ServerConnection.Any) => `server:${ServerConnection.key(server)}` +const projectContextMenuID = (server: ServerConnection.Any, directory: string) => + `project:${ServerConnection.key(server)}:${directory}` + +export type HomeProjectsViewProps = { + language: ReturnType + servers: Accessor + projects: Accessor + recentlyClosed: Accessor + selection: Accessor + homedir: Accessor + serverHealth: (server: ServerConnection.Any) => ServerHealth | undefined + projectsForServer: (server: ServerConnection.Any) => LocalProject[] + collapsed: (server: ServerConnection.Any) => boolean + canDefaultServer: Accessor + defaultServerKey: Accessor + canRevealProject: (server: ServerConnection.Any) => boolean + unseenCount: (server: ServerConnection.Any, project: LocalProject) => number + onWheel: (event: WheelEvent) => void + onChooseProject: (server: ServerConnection.Any) => void + onFocusServer: (server: ServerConnection.Any) => void + onToggleCollapsed: (server: ServerConnection.Any) => void + onEditServer: (server: ServerConnection.Http) => void + onSetDefaultServer: (server: ServerConnection.Any | undefined) => void + onRemoveServer: (server: ServerConnection.Any) => void + onMoveProject: (server: ServerConnection.Any, worktree: string, index: number) => void + onSelectProject: (server: ServerConnection.Any, directory: string) => void + onAddProjects: (server: ServerConnection.Any, directories: string[]) => void + onOpenProjectNewSession: (server: ServerConnection.Any, directory: string) => void + onEditProject: (server: ServerConnection.Any, project: LocalProject) => void + onRevealProject: (server: ServerConnection.Any, project: LocalProject) => void + onClearNotifications: (server: ServerConnection.Any, project: LocalProject) => void + onCloseProject: (server: ServerConnection.Any, directory: string) => void + onOpenSettings: () => void + onOpenHelp: () => void +} + +export function HomeProjectsView(props: HomeProjectsViewProps) { + const [contextMenu, setContextMenu] = createStore({ open: undefined as string | undefined }) + const contextMenuProps = { + contextMenuOpen: (id: string) => contextMenu.open === id, + onSetContextMenuOpen: (id: string, open: boolean) => setContextMenu("open", open ? id : undefined), + } + return ( + + ) +} + +export function HomeUtilityNav(props: { + class?: string + onOpenSettings: () => void + onOpenHelp: () => void + language: ReturnType +}) { + return ( +
    + + + {props.language.t("sidebar.settings")} + + + + {props.language.t("sidebar.help")} + +
    + ) +} + +function HomeServerRow(props: { + language: HomeProjectsViewProps["language"] + projectsForServer: HomeProjectsViewProps["projectsForServer"] + contextMenuOpen: HomeProjectsContextMenuProps["contextMenuOpen"] + canDefaultServer: HomeProjectsViewProps["canDefaultServer"] + defaultServerKey: HomeProjectsViewProps["defaultServerKey"] + onFocusServer: HomeProjectsViewProps["onFocusServer"] + onToggleCollapsed: HomeProjectsViewProps["onToggleCollapsed"] + onEditServer: HomeProjectsViewProps["onEditServer"] + onSetDefaultServer: HomeProjectsViewProps["onSetDefaultServer"] + onRemoveServer: HomeProjectsViewProps["onRemoveServer"] + onSetContextMenuOpen: HomeProjectsContextMenuProps["onSetContextMenuOpen"] + onChooseProject: HomeProjectsViewProps["onChooseProject"] + server: ServerConnection.Any + selected: boolean + collapsed: boolean + health: ServerHealth | undefined +}) { + const healthy = () => !!props.health?.healthy + const canToggle = () => healthy() && props.projectsForServer(props.server).length > 0 + const contextMenuID = () => serverContextMenuID(props.server) + onCleanup(() => { + const id = contextMenuID() + if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false) + }) + return ( +
    + props.onFocusServer(props.server)} + > + { + event.preventDefault() + event.stopPropagation() + if (!canToggle()) return + props.onToggleCollapsed(props.server) + }} + onPointerDown={(event) => event.preventDefault()} + > + + +
    + +
    + + {props.server.displayName ?? new URL(props.server.http.url).host} + + {(label) => ( + + {label()} + + )} + + +
    +
    + props.onSetDefaultServer(props.server)} + onRemoveDefault={() => props.onSetDefaultServer(undefined)} + onRemove={() => props.onRemoveServer(props.server)} + open={props.contextMenuOpen(contextMenuID())} + onOpenChange={(open) => props.onSetContextMenuOpen(contextMenuID(), open)} + /> + + } + aria-label={props.language.t("home.project.add")} + disabled={props.health?.healthy === false} + onClick={() => props.onChooseProject(props.server)} + /> + +
    +
    + ) +} + +type HomeProjectsContextMenuProps = { + contextMenuOpen: (id: string) => boolean + onSetContextMenuOpen: (id: string, open: boolean) => void +} + +type HomeProjectListProps = HomeProjectsViewProps & HomeProjectsContextMenuProps & { + server: ServerConnection.Any + items: LocalProject[] +} + +function HomeProjectList(props: HomeProjectListProps) { + let listRef!: HTMLDivElement + + return ( + [ + ...defaults.filter((sensor) => sensor !== PointerSensor), + PointerSensor.configure({ + activationConstraints: (event) => + event.pointerType === "touch" + ? [new PointerActivationConstraints.Delay({ value: 250, tolerance: 5 })] + : [new PointerActivationConstraints.Distance({ value: 4 })], + preventActivation: (event) => event.target instanceof Element && !!event.target.closest("[data-action]"), + }), + ]} + modifiers={[RestrictToVerticalAxis, RestrictToElement.configure({ element: () => listRef })]} + plugins={(defaults) => [ + ...defaults.filter((plugin) => plugin !== AutoScroller && plugin !== Feedback), + AutoScroller.configure({ acceleration: 8, threshold: { x: 0, y: 0.05 } }), + Feedback.configure({ dropAnimation: null }), + ]} + onDragEnd={(event) => { + const source = event.operation.source + if (event.canceled || !isSortable(source)) return + if (source.initialIndex !== source.index) props.onMoveProject(props.server, source.id.toString(), source.index) + if (props.selection().server !== ServerConnection.key(props.server)) + props.onSelectProject(props.server, source.id.toString()) + }} + > +
    + {/* Keyed on worktree strings: the enriched project objects are + recreated on every store or sync update, so iterating them directly + remounts all rows — killing any in-flight drag activation (the + row's sortable unregisters on unmount) and discarding animations. + String keys keep row elements alive and move them on reorder. */} + project.worktree)}> + {(worktree, index) => } + +
    +
    + ) +} + +function HomeProjectSlot( + props: HomeProjectListProps & { + worktree: string + index: () => number + }, +) { + const project = createMemo(() => props.items.find((item) => item.worktree === props.worktree)) + + return ( + + {(item) => ( + + )} + + ) +} + +function HomeProjectEmpty( + props: HomeProjectsViewProps & { + server: ServerConnection.Any + items: LocalProject[] + }, +) { + const unreachable = () => props.serverHealth(props.server)?.healthy === false + return ( +
    + props.onChooseProject(props.server)} + > + + {props.language.t("home.project.add")} + + 0}> +
    +
    {props.language.t("home.recentlyClosed")}
    +
    + + {(project) => } + +
    +
    + ) +} + +function HomeRecentlyClosedRow( + props: HomeProjectsViewProps & { + project: LocalProject + server: ServerConnection.Any + }, +) { + const unreachable = () => props.serverHealth(props.server)?.healthy === false + const path = () => { + const home = props.homedir() + const worktree = props.project.worktree + if (home && (worktree === home || worktree.startsWith(`${home}/`))) return `~${worktree.slice(home.length)}` + return worktree + } + return ( + + props.onAddProjects(props.server, [props.project.worktree])} + > + + {displayName(props.project)} + + + ) +} + +function HomeProjectRow( + props: HomeProjectsViewProps & HomeProjectsContextMenuProps & { + project: LocalProject + server: ServerConnection.Any + index: () => number + serverSelected: boolean + selected: boolean + unseen: number + }, +) { + const platform = usePlatform() + const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false + const sortable = useSortable({ + get id() { + return props.project.worktree + }, + get index() { + return props.index() + }, + }) + let pointerDownSelected: boolean | undefined + const contextMenuID = () => projectContextMenuID(props.server, props.project.worktree) + onCleanup(() => { + const id = contextMenuID() + if (props.contextMenuOpen(id)) props.onSetContextMenuOpen(id, false) + }) + return ( +
    + { + // Same-server mouse selection happens on pointerdown (like tabs), + // but only ever selects; selectProject toggles, and deselecting here + // would fire on every drag before the threshold is met. Cross-server + // selection waits for click so reordering a remote server's projects + // does not focus that server and load its session index. Touch is + // excluded so flick-scrolling the list cannot select rows. + pointerDownSelected = undefined + if (event.button !== 0 || event.pointerType === "touch") return + if (!props.serverSelected) return + pointerDownSelected = props.selected + if (!props.selected) props.onSelectProject(props.server, props.project.worktree) + }} + onClick={(event) => { + // The drag sensor calls preventDefault on post-drag clicks; never + // toggle selection as part of a reorder. + if (event.defaultPrevented) return + // Keyboard activation and touch taps keep the original toggle. + if (event.detail === 0 || pointerDownSelected === undefined) { + props.onSelectProject(props.server, props.project.worktree) + return + } + // Mouse: pointerdown already selected unselected rows; a plain click + // on an already-selected row toggles it off. + if (pointerDownSelected) props.onSelectProject(props.server, props.project.worktree) + pointerDownSelected = undefined + }} + > + + {displayName(props.project)} + +
    + props.onSetContextMenuOpen(contextMenuID(), open)} + > + } + aria-label={props.language.t("common.moreOptions")} + /> + + + props.onOpenProjectNewSession(props.server, props.project.worktree)}> + {props.language.t("command.session.new")} + + props.onEditProject(props.server, props.project)}> + {props.language.t("dialog.project.edit.title")} + + + props.onRevealProject(props.server, props.project)}> + {props.language.t( + fileManagerApp(platform.platform === "desktop" ? (platform.os ?? "unknown") : "unknown") + .actionLabel, + )} + + + props.onClearNotifications(props.server, props.project)} + > + {props.language.t("sidebar.project.clearNotifications")} + + + props.onCloseProject(props.server, props.project.worktree)}> + {props.language.t("common.close")} + + + + + } + aria-label={props.language.t("command.session.new")} + onClick={() => props.onOpenProjectNewSession(props.server, props.project.worktree)} + /> +
    +
    + ) +} + +function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes) { + const [local, rest] = splitProps(props, ["class", "classList", "children"]) + return ( + + ) +} + +function HomeProjectAvatar(props: { project: LocalProject; outline?: boolean }) { + const name = createMemo(() => displayName(props.project)) + return ( + + ) +} diff --git a/packages/app/src/pages/home/home-projects.tsx b/packages/app/src/pages/home/home-projects.tsx new file mode 100644 index 0000000000..ff2abf7c16 --- /dev/null +++ b/packages/app/src/pages/home/home-projects.tsx @@ -0,0 +1,40 @@ +import type { HomeProjectsController } from "./home-projects-controller" +import { HomeProjectsView } from "./home-projects-view" +import type { HomeScrollController } from "./home-scroll-controller" + +export function HomeProjects(props: { projects: HomeProjectsController; scroll: HomeScrollController }) { + return ( + + ) +} diff --git a/packages/app/src/pages/home/home-scroll-controller.ts b/packages/app/src/pages/home/home-scroll-controller.ts new file mode 100644 index 0000000000..96b0cf46d3 --- /dev/null +++ b/packages/app/src/pages/home/home-scroll-controller.ts @@ -0,0 +1,145 @@ +import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import type { HomeSessionGroup } from "./home-sessions-controller" + +const HOME_SESSION_HEADER_STICKY_TOP = 12 +const HOME_SESSION_HEADER_TEXT_HEIGHT = 16 +const HOME_SESSION_HEADER_FADE_DISTANCE = 16 + +export function createHomeScrollController(groups: Accessor) { + const [thumbTrack, setThumbTrack] = createSignal() + const [hoverTarget, setHoverTarget] = createSignal() + const [state, setState] = createStore({ + titleOpacity: {} as Partial>, + }) + const headerRefs = new Map() + const headerOffsets = new Map() + let viewport: HTMLDivElement | undefined + let content: HTMLDivElement | undefined + let positionFrame: number | undefined + let resizeObserver: ResizeObserver | undefined + let stickyTop = HOME_SESSION_HEADER_STICKY_TOP + + createEffect(() => { + const items = groups() + const ids = new Set(items.map((group) => group.id)) + headerRefs.forEach((_, id) => { + if (!ids.has(id)) headerRefs.delete(id) + }) + headerOffsets.forEach((_, id) => { + if (!ids.has(id)) headerOffsets.delete(id) + }) + if (items.length === 0) { + content = undefined + bindResizeObserver() + } + queuePositionUpdate() + }) + + onCleanup(() => { + if (positionFrame !== undefined) cancelAnimationFrame(positionFrame) + resizeObserver?.disconnect() + }) + + function queuePositionUpdate() { + if (typeof requestAnimationFrame === "undefined") { + updatePositionCache() + return + } + if (positionFrame !== undefined) return + positionFrame = requestAnimationFrame(() => { + positionFrame = undefined + updatePositionCache() + }) + } + + function updatePositionCache() { + if (!viewport) return + const header = groups() + .map((group) => headerRefs.get(group.id)) + .find((element) => element !== undefined) + if (header && typeof getComputedStyle === "function") { + const top = Number.parseFloat(getComputedStyle(header).top) + if (Number.isFinite(top)) stickyTop = top + } + groups().forEach((group) => { + const element = headerRefs.get(group.id) + if (element) headerOffsets.set(group.id, element.offsetTop) + }) + update(viewport.scrollTop) + } + + function update(scrollTop: number) { + const items = groups() + items.forEach((group, index) => { + const nextOffset = items + .slice(index + 1) + .map((item) => headerOffsets.get(item.id)) + .find((offset) => offset !== undefined) + const fadeEnd = stickyTop + HOME_SESSION_HEADER_TEXT_HEIGHT + const nextTop = nextOffset === undefined ? undefined : nextOffset - scrollTop + const opacity = + nextTop === undefined ? 1 : Math.max(0, Math.min(1, (nextTop - fadeEnd) / HOME_SESSION_HEADER_FADE_DISTANCE)) + setState("titleOpacity", group.id, Math.round(opacity * 1000) / 1000) + }) + } + + function bindResizeObserver() { + resizeObserver?.disconnect() + if (typeof ResizeObserver === "undefined") return + resizeObserver = new ResizeObserver(queuePositionUpdate) + if (viewport) resizeObserver.observe(viewport) + if (content) resizeObserver.observe(content) + } + + function containWheel(event: WheelEvent) { + if (!viewport) return + if (event.defaultPrevented || event.ctrlKey || !event.deltaY) return + if (!(event.target instanceof Element)) return + const scrollable = event.target.closest("[data-scrollable]") + if ( + scrollable !== viewport && + scrollable && + (event.deltaY < 0 + ? scrollable.scrollTop > 0 + : scrollable.scrollTop < scrollable.scrollHeight - scrollable.clientHeight) + ) + return + event.preventDefault() + } + + return { + viewport: { + thumbTrack, + hoverTarget, + setThumbTrack, + setHoverTarget, + setViewport: (element: HTMLDivElement) => { + viewport = element + bindResizeObserver() + queuePositionUpdate() + }, + update, + containWheel, + containOuterWheel: (event: WheelEvent) => { + if (!viewport) return + if (event.target instanceof Node && viewport.contains(event.target)) return + containWheel(event) + }, + }, + header: { + setContent: (element: HTMLDivElement) => { + content = element + bindResizeObserver() + queuePositionUpdate() + }, + setHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => { + headerRefs.set(id, element) + queuePositionUpdate() + }, + titleOpacity: (id: HomeSessionGroup["id"]) => state.titleOpacity[id] ?? 1, + }, + } +} + +export type HomeScrollController = ReturnType diff --git a/packages/app/src/pages/home/home-session-search-controller.ts b/packages/app/src/pages/home/home-session-search-controller.ts new file mode 100644 index 0000000000..5e55c4b746 --- /dev/null +++ b/packages/app/src/pages/home/home-session-search-controller.ts @@ -0,0 +1,114 @@ +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" +import { serverName } from "@/context/server" +import { displayName } from "@/pages/layout/helpers" +import { makeEventListener } from "@solid-primitives/event-listener" +import { createMemo, onCleanup } from "solid-js" +import { createStore } from "solid-js/store" +import type { HomeController } from "./home-controller" +import { homeSessionSearchKey, type HomeSessionRecord, type HomeSessionsController } from "./home-sessions-controller" + +type HomeSessionSearchSource = Pick + +export function createHomeSessionSearchController(home: HomeController, sessions: HomeSessionSearchSource) { + const command = useCommand() + const language = useLanguage() + const [state, setState] = createStore({ value: "", focused: false, highlighted: "" }) + let root: HTMLDivElement | undefined + let input: HTMLInputElement | undefined + let list: HTMLDivElement | undefined + const query = createMemo(() => state.value.trim()) + const results = createMemo(() => { + const value = query().toLowerCase() + if (!value) return [] + return sessions.data + .searchRecords() + .filter((record) => `${record.session.title} ${record.projectName}`.toLowerCase().includes(value)) + }) + const active = createMemo(() => { + const records = results() + if (records.some((record) => homeSessionSearchKey(record) === state.highlighted)) return state.highlighted + return records[0] ? homeSessionSearchKey(records[0]) : "" + }) + const open = createMemo(() => state.focused && query().length > 0) + const placeholder = createMemo(() => { + const project = home.project.selected() + if (project) return language.t("home.sessions.search.placeholder.scoped", { scope: displayName(project) }) + if (home.server.list().length > 1) { + const conn = home.server.focused() + if (conn) return language.t("home.sessions.search.placeholder.scoped", { scope: serverName(conn) }) + } + return language.t("home.sessions.search.placeholder") + }) + + onCleanup( + makeEventListener(document, "pointerdown", (event) => { + if (!open()) return + const target = event.target + if (!(target instanceof Node) || root?.contains(target)) return + close() + }), + ) + + command.register("home.search", () => [ + { + id: "home.sessions.search.focus", + title: placeholder(), + keybind: "mod+f", + hidden: true, + onSelect: focus, + }, + ]) + + function focus() { + input?.focus() + setState("focused", true) + } + + function close() { + setState({ value: "", focused: false }) + } + + function select(record: HomeSessionRecord, options?: { background?: boolean }) { + sessions.session.open(record.session, options) + if (!options?.background) close() + } + + return { + query: { + value: () => state.value, + placeholder, + open, + focus, + input: (value: string) => setState({ value, highlighted: "" }), + close, + }, + result: { + loading: sessions.data.loading, + list: results, + active, + noResultsLabel: () => language.t("home.sessions.search.noResults", { query: query() }), + highlight: (record: HomeSessionRecord) => setState("highlighted", homeSessionSearchKey(record)), + move: (delta: number) => { + const records = results() + if (records.length === 0) return + const index = records.findIndex((record) => homeSessionSearchKey(record) === active()) + const next = ((index === -1 ? 0 : index) + delta + records.length) % records.length + setState("highlighted", homeSessionSearchKey(records[next])) + list?.querySelector(`[data-key="${state.highlighted}"]`)?.scrollIntoView({ block: "nearest" }) + }, + select, + selectActive: () => { + const record = results().find((item) => homeSessionSearchKey(item) === active()) + if (record) select(record) + }, + }, + element: { + setRoot: (element: HTMLDivElement) => (root = element), + setInput: (element: HTMLInputElement) => (input = element), + setList: (element: HTMLDivElement) => (list = element), + }, + } +} + +export type HomeSessionSearchController = ReturnType diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx new file mode 100644 index 0000000000..06d86c30c9 --- /dev/null +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -0,0 +1,314 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" +import { preloadMarkdown } from "@opencode-ai/session-ui/markdown-cache" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useMarked } from "@opencode-ai/ui/context/marked" +import { useQuery } from "@tanstack/solid-query" +import { DateTime } from "luxon" +import { type Accessor, createEffect, createMemo, createRoot, type JSX, startTransition } from "solid-js" +import { produce } from "solid-js/store" +import { useCommand } from "@/context/command" +import { + loadHomeSessionIndex, + retainHomeSessions, + type HomeSessionEvents, +} from "@/context/global-sync/home-session-index" +import type { LocalProject } from "@/context/layout" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { sessionHasOpenTab, useTabs } from "@/context/tabs" +import { displayName, errorMessage, projectForSession } from "@/pages/layout/helpers" +import { useSessionTabAvatarState } from "@/pages/layout/project-avatar-state" +import { pathKey } from "@/utils/path-key" +import { showToast } from "@/utils/toast" +import { Binary } from "@opencode-ai/core/util/binary" +import { archiveHomeSession } from "../home-session-archive" +import type { HomeController } from "./home-controller" + +const HOME_SESSION_LIMIT = 64 +export type HomeSessionRecord = { + session: Session + project: LocalProject + projectName: string +} + +export type HomeSessionGroup = { + id: "today" | "yesterday" | "older" + title: string + sessions: HomeSessionRecord[] +} + +export type OpenSessionOptions = { background?: boolean } + +export function createHomeSessionsController(home: HomeController) { + const tabs = useTabs() + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + const marked = useMarked() + const projectDirectories = createMemo(() => { + const project = home.project.selected() + if (!project) return home.project.list().flatMap(directories) + return directories(project) + }) + const projectByID = createMemo( + () => new Map(home.project.list().flatMap((project) => (project.id ? [[project.id, project] as const] : []))), + ) + const homeSessions = () => home.server.focusedSync().homeSessions + const sessionEventLoad = useQuery(() => ({ + queryKey: homeSessions().eventsKey, + queryFn: async (): Promise => ({ sequence: 0, entries: [] }), + initialData: { sequence: 0, entries: [] } satisfies HomeSessionEvents, + enabled: false, + })) + const sessionLoad = useQuery(() => ({ + queryKey: homeSessions().indexKey, + enabled: !!home.server.focusedContext(), + queryFn: async ({ signal }) => { + const ctx = home.server.focusedContext() + if (!ctx) return { sessions: [], eventSequence: 0 } + const cache = homeSessions() + const eventSequence = cache.eventSequence() + const index = await loadHomeSessionIndex( + (input, options) => ctx.sdk.client.v2.session.list(input, options), + eventSequence, + signal, + ) + cache.complete(eventSequence) + return index + }, + retry: false, + staleTime: 30_000, + refetchOnMount: true, + refetchOnReconnect: true, + })) + const indexedSessions = createMemo(() => + retainHomeSessions( + homeSessions().sessions(sessionLoad.data, sessionEventLoad.data), + HOME_SESSION_LIMIT, + Date.now(), + ), + ) + const allRecords = createMemo(() => + buildHomeSessionRecords({ + sessions: indexedSessions, + projectDirectories, + projects: home.project.list, + projectByID, + }), + ) + const records = createMemo(() => allRecords().slice(0, HOME_SESSION_LIMIT)) + const groups = createMemo(() => groupSessions(records(), language)) + const prefetched = new Set() + + createEffect(() => { + const ctx = home.server.focusedContext() + const conn = home.server.focused() + if (!ctx || !conn) return + records() + .slice(0, 2) + .forEach((record) => { + const key = `${ServerConnection.key(conn)}\0${record.session.id}` + if (prefetched.has(key)) return + prefetched.add(key) + createRoot((dispose) => { + try { + void ctx.sync.session + .sync(record.session.id) + .then(() => + Promise.all( + (ctx.sync.session.data.message[record.session.id] ?? []).flatMap((message) => + (ctx.sync.session.data.part[message.id] ?? []).flatMap((part) => { + if (part.type !== "text" || !part.text) return [] + return preloadMarkdown(part.text, part.id, marked) + }), + ), + ), + ) + .catch(() => {}) + .finally(dispose) + } catch { + dispose() + } + }) + }) + }) + + command.register("home.palette", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const conn = home.server.focused() + if (!conn) return + const ctx = home.server.focusedContext() + if (!ctx) return + const { DialogHomeCommandPaletteV2 } = await import("@/components/dialog-command-palette-v2") + void dialog.show(() => ( + { + if (!entry.sessionID || !entry.directory || !entry.server) return + const sessionID = entry.sessionID + const server = entry.server + const directory = entry.project?.worktree ?? entry.directory + ctx.projects.open(directory) + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server, sessionId: sessionID }) + tabs.select(tab) + }) + }} + /> + )) + }, + }, + ]) + + return { + copy: { + language, + }, + data: { + records, + groups, + loading: () => sessionLoad.isLoading, + searchRecords: allRecords, + }, + session: { + showProjectName: () => !home.project.selected(), + server: () => home.selection.value().server, + canCreate: () => !!home.project.newSession(), + create: home.project.openNewSession, + open: (session: Session, options?: OpenSessionOptions) => { + const directoryKey = pathKey(session.directory) + const project = + home.project + .list() + .find( + (item) => + pathKey(item.worktree) === directoryKey || + item.sandboxes?.some((sandbox) => pathKey(sandbox) === directoryKey), + ) ?? projectForSession(session, home.project.list(), projectByID()) + const conn = home.server.focused() + if (!conn) return + const directory = project?.worktree ?? session.directory + const ctx = home.server.focusedContext() + if (!ctx) return + ctx.projects.open(directory) + if (options?.background) { + tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + return + } + ctx.projects.touch(directory) + void startTransition(() => { + const tab = tabs.addSessionTab({ server: ServerConnection.key(conn), sessionId: session.id }) + tabs.select(tab) + }) + }, + archive: async (session: Session) => { + const conn = home.server.focused() + const ctx = home.server.focusedContext() + if (!conn || !ctx) return + const [, setStore] = ctx.sync.child(session.directory) + await archiveHomeSession({ + server: ServerConnection.key(conn), + session, + archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }), + remove: () => + setStore( + produce((draft) => { + const match = Binary.search(draft.session, session.id, (item) => item.id) + if (match.found) draft.session.splice(match.index, 1) + }), + ), + onError: (cause) => + showToast({ + title: language.t("common.requestFailed"), + description: errorMessage(cause, language.t("common.requestFailed")), + }), + }) + }, + }, + tab: { + isOpen: (record: HomeSessionRecord) => + sessionHasOpenTab(tabs.store, home.selection.value().server, record.session), + }, + } +} + +function directories(project: LocalProject) { + return [project.worktree, ...(project.sandboxes ?? [])] +} + +function buildHomeSessionRecords(input: { + sessions: () => Session[] + projectDirectories: () => string[] + projects: () => LocalProject[] + projectByID: () => Map +}) { + const directories = new Set(input.projectDirectories().map(pathKey)) + const sessions = input.sessions().filter((session) => directories.has(pathKey(session.directory))) + return [...new Map(sessions.map((session) => [session.id, session] as const)).values()] + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .flatMap((session) => { + const directory = pathKey(session.directory) + const project = + input + .projects() + .find( + (item) => + pathKey(item.worktree) === directory || item.sandboxes?.some((sandbox) => pathKey(sandbox) === directory), + ) ?? projectForSession(session, input.projects(), input.projectByID()) + if (!project) return [] + return { session, project, projectName: displayName(project) } + }) +} + +export function homeSessionSearchKey(record: HomeSessionRecord) { + return `${pathKey(record.session.directory)}:${record.session.id}` +} + +function groupSessions(records: HomeSessionRecord[], language: ReturnType): HomeSessionGroup[] { + const now = DateTime.local() + const yesterday = now.minus({ days: 1 }) + const todaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(now, "day"), + ) + const yesterdaySessions = records.filter((record) => + DateTime.fromMillis(record.session.time.updated ?? record.session.time.created).hasSame(yesterday, "day"), + ) + const olderSessions = records.filter((record) => { + const time = DateTime.fromMillis(record.session.time.updated ?? record.session.time.created) + return !time.hasSame(now, "day") && !time.hasSame(yesterday, "day") + }) + const olderTitle = + todaySessions.length === 0 && yesterdaySessions.length === 0 + ? language.t("sidebar.project.recentSessions") + : language.t("home.sessions.group.older") + return [ + { id: "today" as const, title: language.t("home.sessions.group.today"), sessions: todaySessions }, + { id: "yesterday" as const, title: language.t("home.sessions.group.yesterday"), sessions: yesterdaySessions }, + { id: "older" as const, title: olderTitle, sessions: olderSessions }, + ].filter((group) => group.sessions.length > 0) +} + +export type HomeSessionsController = ReturnType + +export function HomeSessionStatusController(props: { + server: Accessor + record: HomeSessionRecord + isOpenTab: (record: HomeSessionRecord) => boolean + render: (state: { unread: Accessor; loading: Accessor; open: Accessor }) => JSX.Element +}) { + const avatar = useSessionTabAvatarState( + props.server, + () => props.record.session.directory, + () => props.record.session.id, + ) + return props.render({ + unread: avatar.unread, + loading: avatar.loading, + open: () => props.isOpenTab(props.record), + }) +} diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx new file mode 100644 index 0000000000..461322ae43 --- /dev/null +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -0,0 +1,550 @@ +import type { Session } from "@opencode-ai/sdk/v2/client" +import { type Accessor, createMemo, For, Show } from "solid-js" +import { Spinner } from "@opencode-ai/ui/spinner" +import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { useLanguage } from "@/context/language" +import { ServerConnection } from "@/context/server" +import { SessionTabAvatarView } from "@/pages/layout/session-tab-avatar" +import { sessionTitle } from "@/utils/session-title" +import { shouldOpenSessionInBackground } from "../home-session-open" +import { + HomeSessionStatusController, + homeSessionSearchKey, + type HomeSessionGroup, + type HomeSessionRecord, + type OpenSessionOptions, +} from "./home-sessions-controller" + +const SHOW_HOME_SESSION_ARCHIVE = false +const HOME_SECTION_LABEL = "text-v2-text-text-muted [font-weight:440]" +const HOME_SESSION_SEARCH_RESULTS_ID = "home-session-search-results" + +// Middle-click or Cmd+click on macOS (Ctrl+click elsewhere) opens a session +// tab in the background without navigating, matching browser conventions. +function isBackgroundOpen(event: MouseEvent) { + return shouldOpenSessionInBackground({ + button: event.button, + mac: typeof navigator === "object" && /(Mac|iPod|iPhone|iPad)/.test(navigator.platform), + meta: event.metaKey, + ctrl: event.ctrlKey, + shift: event.shiftKey, + alt: event.altKey, + }) +} + +export type HomeSessionsViewProps = { + language: ReturnType + groups: Accessor + loading: Accessor + showProjectName: Accessor + server: Accessor + canCreateSession: Accessor + searchValue: Accessor + searchPlaceholder: Accessor + searchOpen: Accessor + searchLoading: Accessor + searchResults: Accessor + searchActive: Accessor + searchNoResultsLabel: Accessor + titleOpacity: (id: HomeSessionGroup["id"]) => number + isOpenTab: (record: HomeSessionRecord) => boolean + onCreateSession: () => void + onOpenSession: (session: Session, options?: OpenSessionOptions) => void + onArchiveSession: (session: Session) => Promise + onSetHoverTarget: (element: HTMLElement) => void + onSetThumbTrack: (element: HTMLDivElement) => void + onSetContent: (element: HTMLDivElement) => void + onSetHeader: (id: HomeSessionGroup["id"], element: HTMLDivElement) => void + onWheel: (event: WheelEvent) => void + onSetSearchRoot: (element: HTMLDivElement) => void + onSetSearchInput: (element: HTMLInputElement) => void + onSetSearchList: (element: HTMLDivElement) => void + onSearchFocus: () => void + onSearchInput: (value: string) => void + onSearchClose: () => void + onSearchMove: (delta: number) => void + onSearchSelectActive: () => void + onSearchHighlight: (record: HomeSessionRecord) => void + onSearchSelect: (record: HomeSessionRecord, options?: OpenSessionOptions) => void +} + +export function HomeSessionsView(props: HomeSessionsViewProps) { + return ( +
    +
    + + 0 && props.canCreateSession()}> +
    + + {props.language.t("command.session.new")} + +
    +
    +
    + +
    + ) +} + +function HomeSessionLeadingController(props: { + server: HomeSessionsViewProps["server"] + isOpenTab: HomeSessionsViewProps["isOpenTab"] + record: HomeSessionRecord + revealProjectOnHover: boolean +}) { + return ( + ( + + )} + /> + ) +} + +function HomeSessionLeading(props: { + record: HomeSessionRecord + revealProjectOnHover: boolean + open: boolean + unread: boolean + loading: boolean +}) { + return ( +
    + + + +
    + ) +} + +function HomeSessionSearch(props: HomeSessionsViewProps) { + return ( +
    +
    + +
    +
    +
    + + +
    + } + > + 0} + fallback={ +

    + {props.searchNoResultsLabel()} +

    + } + > +
    +

    + {props.language.t("home.sessions.search.sessions")} +

    + +
    + + {(record) => ( + + )} + +
    +
    +
    +
    + +
    +
    +
    + + +
    +
    + ) +} + +function HomeSessionSearchResultRow( + props: HomeSessionsViewProps & { + record: HomeSessionRecord + selected: boolean + }, +) { + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showProjectName = () => props.showProjectName() && props.record.projectName + const key = () => homeSessionSearchKey(props.record) + + return ( + + ) +} + +function HomeSessionGroupHeader(props: { + title: string + titleOpacity: number + onSetRef: (element: HTMLDivElement) => void + elevated?: boolean +}) { + return ( +
    + +
    + ) +} + +function HomeSessionRow(props: HomeSessionsViewProps & { record: HomeSessionRecord }) { + const title = createMemo(() => sessionTitle(props.record.session.title) || props.record.session.id) + const showProjectName = () => props.showProjectName() && props.record.projectName + + return ( +
    + + +
    + + } + aria-label={props.language.t("common.archive")} + onClick={(event) => { + event.preventDefault() + event.stopPropagation() + void props.onArchiveSession(props.record.session) + }} + /> + +
    +
    +
    + ) +} + +function HomeSessionTitle(props: { title: string; showProjectName: boolean; search?: boolean }) { + return ( + + {props.title} + + ) +} + +function HomeSessionProjectName(props: { name: string; search?: boolean }) { + return ( + + {props.name} + + ) +} + +function HomeSessionsEmpty(props: { onNewSession?: () => void; language: ReturnType }) { + return ( +
    +
    + {props.language.t("home.sessions.empty")} +
    +

    + {props.language.t("home.sessions.empty.description")} +

    + + {(onNewSession) => ( + + {props.language.t("command.session.new")} + + )} + +
    + ) +} + +function HomeSessionSkeleton(props: { label: string }) { + return ( +
    +
    + +
    + + ) +} diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx new file mode 100644 index 0000000000..2e3828fd8c --- /dev/null +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -0,0 +1,48 @@ +import type { HomeScrollController } from "./home-scroll-controller" +import type { HomeSessionSearchController } from "./home-session-search-controller" +import type { HomeSessionsController } from "./home-sessions-controller" +import { HomeSessionsView } from "./home-sessions-view" + +export function HomeSessions(props: { + sessions: HomeSessionsController + search: HomeSessionSearchController + scroll: HomeScrollController +}) { + return ( + + ) +} diff --git a/packages/app/src/pages/home/legacy-home.tsx b/packages/app/src/pages/home/legacy-home.tsx new file mode 100644 index 0000000000..0556f7cf60 --- /dev/null +++ b/packages/app/src/pages/home/legacy-home.tsx @@ -0,0 +1,142 @@ +import { DialogSelectServer } from "@/components/dialog-select-server" +import { useDirectoryPicker } from "@/components/directory-picker" +import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" +import { type ServerConnection, useServer } from "@/context/server" +import { useServerSync } from "@/context/server-sync" +import { base64Encode } from "@opencode-ai/core/util/encode" +import { Button } from "@opencode-ai/ui/button" +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Icon } from "@opencode-ai/ui/icon" +import { Logo } from "@opencode-ai/ui/logo" +import { useNavigate } from "@solidjs/router" +import { DateTime } from "luxon" +import { createMemo, For, Match, Switch } from "solid-js" + +export function LegacyHome() { + const sync = useServerSync() + const pickDirectory = useDirectoryPicker() + const dialog = useDialog() + const navigate = useNavigate() + const global = useGlobal() + const server = useServer() + const language = useLanguage() + const homedir = createMemo(() => sync().data.path.home) + const serverUnreachable = createMemo(() => global.servers.health[server.key]?.healthy === false) + const recent = createMemo(() => { + return sync() + .data.project.slice() + .sort((a, b) => (b.time.updated ?? b.time.created) - (a.time.updated ?? a.time.created)) + .slice(0, 5) + }) + + const serverDotClass = createMemo(() => { + const healthy = global.servers.health[server.key]?.healthy + if (healthy === true) return "bg-icon-success-base" + if (healthy === false) return "bg-icon-critical-base" + return "bg-border-weak-base" + }) + + function openProject(conn: ServerConnection.Any, directory: string) { + const serverCtx = global.ensureServerCtx(conn) + serverCtx.projects.open(directory) + serverCtx.projects.touch(directory) + navigate(`/${base64Encode(directory)}`) + } + + function chooseProject() { + if (serverUnreachable()) return + const conn = server.current + if (!conn) return + + const resolve = (result: string | string[] | null) => { + if (Array.isArray(result)) { + result.forEach((directory) => openProject(conn, directory)) + return + } + if (result) openProject(conn, result) + } + + pickDirectory({ + server: conn, + title: language.t("command.project.open"), + multiple: true, + onSelect: resolve, + }) + } + + return ( +
    + + + + 0}> +
    +
    +
    {language.t("home.recentProjects")}
    + +
    +
      + + {(project) => ( + + )} + +
    +
    +
    + +
    +
    {language.t("common.loading")}
    + +
    +
    + +
    + +
    +
    {language.t("home.empty.title")}
    +
    {language.t("home.empty.description")}
    +
    + +
    +
    +
    +
    + ) +} diff --git a/packages/app/src/pages/layout/session-tab-avatar.tsx b/packages/app/src/pages/layout/session-tab-avatar.tsx index 3c776c8671..0902173643 100644 --- a/packages/app/src/pages/layout/session-tab-avatar.tsx +++ b/packages/app/src/pages/layout/session-tab-avatar.tsx @@ -19,16 +19,34 @@ export function SessionTabAvatar(props: { () => props.directory, () => props.sessionId, ) + return ( + + ) +} + +export function SessionTabAvatarView(props: { + project?: LocalProject + directory: string + revealProjectOnHover?: boolean + unread: boolean + loading: boolean +}) { const projectAvatar = () => ( ) return ( - + Date: Fri, 24 Jul 2026 06:07:04 +0000 Subject: [PATCH 26/48] chore: generate --- .../app/src/pages/home/home-projects-view.tsx | 33 +++++++++++-------- 1 file changed, 20 insertions(+), 13 deletions(-) diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx index 4dc39117c3..573b606579 100644 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -106,7 +106,12 @@ export function HomeProjectsView(props: HomeProjectsViewProps) { when={props.projects().length > 0} fallback={} > - +
    } @@ -300,10 +305,11 @@ type HomeProjectsContextMenuProps = { onSetContextMenuOpen: (id: string, open: boolean) => void } -type HomeProjectListProps = HomeProjectsViewProps & HomeProjectsContextMenuProps & { - server: ServerConnection.Any - items: LocalProject[] -} +type HomeProjectListProps = HomeProjectsViewProps & + HomeProjectsContextMenuProps & { + server: ServerConnection.Any + items: LocalProject[] + } function HomeProjectList(props: HomeProjectListProps) { let listRef!: HTMLDivElement @@ -437,14 +443,15 @@ function HomeRecentlyClosedRow( } function HomeProjectRow( - props: HomeProjectsViewProps & HomeProjectsContextMenuProps & { - project: LocalProject - server: ServerConnection.Any - index: () => number - serverSelected: boolean - selected: boolean - unseen: number - }, + props: HomeProjectsViewProps & + HomeProjectsContextMenuProps & { + project: LocalProject + server: ServerConnection.Any + index: () => number + serverSelected: boolean + selected: boolean + unseen: number + }, ) { const platform = usePlatform() const serverUnreachable = () => props.serverHealth(props.server)?.healthy === false From d07323ef5900afb88b35db0fa40741890a3f1c10 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:38:39 +0800 Subject: [PATCH 27/48] feat(app): migrate discovery workflows (#38465) --- .../regression/cross-server-tab-close.spec.ts | 25 ++- .../remote-session-settings.spec.ts | 6 +- .../e2e/regression/remote-tab-busy.spec.ts | 29 ++- .../session-list-path-loading.spec.ts | 4 +- .../regression/session-request-docks.spec.ts | 12 +- .../regression/tab-navigate-mousedown.spec.ts | 25 ++- .../app/src/components/command-palette.ts | 13 +- .../components/dialog-command-palette-v2.tsx | 2 +- .../components/dialog-connect-provider.tsx | 200 ++++++++---------- .../components/dialog-select-directory-v2.tsx | 35 ++- .../components/dialog-select-directory.tsx | 3 +- .../app/src/components/dialog-select-mcp.tsx | 4 +- .../directory-picker-domain.test.ts | 31 ++- .../src/components/directory-picker-domain.ts | 23 +- packages/app/src/components/edit-project.ts | 7 +- .../app/src/components/titlebar-tab-nav.tsx | 3 +- packages/app/src/components/titlebar.tsx | 5 +- packages/app/src/context/file.tsx | 14 +- packages/app/src/context/layout.tsx | 8 +- packages/app/src/context/permission.tsx | 7 +- packages/app/src/pages/layout.tsx | 63 +++--- packages/app/src/utils/server-compat.test.ts | 23 ++ packages/app/src/utils/server-compat.ts | 19 +- .../app/test-browser/command-palette.test.ts | 14 +- 24 files changed, 353 insertions(+), 222 deletions(-) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index 159b5a5067..a8fc81b17c 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -33,7 +34,7 @@ test("closing the active server's last tab opens the remaining server tab", asyn await tabA.locator('[data-slot="tab-close"] button').click() await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) - await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/session/${sessionB.id}`))).toBe(true) + await expect.poll(() => requests.some((url) => url.startsWith(`${serverB}/api/session/${sessionB.id}`))).toBe(true) await expect(page.getByText(sessionB.title).first()).toBeVisible() const sessionBRequests = requests.filter((url) => url.includes(`/session/${sessionB.id}`)) expect(sessionBRequests.every((url) => url.startsWith(serverB))).toBe(true) @@ -84,16 +85,20 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) @@ -116,7 +121,17 @@ async function mockServers(page: Page, requests: string[]) { directory: current.directory, home: current.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 40491c867e..c17ae5c1c6 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -98,7 +98,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: undefined, + directory: directoryA, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, @@ -126,14 +126,14 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => .toEqual([ { origin: serverA, - directory: undefined, + directory: directoryA, sessionID: sessionA.id, permissionID: "permission-background-a", body: { response: "once" }, }, { origin: serverA, - directory: undefined, + directory: directoryA, sessionID: childSessionA.id, permissionID: "permission-background-a-child", body: { response: "once" }, diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index 7692928f9d..faf591e3a1 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -57,11 +58,15 @@ async function mockServers(page: Page) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) - if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") - return json(route, url.origin === serverB ? { [sessionB.id]: { type: "busy" } } : {}) - if (url.pathname === "/session") return json(route, [current]) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route, url.pathname === "/api/event") + if (url.pathname === "/global/health") return json(route, {}, 404) + if (url.pathname === "/api/health") return json(route, { pid: 1 }) + if (url.pathname === "/api/session/active") + return json(route, { data: url.origin === serverB ? { [sessionB.id]: { type: "running" } } : {} }) + if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) + if (url.pathname === `/api/session/${current.id}`) return json(route, { data: currentSession(current) }) + if (url.pathname === `/api/session/${current.id}/message`) return json(route, { data: [], cursor: {} }) if (url.pathname === `/session/${current.id}`) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) if (url.pathname === `/session/${current.id}/message`) return json(route, []) @@ -90,7 +95,17 @@ async function mockServers(page: Page) { directory: current.directory, home: current.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: current.directory, + config: current.directory, + worktree: current.directory, + directory: current.directory, + home: current.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) return json(route, {}) }) } @@ -104,10 +119,10 @@ function json(route: Route, body: unknown, status = 200) { }) } -function sse(route: Route) { +function sse(route: Route, current: boolean) { return route.fulfill({ status: 200, contentType: "text/event-stream", - body: `data: ${JSON.stringify({ payload: { id: "evt_mock_connected", type: "server.connected", properties: {} } })}\n\n`, + body: current ? 'data: {"id":"evt_connected","type":"server.connected","data":{}}\n\n' : ": ok\n\n", }) } diff --git a/packages/app/e2e/regression/session-list-path-loading.spec.ts b/packages/app/e2e/regression/session-list-path-loading.spec.ts index 4a3855122a..3319514df6 100644 --- a/packages/app/e2e/regression/session-list-path-loading.spec.ts +++ b/packages/app/e2e/regression/session-list-path-loading.spec.ts @@ -16,8 +16,8 @@ test("shows loaded sessions before the directory path request resolves", async ( const pathBlocked = new Promise((resolve) => { releasePath = resolve }) - await page.route("**/path?*", async (route) => { - if (!new URL(route.request().url()).searchParams.has("directory")) return route.fallback() + await page.route("**/api/path?*", async (route) => { + if (!new URL(route.request().url()).searchParams.has("location[directory]")) return route.fallback() await pathBlocked return route.fallback() }) diff --git a/packages/app/e2e/regression/session-request-docks.spec.ts b/packages/app/e2e/regression/session-request-docks.spec.ts index 714d6ca96f..5ea9d4f761 100644 --- a/packages/app/e2e/regression/session-request-docks.spec.ts +++ b/packages/app/e2e/regression/session-request-docks.spec.ts @@ -42,7 +42,8 @@ test("shows a pending question dock", async ({ page }) => { const rejectRequests: string[] = [] page.on("request", (request) => { if (request.method() !== "POST") return - if (new URL(request.url()).pathname === "/question/question-request/reject") rejectRequests.push(request.url()) + if (new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reject`) + rejectRequests.push(request.url()) }) await question.locator('[data-component="icon-button"][data-icon="chevron-down"]').click() @@ -64,7 +65,9 @@ test("shows a pending question dock", async ({ page }) => { await question.getByRole("radio", { name: /Minimal/ }).click() const reply = page.waitForRequest( - (request) => request.method() === "POST" && new URL(request.url()).pathname === "/question/question-request/reply", + (request) => + request.method() === "POST" && + new URL(request.url()).pathname === `/api/session/${sessionID}/question/question-request/reply`, ) await question.getByRole("button", { name: "Submit" }).click() expect((await reply).postDataJSON()).toEqual({ answers: [["Minimal"]] }) @@ -97,8 +100,8 @@ test("shows a pending permission dock", async ({ page }) => { const reply = page.waitForRequest((request) => request.method() === "POST") await permission.getByRole("button", { name: "Allow once" }).click() const request = await reply - expect(new URL(request.url()).pathname).toBe(`/session/${sessionID}/permissions/permission-request`) - expect(request.postDataJSON()).toEqual({ response: "once" }) + expect(new URL(request.url()).pathname).toBe(`/api/session/${sessionID}/permission/permission-request/reply`) + expect(request.postDataJSON()).toEqual({ reply: "once" }) }) test("restores the draft caret before typing after a request dock closes", async ({ page }) => { @@ -170,6 +173,7 @@ async function mockServer( }, ) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 94afbc9a9d..4136c16d01 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -1,5 +1,6 @@ import { expect, test, type Page, type Route } from "@playwright/test" import { base64Encode } from "@opencode-ai/core/util/encode" +import { currentSession } from "../utils/mock-server" const server = "http://127.0.0.1:4096" const sessionA = session("ses_tab_a", "Tab A session") @@ -56,9 +57,14 @@ async function mockServer(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session") return json(route, sessions) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((item) => url.pathname === `/api/session/${item.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((item) => url.pathname === `/api/session/${item.id}/message`)) + return json(route, { data: [], cursor: {} }) const byId = sessions.find((item) => url.pathname === `/session/${item.id}`) if (byId) return json(route, byId) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -66,7 +72,7 @@ async function mockServer(page: Page) { if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp", "/session/status"].includes(url.pathname)) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) @@ -89,7 +95,20 @@ async function mockServer(page: Page) { directory: sessionA.directory, home: sessionA.directory, }) + if (url.pathname === "/api/path") + return json(route, { + state: sessionA.directory, + config: sessionA.directory, + worktree: sessionA.directory, + directory: sessionA.directory, + home: sessionA.directory, + }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { + location: { directory: sessionA.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) return json(route, {}) }) } diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 59d3cbd1da..487d823550 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -1,5 +1,6 @@ import { getFilename } from "@opencode-ai/core/util/path" -import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" import { useDialog } from "@opencode-ai/ui/context/dialog" import { createMemo, onCleanup } from "solid-js" import { commandPaletteOptions, useCommand, type CommandOption } from "@/context/command" @@ -13,6 +14,7 @@ import { useTabs } from "@/context/tabs" import { displayName, projectForSession } from "@/pages/layout/helpers" import { createSessionTabs } from "@/pages/session/helpers" import { useSessionLayout } from "@/pages/session/session-layout" +import { normalizeSessionInfo } from "@/utils/session" export type CommandPaletteEntry = { id: string @@ -145,7 +147,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, load: (search, signal) => - serverSDK.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) @@ -219,7 +221,7 @@ export function createServerSessionEntries(props: { server: ServerConnection.Key opened: () => LocalProject[] stored: () => Project[] - load: (search: string, signal: AbortSignal) => Promise<{ data?: GlobalSession[] }> + load: (search: string, signal: AbortSignal) => Promise<{ data: SessionInfo[] }> untitled: () => string category: () => string }) { @@ -255,7 +257,8 @@ export function createServerSessionEntries(props: { return props .load(search, current.signal) .then((result) => - (result.data ?? []) + result.data + .map(normalizeSessionInfo) .filter((session) => !session.time.archived) .map((session) => { const project = @@ -264,7 +267,7 @@ export function createServerSessionEntries(props: { id: `session:${props.server}:${session.id}`, type: "session" as const, title: session.title || props.untitled(), - description: project ? displayName(project) : session.project?.name || getFilename(session.directory), + description: project ? displayName(project) : getFilename(session.directory), category: props.category(), directory: session.directory, sessionID: session.id, diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index 85ca44ae69..c23b703e52 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -80,7 +80,7 @@ export function DialogHomeCommandPaletteV2(props: { opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, load: (search, signal) => - serverCtx.sdk.client.experimental.session.list({ roots: true, search, limit: 50 }, { signal }), + serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 4c58857249..93a62acb61 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,4 +1,7 @@ -import type { ProviderAuthAuthorization, ProviderAuthMethod } from "@opencode-ai/sdk/v2/client" +import type { + IntegrationMethod, + IntegrationOauthConnectOutput, +} from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -28,6 +31,8 @@ import { Switch, } from "solid-js" import { createStore, produce } from "solid-js/store" +import { useQueryClient } from "@tanstack/solid-query" +import { useParams } from "@solidjs/router" import { Link } from "@/components/link" import { useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -35,8 +40,11 @@ import { useLanguage } from "@/context/language" import { useSettings } from "@/context/settings" import { popularProviders, useProviders } from "@/hooks/use-providers" import { CustomProviderForm } from "./dialog-custom-provider" +import { decode64 } from "@/utils/base64" +import { pathKey } from "@/utils/path-key" const CUSTOM_ID = "_custom" +type ConnectMethod = Extract export function useProviderConnectController(options: { onBack?: () => void } = {}) { const [store, setStore] = createStore({ selected: undefined as string | undefined }) @@ -228,8 +236,6 @@ function ProviderPickerV2(props: { }) { const providers = useProviders(props.directory) const language = useLanguage() - const serverSync = useServerSync() - const serverSDK = useServerSDK() const [store, setStore] = createStore({ filter: "", active: undefined as string | undefined, @@ -266,19 +272,7 @@ function ProviderPickerV2(props: { const connect = (provider: string) => { props.onPrepare?.() - if (provider === CUSTOM_ID || serverSync().data.provider_auth[provider]) { - props.onSelect(provider) - return - } - if (store.connecting) return - setStore("connecting", provider) - void serverSDK() - .client.provider.auth() - .then((response) => { - serverSync().set("provider_auth", response.data ?? {}) - props.onSelect(provider) - }) - .catch(() => props.onSelect(provider)) + props.onSelect(provider) } const move = (event: KeyboardEvent, direction: number) => { @@ -395,10 +389,17 @@ function ProviderConnection(props: { const dialog = useDialog() const serverSync = useServerSync() const serverSDK = useServerSDK() + const queryClient = useQueryClient() + const params = useParams() const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns const providers = useProviders(props.directory) + const directory = () => props.directory?.() ?? decode64(params.dir) + const location = () => { + const value = directory() + return value ? { directory: value } : undefined + } const alive = { value: true } const timer = { current: undefined as ReturnType | undefined } @@ -413,38 +414,34 @@ function ProviderConnection(props: { const provider = createMemo( () => providers.all().get(props.provider) ?? serverSync().data.provider.all.get(props.provider)!, ) - const fallback = createMemo(() => [ + const fallback = createMemo(() => [ { - type: "api" as const, + type: "key" as const, label: language.t("provider.connect.method.apiKey"), }, ]) - const [auth] = createResource( - () => props.provider, - async () => { - const cached = serverSync().data.provider_auth[props.provider] - if (cached) return cached - const res = await serverSDK().client.provider.auth() - if (!alive.value) return fallback() - serverSync().set("provider_auth", res.data ?? {}) - return res.data?.[props.provider] ?? fallback() - }, + const [integration] = createResource( + () => ({ provider: props.provider, directory: directory() }), + (input) => + serverSDK() + .api.integration.get({ + integrationID: input.provider, + location: input.directory ? { directory: input.directory } : undefined, + }) + .then((result) => result.data), ) - const loading = createMemo(() => auth.loading && !serverSync().data.provider_auth[props.provider]) - const methods = createMemo(() => auth.latest ?? serverSync().data.provider_auth[props.provider] ?? fallback()) - const cachedMethods = serverSync().data.provider_auth[props.provider] - const directMethod = - cachedMethods?.length === 1 && cachedMethods[0].type === "api" && !cachedMethods[0].prompts?.length ? 0 : undefined + const loading = createMemo(() => integration.loading) + const methods = createMemo(() => { + const values = integration.latest?.methods.filter( + (method): method is ConnectMethod => method.type === "key" || method.type === "oauth", + ) + return values?.length ? values : fallback() + }) const [store, setStore] = createStore({ - methodIndex: directMethod as undefined | number, - authorization: undefined as undefined | ProviderAuthAuthorization, + methodIndex: undefined as undefined | number, + authorization: undefined as undefined | IntegrationOauthConnectOutput["data"], promptInputs: undefined as undefined | Record, - state: (directMethod === undefined ? "pending" : undefined) as - | undefined - | "pending" - | "complete" - | "error" - | "prompt", + state: "pending" as undefined | "pending" | "complete" | "error" | "prompt", error: undefined as string | undefined, }) @@ -454,7 +451,7 @@ function ProviderConnection(props: { | { type: "auth.prompt" } | { type: "auth.inputs"; inputs: Record } | { type: "auth.pending" } - | { type: "auth.complete"; authorization: ProviderAuthAuthorization } + | { type: "auth.complete"; authorization: IntegrationOauthConnectOutput["data"] } | { type: "auth.error"; error: string } function dispatch(action: Action) { @@ -508,7 +505,7 @@ function ProviderConnection(props: { const methodLabel = (value?: { type?: string; label?: string }) => { if (!value) return "" - if (value.type === "api") return language.t("provider.connect.method.apiKey") + if (value.type === "key") return language.t("provider.connect.method.apiKey") return value.label ?? "" } @@ -518,7 +515,7 @@ function ProviderConnection(props: { const hint = suffix?.[1] return { label: suffix ? label.slice(0, -suffix[0].length) : label, - hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "api" ? "Browser" : undefined, + hint: hint ? hint[0].toUpperCase() + hint.slice(1) : value?.type === "key" ? "Browser" : undefined, } } @@ -549,46 +546,22 @@ function ProviderConnection(props: { const method = methods()[index] dispatch({ type: "method.select", index }) - if (method.type === "api" && method.prompts?.length) { - if (!inputs) { - dispatch({ type: "auth.prompt" }) - return - } - dispatch({ type: "auth.inputs", inputs }) - return - } - if (method.type === "oauth") { if (method.prompts?.length && !inputs) { dispatch({ type: "auth.prompt" }) return } dispatch({ type: "auth.pending" }) - const start = Date.now() await serverSDK() - .client.provider.oauth.authorize( - { - providerID: props.provider, - method: index, - inputs, - }, - { throwOnError: true }, - ) + .api.integration.oauth.connect({ + integrationID: props.provider, + methodID: method.id, + inputs: inputs ?? {}, + location: location(), + }) .then((x) => { if (!alive.value) return - const elapsed = Date.now() - start - const delay = 1000 - elapsed - - if (delay > 0) { - if (timer.current !== undefined) clearTimeout(timer.current) - timer.current = setTimeout(() => { - timer.current = undefined - if (!alive.value) return - dispatch({ type: "auth.complete", authorization: x.data! }) - }, delay) - return - } - dispatch({ type: "auth.complete", authorization: x.data! }) + dispatch({ type: "auth.complete", authorization: x.data }) }) .catch((e) => { if (!alive.value) return @@ -603,9 +576,9 @@ function ProviderConnection(props: { index: 0, }) - const prompts = createMemo>(() => { + const prompts = createMemo(() => { const value = method() - return value?.prompts ?? [] + return value?.type === "oauth" ? (value.prompts ?? []) : [] }) const matches = (prompt: NonNullable[number]>, value: Record) => { if (!prompt.when) return true @@ -636,10 +609,6 @@ function ProviderConnection(props: { setFormStore("index", next) return } - if (method()?.type === "api") { - dispatch({ type: "auth.inputs", inputs: value }) - return - } await selectMethod(store.methodIndex, value) } @@ -741,7 +710,10 @@ function ProviderConnection(props: { }) async function complete() { - await serverSDK().client.global.dispose() + const value = directory() + await queryClient + .refetchQueries(serverSync().queryOptions.providers(value ? pathKey(value) : null)) + .catch(() => undefined) dialog.close() showToast({ variant: "success", @@ -805,7 +777,7 @@ function ProviderConnection(props: { listRef = ref }} items={methods} - key={(m) => m?.label} + key={(m) => m?.label ?? m?.type} onSelect={async (selected, index) => { if (!selected) return void selectMethod(index) @@ -851,13 +823,10 @@ function ProviderConnection(props: { } setFormStore("error", undefined) - await serverSDK().client.auth.set({ - providerID: props.provider, - auth: { - type: "api", - key: apiKey, - ...(store.promptInputs ? { metadata: store.promptInputs } : {}), - }, + await serverSDK().api.integration.connect.key({ + integrationID: props.provider, + location: location(), + key: apiKey, }) await complete() } @@ -984,12 +953,13 @@ function ProviderConnection(props: { setFormStore("error", undefined) const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.complete({ + integrationID: props.provider, + attemptID: store.authorization!.attemptID, + location: location(), code, }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then(() => ({ ok: true as const })) .catch((error) => ({ ok: false as const, error })) if (result.ok) { await complete() @@ -1076,25 +1046,37 @@ function ProviderConnection(props: { }) onMount(() => { - void (async () => { + const poll = async () => { + const authorization = store.authorization + if (!authorization || !alive.value) return const result = await serverSDK() - .client.provider.oauth.callback({ - providerID: props.provider, - method: store.methodIndex, + .api.integration.oauth.status({ + integrationID: props.provider, + attemptID: authorization.attemptID, + location: location(), }) - .then((value) => (value.error ? { ok: false as const, error: value.error } : { ok: true as const })) + .then((value) => ({ ok: true as const, status: value.data })) .catch((error) => ({ ok: false as const, error })) - if (!alive.value) return - if (!result.ok) { - const message = formatError(result.error, language.t("common.requestFailed")) - dispatch({ type: "auth.error", error: message }) + dispatch({ type: "auth.error", error: formatError(result.error, language.t("common.requestFailed")) }) return } - - await complete() - })() + if (result.status.status === "complete") { + await complete() + return + } + if (result.status.status === "failed") { + dispatch({ type: "auth.error", error: result.status.message }) + return + } + if (result.status.status === "expired") { + dispatch({ type: "auth.error", error: language.t("common.requestFailed") }) + return + } + timer.current = setTimeout(poll, 1_000) + } + void poll() }) return ( @@ -1178,15 +1160,15 @@ function ProviderConnection(props: {
    - + - + - + diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 69d46ddcb6..a457c9a2f5 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -28,6 +28,7 @@ import { } from "./directory-picker-domain" import "./dialog-select-directory-v2.css" import { DividerV2 } from "@opencode-ai/ui/v2/divider-v2" +import { getFilename } from "@opencode-ai/core/util/path" interface DialogSelectDirectoryV2Props { title?: string @@ -68,9 +69,8 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), () => - sdk.client.path + sdk.api.path .get() - .then((result) => result.data) .catch(() => undefined), { initialValue: undefined }, ) @@ -85,18 +85,26 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { ) const search = createDirectorySearch({ sdk, home, base: () => root() || start() }) const [suggestions] = createResource(input, async (value) => { - const typed = cleanPickerInput(value).replace(/\/+$/, "") + const cleaned = cleanPickerInput(value) + const typed = cleaned.replace(/\/+$/, "") const current = displayPickerPath(root(), value, home()).replace(/\/+$/, "") - if (!typed || typed === current) return { query: value, items: [] } + if (!cleaned || (root() && typed === current)) return { query: value, items: [] } const directories = (await search(value)).map((absolute) => ({ absolute, type: "directory" as const })) if (!policy.includeFiles) return { query: value, items: directories.slice(0, 5) } - const files = await sdk.client.find - .files({ directory: root(), query: pickerFileSearchQuery(root(), value, home()), type: "file", limit: 20 }) - .then((result) => result.data ?? []) + const base = pickerRoot(cleaned) || root() || start() + if (!base) return { query: value, items: directories.slice(0, 5) } + const files = await sdk.api.file + .find({ + location: { directory: base }, + query: pickerFileSearchQuery(base, value, home()), + type: "file", + limit: 20, + }) + .then((result) => result.data) .catch(() => []) const results = [ ...directories, - ...files.map((path) => ({ absolute: absoluteTreePath(root(), path), type: "file" as const })), + ...files.map((entry) => ({ absolute: absoluteTreePath(base, entry.path), type: "file" as const })), ] return { query: value, @@ -115,9 +123,14 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { existing ?? loads.schedule(`${generation}:${key}`, eager ? "background" : "user", () => { if (!activeTreeNavigation(generation, navigation)) return Promise.resolve(undefined) - return sdk.client.file - .list({ directory: absolute, path: "" }) - .then((result) => result.data ?? []) + return sdk.api.file + .list({ location: { directory: absolute } }) + .then((result) => + result.data.map((entry) => ({ + name: getFilename(entry.path.replace(/[\\/]+$/, "")), + type: entry.type, + })), + ) .catch(() => undefined) }) listings.set(key, request) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 8ba09a9f90..80ac070750 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -60,9 +60,8 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async () => { - return sdk.client.path + return sdk.api.path .get() - .then((x) => x.data) .catch(() => undefined) }, { initialValue: undefined }, diff --git a/packages/app/src/components/dialog-select-mcp.tsx b/packages/app/src/components/dialog-select-mcp.tsx index 05253381f0..4f1a3cd239 100644 --- a/packages/app/src/components/dialog-select-mcp.tsx +++ b/packages/app/src/components/dialog-select-mcp.tsx @@ -43,7 +43,7 @@ export const DialogSelectMcp: Component = () => { filterKeys={["name", "status"]} sortBy={(a, b) => a.name.localeCompare(b.name)} onSelect={(x) => { - if (!x || toggle.isPending) return + if (!x || x.status === "pending" || toggle.isPending) return toggle.mutate(x.name) }} > @@ -76,7 +76,7 @@ export const DialogSelectMcp: Component = () => {
    e.stopPropagation()}> { if (toggle.isPending) return toggle.mutate(i.name) diff --git a/packages/app/src/components/directory-picker-domain.test.ts b/packages/app/src/components/directory-picker-domain.test.ts index 5746410610..1bc9af0833 100644 --- a/packages/app/src/components/directory-picker-domain.test.ts +++ b/packages/app/src/components/directory-picker-domain.test.ts @@ -133,10 +133,10 @@ test("scopes file autocomplete to the current browser root", () => { test("resolves directory autocomplete from the current browser root", async () => { const directories: string[] = [] const sdk = { - client: { - find: { - files: (input: { directory: string }) => { - directories.push(input.directory) + api: { + file: { + find: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") return Promise.resolve({ data: [] }) }, }, @@ -152,6 +152,29 @@ test("resolves directory autocomplete from the current browser root", async () = expect(directories).toEqual(["/repo", "/repo/src"]) }) +test("searches from an absolute root without a default base", async () => { + const directories: string[] = [] + const sdk = { + api: { + file: { + list: (input: { location?: { directory?: string } }) => { + directories.push(input.location?.directory ?? "") + return Promise.resolve({ + data: [ + { path: "Users/", type: "directory" }, + { path: "tmp/", type: "directory" }, + ], + }) + }, + }, + }, + } as unknown as Parameters[0]["sdk"] + const search = createDirectorySearch({ sdk, home: () => "", base: () => undefined }) + + expect(await search("/")).toEqual(["/Users", "/tmp"]) + expect(directories).toEqual(["/"]) +}) + test("identifies the next directory level to preload", () => { expect( preloadTreeDirectories("src/", [ diff --git a/packages/app/src/components/directory-picker-domain.ts b/packages/app/src/components/directory-picker-domain.ts index 9900265962..9539ae1d01 100644 --- a/packages/app/src/components/directory-picker-domain.ts +++ b/packages/app/src/components/directory-picker-domain.ts @@ -326,15 +326,15 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string let current = 0 const scoped = (value: string) => { + const raw = normalizePickerDrive(value) + const root = pickerRoot(raw) + if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } const base = args.base() if (!base) return - const raw = normalizePickerDrive(value) if (!raw) return { directory: trimPickerPath(base), path: "" } const home = args.home() if (raw === "~") return { directory: trimPickerPath(home || base), path: "" } if (raw.startsWith("~/")) return { directory: trimPickerPath(home || base), path: raw.slice(2) } - const root = pickerRoot(raw) - if (root) return { directory: trimPickerPath(root), path: raw.slice(root.length) } return { directory: trimPickerPath(base), path: raw } } @@ -342,14 +342,17 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const key = trimPickerPath(directory) const existing = cache.get(key) if (existing) return existing - const request = args.sdk.client.file - .list({ directory: key, path: "" }) - .then((result) => result.data ?? []) + const request = args.sdk.api.file + .list({ location: { directory: key } }) + .then((result) => result.data) .catch(() => []) .then((nodes) => nodes .filter((node) => node.type === "directory") - .map((node) => ({ name: node.name, absolute: trimPickerPath(normalizePickerDrive(node.absolute)) })), + .map((node) => { + const relative = trimPickerPath(normalizePickerDrive(node.path)) + return { name: getFilename(relative), absolute: joinPickerPath(key, relative) } + }), ) cache.set(key, request) return request @@ -371,9 +374,9 @@ export function createDirectorySearch(args: { sdk: ServerSDK; base: () => string const pathInput = raw.startsWith("~") || !!pickerRoot(raw) || raw.includes("/") const query = normalizePickerDrive(input.path) if (!pathInput) { - const results = await args.sdk.client.find - .files({ directory: input.directory, query, type: "directory", limit: 50 }) - .then((result) => result.data ?? []) + const results = await args.sdk.api.file + .find({ location: { directory: input.directory }, query, type: "directory", limit: 50 }) + .then((result) => result.data.map((entry) => entry.path)) .catch(() => []) if (!active()) return [] return results.map((path) => joinPickerPath(input.directory, path)).slice(0, 50) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts index 3ec999da06..42053f6eff 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -1,6 +1,7 @@ import { getFilename } from "@opencode-ai/core/util/path" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useMutation } from "@tanstack/solid-query" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { createMemo } from "solid-js" import { createStore } from "solid-js/store" import { useGlobal } from "@/context/global" @@ -70,13 +71,15 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - await serverCtx().sdk.client.project.update({ + const project = await serverCtx().sdk.api.project.update({ projectID: props.project.id, - directory: props.project.worktree, name, icon: { color: store.color || "", override: store.iconOverride || "" }, commands: { start }, }) + serverCtx().sync.set("project", (items) => + items.map((item) => (item.id === project.id ? normalizeProjectInfo(project) : item)), + ) serverCtx().sync.project.icon(props.project.worktree, store.iconOverride || undefined) dialog.close() return diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index a397046f9b..3058e6881a 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -120,8 +120,7 @@ export function TabNavItem(props: { const ctx = serverCtx() const session = props.session() if (!ctx || !session) return - const client = ctx.sdk.createClient({ directory: session.directory, throwOnError: true }) - await client.session.update({ sessionID: session.id, title }) + await ctx.sdk.api.session.rename({ sessionID: session.id, title }) } const closeRename = async (save: boolean) => { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 4578656107..aa2f220e49 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -28,6 +28,7 @@ import { tabKey, useTabs } from "@/context/tabs" import type { PromptSession } from "@/context/prompt" import "./titlebar.css" import { newTabTooltipKeybind } from "./command-tooltip-keybind" +import { normalizeSessionInfo } from "@/utils/session" type TauriDesktopWindow = { startDragging?: () => Promise @@ -267,9 +268,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return conn ? { route, sdk: global.ensureServerCtx(conn).sdk } : undefined }, ({ route, sdk }) => - sdk.client.session + sdk.api.session .get({ sessionID: route.sessionId }) - .then((x) => x.data) + .then(normalizeSessionInfo) .catch(() => {}), ) diff --git a/packages/app/src/context/file.tsx b/packages/app/src/context/file.tsx index 6032b81dde..fbbef3a2a8 100644 --- a/packages/app/src/context/file.tsx +++ b/packages/app/src/context/file.tsx @@ -204,10 +204,18 @@ export const { use: useFile, provider: FileProvider } = createSimpleContext({ } const search = (query: string, dirs: "true" | "false", options?: { limit?: number; signal?: AbortSignal }) => - sdk() - .client.find.files({ query, dirs, limit: options?.limit }, { signal: options?.signal }) + serverSDK() + .api.file.find( + { + location: { directory: sdk().directory }, + query, + type: dirs === "true" ? "directory" : "file", + limit: options?.limit, + }, + { signal: options?.signal }, + ) .then( - (x) => (x.data ?? []).map(path.normalize), + (x) => x.data.map((entry) => path.normalize(entry.path)), (error) => { if (options?.signal?.aborted) throw error return [] diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index 7ac248dc1d..c039b3d482 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -8,6 +8,7 @@ import { useServerSDK } from "./server-sdk" import { RECENTLY_CLOSED_DISPLAY_LIMIT, ServerConnection, useServer } from "./server" import { usePlatform } from "./platform" import { Project } from "@opencode-ai/sdk/v2" +import { normalizeProjectInfo } from "./global-sync/utils" import { Persist, persisted, removePersisted } from "@/utils/persist" import { pathKey } from "@/utils/path-key" import { decode64 } from "@/utils/base64" @@ -570,7 +571,12 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( } void serverSdk() - .client.project.update({ projectID: project.id, directory: worktree, icon: { color } }) + .api.project.update({ projectID: project.id, icon: { color } }) + .then((result) => + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ), + ) .catch(() => { if (colorRequested.get(worktree) === color) colorRequested.delete(worktree) }) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 388e4534a1..3ed91e60bf 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -245,7 +245,12 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } const respond: PermissionRespondFn = (request) => { if (meta.disposed) return input.sdk.api.permission - .reply({ sessionID: request.sessionID, requestID: request.permissionID, reply: request.response }) + .reply({ + sessionID: request.sessionID, + requestID: request.permissionID, + reply: request.response, + location: request.directory ? { directory: request.directory } : undefined, + }) .catch(() => { responded.delete(request.permissionID) }) diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 812a479b20..5947442318 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -36,6 +36,7 @@ import { useProviders } from "@/hooks/use-providers" import { toaster } from "@opencode-ai/ui/toast" import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" +import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" import { pickSessionCacheEvictions } from "@/context/global-sync/session-cache" import { useNotification } from "@/context/notification" @@ -48,6 +49,7 @@ import { setNavigate } from "@/utils/notification-click" import { Worktree as WorktreeState } from "@/utils/worktree" import { setSessionHandoff } from "@/pages/session/handoff" import { SessionRouteKey, SessionStateKey } from "@/utils/server-scope" +import { listAllSessions } from "@/utils/session" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" @@ -875,11 +877,7 @@ export default function LegacyLayout(props: ParentProps) { const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] - await serverSDK().client.session.update({ - directory: session.directory, - sessionID: session.id, - time: { archived: Date.now() }, - }) + await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory }) setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) @@ -1185,9 +1183,12 @@ export default function LegacyLayout(props: ParentProps) { } const refreshDirs = async (target?: string) => { if (!target || target === root || canOpen(target)) return canOpen(target) - const listed = await serverSDK() - .client.worktree.list({ directory: root }) - .then((x) => x.data ?? []) + const listed = await Promise.resolve( + project?.id ?? serverSDK().api.project.current({ location: { directory: root } }), + ) + .then((value) => (typeof value === "string" ? value : value.id)) + .then((projectID) => serverSDK().api.project.directories({ projectID, location: { directory: root } })) + .then((items) => items.map((item) => item.directory).filter((item) => pathKey(item) !== pathKey(root))) .catch(() => [] as string[]) dirs = effectiveWorkspaceOrder(root, [root, ...listed], store.workspaceOrder[root]) return canOpen(target) @@ -1231,10 +1232,11 @@ export default function LegacyLayout(props: ParentProps) { await Promise.all( dirs.map(async (item) => ({ path: { directory: item }, - session: await serverSDK() - .client.session.list({ directory: item }) - .then((x) => x.data ?? []) - .catch(() => []), + session: await listAllSessions(serverSDK().api.session, { + directory: item, + parentID: null, + order: "desc", + }).catch(() => []), })), ), Date.now(), @@ -1294,7 +1296,10 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - await serverSDK().client.project.update({ projectID: project.id, directory: project.worktree, name }) + const result = await serverSDK().api.project.update({ projectID: project.id, name }) + serverSync().set("project", (items) => + items.map((item) => (item.id === result.id ? normalizeProjectInfo(result) : item)), + ) return } @@ -1445,10 +1450,7 @@ export default function LegacyLayout(props: ParentProps) { }) const dismiss = () => toaster.dismiss(progress) - const sessions: Session[] = await serverSDK() - .client.session.list({ directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) clearWorkspaceTerminals( directory, @@ -1477,17 +1479,12 @@ export default function LegacyLayout(props: ParentProps) { return } - const archivedAt = Date.now() await Promise.all( sessions .filter((session) => session.time.archived === undefined) .map((session) => serverSDK() - .client.session.update({ - sessionID: session.id, - directory: session.directory, - time: { archived: archivedAt }, - }) + .api.session.archive({ sessionID: session.id, directory: session.directory }) .catch(() => undefined), ), ) @@ -1524,9 +1521,9 @@ export default function LegacyLayout(props: ParentProps) { onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setData({ status: "ready", dirty }) }) @@ -1582,19 +1579,19 @@ export default function LegacyLayout(props: ParentProps) { }) const refresh = async () => { - const sessions = await serverSDK() - .client.session.list({ directory: props.directory }) - .then((x) => x.data ?? []) - .catch(() => []) + const sessions = await listAllSessions(serverSDK().api.session, { + directory: props.directory, + order: "desc", + }).catch(() => []) const active = sessions.filter((session) => session.time.archived === undefined) setState({ sessions: active }) } onMount(() => { serverSDK() - .client.vcs.status({ directory: props.directory }) - .then((x) => { - const files = x.data ?? [] + .api.vcs.status({ location: { directory: props.directory } }) + .then((result) => { + const files = result.data const dirty = files.length > 0 setState({ status: "ready", dirty }) void refresh() diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 908664cdec..f46c5f86e0 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -123,4 +123,27 @@ describe("createCompatibleApi", () => { data: { branch: "feature", defaultBranch: "dev" }, }) }) + + test("translates current file searches to the V1 dirs parameter", async () => { + const { api, requests } = setup("v1") + await api.file.find({ location: { directory: "/repo" }, query: "src", type: "file", limit: 20 }) + + const url = new URL(requests[0]!.url) + expect(url.pathname).toBe("/find/file") + expect(url.searchParams.get("dirs")).toBe("false") + expect(url.searchParams.get("limit")).toBe("20") + }) + + test("routes V1 permission replies through the requested directory", async () => { + const { api, requests } = setup("v1") + await api.permission.reply({ + sessionID: "ses_1", + requestID: "permission_1", + reply: "once", + location: { directory: "/other" }, + }) + + expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/permissions/permission_1") + expect(new URL(requests[0]!.url).searchParams.get("directory")).toBe("/other") + }) }) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 88282742b9..7070c5222a 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -30,7 +30,15 @@ type CompatibleSessionApi = Omit< archive: (input: Parameters[0] & LegacyLocation) => ReturnType remove: (input: Parameters[0] & LegacyLocation) => ReturnType } -export type CompatibleApi = Omit & { readonly session: CompatibleSessionApi } +type CompatiblePermissionApi = Omit & { + reply: ( + input: Parameters[0] & { location?: { directory?: string } }, + ) => ReturnType +} +export type CompatibleApi = Omit & { + readonly session: CompatibleSessionApi + readonly permission: CompatiblePermissionApi +} type LegacyPrompt = { agent?: string model?: { providerID: string; modelID: string } @@ -350,7 +358,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { async find(value: Parameters[0]) { const result = await legacy(value.location).find.files({ query: value.query, - type: value.type, + dirs: value.type === undefined ? undefined : value.type === "directory" ? "true" : "false", limit: value.limit, }) return located( @@ -471,11 +479,14 @@ function createV1Api(input: CompatibleInput): CompatibleApi { }, permission: { ...input.current.permission, - async reply(value: Parameters[0]) { - await legacy().permission.respond({ + async reply( + value: Parameters[0] & { location?: { directory?: string } }, + ) { + await legacy(value.location).permission.respond({ sessionID: value.sessionID, permissionID: value.requestID, response: value.reply, + directory: directory(value.location), }) }, }, diff --git a/packages/app/test-browser/command-palette.test.ts b/packages/app/test-browser/command-palette.test.ts index 421a2e71fd..6a74834fd0 100644 --- a/packages/app/test-browser/command-palette.test.ts +++ b/packages/app/test-browser/command-palette.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" -import type { GlobalSession, Project } from "@opencode-ai/sdk/v2/client" +import type { Project } from "@opencode-ai/sdk/v2/client" +import type { SessionInfo } from "@opencode-ai/client/promise" import { createRoot } from "solid-js" import { createServerSessionEntries } from "@/components/command-palette" import type { LocalProject } from "@/context/layout" @@ -14,15 +15,16 @@ const stored: Project = { time: { created: 1, updated: 1 }, } -const session: GlobalSession = { +const session: SessionInfo = { id: "session-1", - slug: "session-1", projectID: stored.id, - directory: stored.worktree, + agent: "build", + model: { id: "model-1", providerID: "provider-1" }, + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + location: { directory: stored.worktree }, title: "Palette session", - version: "1", time: { created: 1, updated: 2 }, - project: { id: stored.id, name: stored.name, worktree: stored.worktree }, } describe("command palette sessions", () => { From 2ea4bb793ec9240251b39706fb5564039023fd79 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 06:40:05 +0000 Subject: [PATCH 28/48] chore: generate --- .../app/e2e/regression/cross-server-tab-close.spec.ts | 11 +++++++---- packages/app/e2e/regression/remote-tab-busy.spec.ts | 5 ++++- .../app/e2e/regression/tab-navigate-mousedown.spec.ts | 6 +++--- packages/app/src/components/command-palette.ts | 3 +-- .../app/src/components/dialog-command-palette-v2.tsx | 3 +-- .../app/src/components/dialog-connect-provider.tsx | 5 +---- .../app/src/components/dialog-select-directory-v2.tsx | 5 +---- .../app/src/components/dialog-select-directory.tsx | 4 +--- packages/app/src/utils/server-compat.ts | 4 +--- 9 files changed, 20 insertions(+), 26 deletions(-) diff --git a/packages/app/e2e/regression/cross-server-tab-close.spec.ts b/packages/app/e2e/regression/cross-server-tab-close.spec.ts index a8fc81b17c..f09a2c7b63 100644 --- a/packages/app/e2e/regression/cross-server-tab-close.spec.ts +++ b/packages/app/e2e/regression/cross-server-tab-close.spec.ts @@ -85,7 +85,8 @@ async function mockServers(page: Page, requests: string[]) { const current = url.origin === serverA ? sessionA : sessionB const directory = url.searchParams.get("directory") if (directory && directory !== current.directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) if (url.pathname === "/global/health") return json(route, {}, 404) if (url.pathname === "/api/health") return json(route, { pid: 1 }) if (url.pathname === "/api/session") return json(route, { data: [currentSession(current)], cursor: {} }) @@ -98,8 +99,7 @@ async function mockServers(page: Page, requests: string[]) { if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) - return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) @@ -131,7 +131,10 @@ async function mockServers(page: Page, requests: string[]) { }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/api/vcs") - return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) + return json(route, { + location: { directory: current.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/remote-tab-busy.spec.ts b/packages/app/e2e/regression/remote-tab-busy.spec.ts index faf591e3a1..2d9b1e2349 100644 --- a/packages/app/e2e/regression/remote-tab-busy.spec.ts +++ b/packages/app/e2e/regression/remote-tab-busy.spec.ts @@ -105,7 +105,10 @@ async function mockServers(page: Page) { }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) if (url.pathname === "/api/vcs") - return json(route, { location: { directory: current.directory }, data: { branch: "main", defaultBranch: "main" } }) + return json(route, { + location: { directory: current.directory }, + data: { branch: "main", defaultBranch: "main" }, + }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index 4136c16d01..ae61b2acbf 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -57,7 +57,8 @@ async function mockServer(page: Page) { await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) if (url.pathname === "/api/session/active") return json(route, { data: {} }) @@ -72,8 +73,7 @@ async function mockServer(page: Page) { if (/^\/session\/[^/]+\/(children|todo|diff)$/.test(url.pathname)) return json(route, []) if (["/skill", "/command", "/lsp", "/formatter", "/permission", "/question", "/vcs/diff"].includes(url.pathname)) return json(route, []) - if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) - return json(route, {}) + if (["/global/config", "/config", "/provider/auth", "/mcp"].includes(url.pathname)) return json(route, {}) if (url.pathname === "/provider") return json(route, { all: [], connected: [], default: { providerID: "", modelID: "" } }) if (url.pathname === "/agent") return json(route, [{ name: "build", mode: "primary" }]) diff --git a/packages/app/src/components/command-palette.ts b/packages/app/src/components/command-palette.ts index 487d823550..8014ea1c43 100644 --- a/packages/app/src/components/command-palette.ts +++ b/packages/app/src/components/command-palette.ts @@ -146,8 +146,7 @@ export function createCommandPaletteModel(props: { filesOnly?: () => boolean; on server: ServerConnection.key(serverSDK.server), opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, - load: (search, signal) => - serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + load: (search, signal) => serverSDK.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-command-palette-v2.tsx b/packages/app/src/components/dialog-command-palette-v2.tsx index c23b703e52..e996fd0be7 100644 --- a/packages/app/src/components/dialog-command-palette-v2.tsx +++ b/packages/app/src/components/dialog-command-palette-v2.tsx @@ -79,8 +79,7 @@ export function DialogHomeCommandPaletteV2(props: { server: ServerConnection.key(props.server), opened: serverCtx.projects.list, stored: () => serverCtx.sync.data.project, - load: (search, signal) => - serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), + load: (search, signal) => serverCtx.sdk.api.session.list({ parentID: null, search, limit: 50 }, { signal }), untitled: () => language.t("command.session.new"), category: () => language.t("command.category.session"), }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 93a62acb61..9ad389317b 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -1,7 +1,4 @@ -import type { - IntegrationMethod, - IntegrationOauthConnectOutput, -} from "@opencode-ai/client/promise" +import type { IntegrationMethod, IntegrationOauthConnectOutput } from "@opencode-ai/client/promise" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index a457c9a2f5..e0909d849b 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -68,10 +68,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), - () => - sdk.api.path - .get() - .catch(() => undefined), + () => sdk.api.path.get().catch(() => undefined), { initialValue: undefined }, ) const home = createMemo(() => sync.data.path.home || fallbackPath()?.home || "") diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 80ac070750..5cc19fd920 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -60,9 +60,7 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), async () => { - return sdk.api.path - .get() - .catch(() => undefined) + return sdk.api.path.get().catch(() => undefined) }, { initialValue: undefined }, ) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 7070c5222a..ec4b5ede3d 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -479,9 +479,7 @@ function createV1Api(input: CompatibleInput): CompatibleApi { }, permission: { ...input.current.permission, - async reply( - value: Parameters[0] & { location?: { directory?: string } }, - ) { + async reply(value: Parameters[0] & { location?: { directory?: string } }) { await legacy(value.location).permission.respond({ sessionID: value.sessionID, permissionID: value.requestID, From a48912cbb10f972cd9b9be8a5f3bace296df0f4f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 14:55:54 +0800 Subject: [PATCH 29/48] fix(app): restore directory-scoped session status for v1 servers (#38637) --- .../app/src/context/global-sync/bootstrap.ts | 27 +++++++++++++++++++ 1 file changed, 27 insertions(+) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 4c527a9580..4f7b949e61 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -373,6 +373,33 @@ export async function bootstrapDirectory(input: { .then((data) => input.setStore("agent", data)), () => retry(() => input.sdk.config.get().then((x) => input.setStore("config", reconcile(x.data!, { merge: false })))), + () => + retry(() => + (async () => { + if ((await input.protocol) !== "v1") return + const x = await input.sdk.session.status() + if (!input.session) { + input.setStore("session_status", x.data!) + return + } + const statuses = x.data ?? {} + input.session.set( + "session_status", + produce((draft) => { + for (const sessionID of Object.keys(draft)) { + if (statuses[sessionID]) continue + if (input.session?.get(sessionID)?.directory === input.directory) delete draft[sessionID] + } + }), + ) + for (const [sessionID, status] of Object.entries(statuses)) { + input.session.set("session_status", sessionID, reconcile(status)) + } + await Promise.all( + Object.keys(statuses).map((sessionID) => input.session!.resolve(sessionID).catch(() => undefined)), + ) + })(), + ), !seededProject && (() => retry(() => input.api.project.current({ location: { directory: input.directory } })).then((project) => From 55f4a2691ae9e72a84c821d789f0912353197cbe Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:44:03 +0800 Subject: [PATCH 30/48] fix(app): preserve paginated timeline order (#38641) --- .../session-todo-dock-navigation.spec.ts | 1 + .../app/src/context/server-session.test.ts | 15 ++++++---- packages/app/src/context/server-session.ts | 30 +++++++++++++++---- .../session/timeline/rows-current.test.ts | 26 +++++++++++----- .../app/src/pages/session/timeline/rows.ts | 21 ++++++++----- 5 files changed, 69 insertions(+), 24 deletions(-) diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 603c411d55..43f3500899 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -56,6 +56,7 @@ test("animates todo lifecycle without replaying it across session tabs", async ( default: { providerID: "opencode", modelID: "claude-opus-4-6" }, }, sessions: [session(sourceID, sourceTitle, 1700000000000), session(otherID, otherTitle, 1700000001000)], + sessionStatus: { [sourceID]: { type: "busy" } }, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 30723ecfbf..1e1046fb76 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -263,7 +263,7 @@ describe("server session", () => { expect(store.data.session_message.root.map((message) => message.id)).toEqual([user.id, assistant.id]) }) - test("reprojects current assistants when an older page supplies their user", async () => { + test("extends a current page to include the user for split assistant turns", async () => { const user = { id: "msg_1_user", type: "user", text: "hello", time: { created: 1 } } as const const assistant = (id: string, created: number) => ({ id, @@ -282,17 +282,22 @@ describe("server session", () => { { data: assistants.slice(1).toReversed(), cursor: { previous: null, next: "older" } }, { data: [assistants[0], user], cursor: { previous: null, next: null } }, ] + const requests: unknown[] = [] const messageApi = { - list: async () => pages.shift()!, + list: async (input: unknown) => { + requests.push(input) + return pages.shift()! + }, } as unknown as MessageApi const store = createServerSession({} as OpencodeClient, {} as SessionApi, messageApi) store.remember(session("root")) await store.sync("root") - expect(store.data.message.root).toEqual([]) - - await store.history.loadMore("root") + expect(requests).toEqual([ + { sessionID: "root", limit: 20, order: "desc" }, + { sessionID: "root", limit: 20, cursor: "older" }, + ]) expect(store.data.message.root.map((message) => message.id)).toEqual([ user.id, ...assistants.map((item) => item.id), diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index 6bf0f47f5c..e8f91cda3f 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -30,6 +30,17 @@ const historyMessagePageSize = 200 const sessionInfoLimit = 2_048 const emptyIDs: ReadonlySet = new Set() +function needsOlderTurnRoot(source: readonly SessionMessageInfo[]) { + const boundary = source.find( + (message) => + message.type === "user" || + message.type === "shell" || + message.type === "assistant" || + (message.type === "synthetic" && message.description?.trim()), + ) + return boundary?.type === "assistant" +} + type OptimisticItem = { message: Message parts: Part[] @@ -525,11 +536,20 @@ export function createServerSession( const fetchMessages = async (sessionID: string, limit: number, before?: string, onAttempt?: () => void) => { if (messageApi && (await options?.protocol) !== "v1") { - const response = await (options?.retry ?? retry)(() => { - onAttempt?.() - return messageApi.list(before ? { sessionID, limit, cursor: before } : { sessionID, limit, order: "desc" }) - }) - const source = [...response.data].reverse() + const request = (cursor?: string) => + (options?.retry ?? retry)(() => { + onAttempt?.() + return messageApi.list(cursor ? { sessionID, limit, cursor } : { sessionID, limit, order: "desc" }) + }) + const first = await request(before) + const pages = [first] + while (pages.at(-1)?.cursor.next && needsOlderTurnRoot(pages.flatMap((page) => page.data).toReversed())) { + const response = await request(pages.at(-1)!.cursor.next ?? undefined) + pages.push(response) + if (!response.data.length) break + } + const response = pages.at(-1)! + const source = pages.flatMap((page) => page.data).toReversed() const normalized = normalizeSessionMessages(sessionID, source) return { session: normalized.messages.sort((a, b) => cmp(a.id, b.id)), diff --git a/packages/app/src/pages/session/timeline/rows-current.test.ts b/packages/app/src/pages/session/timeline/rows-current.test.ts index f5c74f5acb..b321ef8750 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -90,23 +90,32 @@ describe("current session timeline rows", () => { ]) }) - test("associates assistants with a projected parent missing from the source page", () => { + test("keeps a projected parent missing from the source page before newer turns", () => { const source = [ - { id: "msg_user", type: "user", text: "question", time: { created: 1 } }, + { id: "msg_user_1", type: "user", text: "first question", time: { created: 1 } }, { - id: "msg_assistant", + id: "msg_assistant_1", type: "assistant", agent: "build", model: { id: "model", providerID: "provider" }, - content: [{ type: "text", text: "answer" }], + content: [{ type: "text", text: "first answer" }], time: { created: 2, completed: 3 }, }, + { id: "msg_user_2", type: "user", text: "second question", time: { created: 4 } }, + { + id: "msg_assistant_2", + type: "assistant", + agent: "build", + model: { id: "model", providerID: "provider" }, + content: [{ type: "text", text: "second answer" }], + time: { created: 5, completed: 6 }, + }, ] satisfies SessionMessageInfo[] const normalized = normalizeSessionMessages("ses_1", source) const messages = new Map(normalized.messages.map((message) => [message.id, message])) const result = Timeline.constructSessionMessageRows( - [source[1]!], + source.slice(1), (messageID) => messages.get(messageID), (messageID) => normalized.parts.get(messageID) ?? [], true, @@ -115,8 +124,11 @@ describe("current session timeline rows", () => { ) expect(result.rows.map(TimelineRow.key)).toEqual([ - "user-message:msg_user", - "assistant-part:msg_user:msg_assistant:text:0", + "user-message:msg_user_1", + "assistant-part:msg_user_1:msg_assistant_1:text:0", + "turn-gap:msg_user_2", + "user-message:msg_user_2", + "assistant-part:msg_user_2:msg_assistant_2:text:0", ]) }) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index 2f05910d9e..f41dff7a34 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -40,17 +40,24 @@ export namespace Timeline { status: SessionStatus["type"], inlineComments: boolean, ) { - const turns = messages.flatMap<{ user: UserMessage; assistants: AssistantMessage[] }>((message) => { + const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] + const turnByUserID = new Map() + messages.forEach((message) => { const projected = getMessage(message.id) if (message.type === "shell" && projected?.role === "user") { const assistant = getMessage(`${message.id}:assistant`) - return [{ user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] }] + const turn = { user: projected, assistants: assistant?.role === "assistant" ? [assistant] : [] } + turns.push(turn) + turnByUserID.set(projected.id, turn) + return + } + if (projected?.role === "user") { + if (turnByUserID.has(projected.id)) return + const turn = { user: projected, assistants: [] } + turns.push(turn) + turnByUserID.set(projected.id, turn) + return } - return projected?.role === "user" ? [{ user: projected, assistants: [] }] : [] - }) - const turnByUserID = new Map(turns.map((turn) => [turn.user.id, turn])) - messages.forEach((message) => { - const projected = getMessage(message.id) if (projected?.role !== "assistant") return const existing = turnByUserID.get(projected.parentID) if (existing) { From 3819848cf20a4d46a2a4e7d21fc970795e35cc9b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 15:47:29 +0800 Subject: [PATCH 31/48] feat(app): support current review data (#38460) --- packages/app/e2e/regression/review-terminal-stacked.spec.ts | 5 ++++- .../app/e2e/regression/session-todo-dock-navigation.spec.ts | 3 +++ packages/app/e2e/utils/mock-server.ts | 5 +++-- 3 files changed, 10 insertions(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index 154bab48c4..afdc93f17e 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -20,6 +20,7 @@ const branchDiffs = [ test("keeps the review tree and terminal sized when both panels are open", async ({ page }) => { test.setTimeout(120_000) const events: Array<{ directory: string; payload: Record }> = [] + const sessionStatus = { [sessionID]: { type: "idle" as "busy" | "idle" } } let detailVersion = 1 let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) @@ -55,7 +56,7 @@ test("keeps the review tree and terminal sized when both panels are open", async time: { created: 1700000000000, updated: 1700000000000 }, }, ], - sessionStatus: { [sessionID]: { type: "idle" } }, + sessionStatus: () => sessionStatus, pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, @@ -143,6 +144,7 @@ test("keeps the review tree and terminal sized when both panels are open", async const preview = page.locator('[data-slot="session-review-v2-diff-scroll"]') await expect(preview).toContainText("after-1") detailVersion = 2 + sessionStatus[sessionID] = { type: "busy" } events.push(statusEvent("busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() const refreshedDiff = page.waitForRequest((request) => { @@ -152,6 +154,7 @@ test("keeps the review tree and terminal sized when both panels are open", async url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) + sessionStatus[sessionID] = { type: "idle" } events.push(statusEvent("idle")) await refreshedDiff await expect(preview).toContainText("after-2") diff --git a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts index 43f3500899..55e7121275 100644 --- a/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts +++ b/packages/app/e2e/regression/session-todo-dock-navigation.spec.ts @@ -27,6 +27,7 @@ test("animates todo lifecycle without replaying it across session tabs", async ( test.setTimeout(90_000) const events: EventPayload[] = [] const todos: Record = { [sourceID]: [], [otherID]: [] } + const sessionStatus: Record = {} await mockOpenCodeServer(page, { directory, @@ -60,6 +61,7 @@ test("animates todo lifecycle without replaying it across session tabs", async ( pageMessages: () => ({ items: [] }), events: () => events.splice(0, 1), eventRetry: 16, + sessionStatus: () => sessionStatus, todos: (sessionID) => todos[sessionID] ?? [], }) await configurePage(page) @@ -69,6 +71,7 @@ test("animates todo lifecycle without replaying it across session tabs", async ( const dock = page.locator('[data-component="session-todo-dock"]') await expect(dock).toHaveCount(0) + sessionStatus[sourceID] = { type: "busy" } events.push(statusEvent(sourceID, "busy")) await expect(page.getByRole("button", { name: "Stop" })).toBeVisible() diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 84a38771e6..df003201ad 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -24,7 +24,7 @@ export interface MockServerConfig { fileList?: (path: string) => unknown | Promise fileContent?: (path: string) => unknown | Promise findFiles?: (input: { query: string; dirs?: string; limit?: number }) => unknown - sessionStatus?: unknown + sessionStatus?: Record | (() => Record) } export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { @@ -79,7 +79,8 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) - if (path === "/session/status") return json(route, config.sessionStatus ?? {}) + if (path === "/session/status") + return json(route, typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (path === "/file" && config.fileList) return json(route, await config.fileList(url.searchParams.get("path") ?? "")) From bce2992729a9e0f1fe6dc3afa40f62004ab7a672 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 07:48:56 +0000 Subject: [PATCH 32/48] chore: generate --- packages/app/e2e/utils/mock-server.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index df003201ad..0e7dfc087c 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -80,7 +80,10 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { if (path === "/question") return json(route, typeof config.questions === "function" ? config.questions() : (config.questions ?? [])) if (path === "/session/status") - return json(route, typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {})) + return json( + route, + typeof config.sessionStatus === "function" ? config.sessionStatus() : (config.sessionStatus ?? {}), + ) if (path === "/vcs/diff" && config.vcsDiff) return json(route, config.vcsDiff) if (path === "/file" && config.fileList) return json(route, await config.fileList(url.searchParams.get("path") ?? "")) From ce7f54d5e7f1f36cc41858560fd6eb29ec96e5ce Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:12:57 +0800 Subject: [PATCH 33/48] fix(app): make prompt input agent toggle reactive (#38653) --- packages/app/src/components/prompt-input-v2.tsx | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 13df57bec2..14e8e2bd0b 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -447,15 +447,16 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): }, view: { placeholder: designPlaceholder, - agent: - props.controls.agents.visible && props.controls.agents.options.length > 0 + get agent() { + return props.controls.agents.visible && props.controls.agents.options.length > 0 ? { options: () => props.controls.agents.options.map((name) => ({ id: name, label: name })), current: () => props.controls.agents.current, - onSelect: props.controls.agents.select, + onSelect: (value: string) => props.controls.agents.select(value), keybind: () => command.keybindParts("agent.cycle"), } - : undefined, + : undefined + }, variant: { options: () => variants().map((value) => ({ id: value, label: value })), current: () => props.controls.model.selection.variant.current() ?? "default", From 57ddfeb756ac87574a2c6623464e7120f185f4fe Mon Sep 17 00:00:00 2001 From: Devin R Leopold Date: Fri, 24 Jul 2026 02:28:27 -0600 Subject: [PATCH 34/48] fix(app): classify existing web profiles for layout transition (#38117) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/app/src/context/settings.test.ts | 7 +++++++ packages/app/src/context/settings.tsx | 19 ++++++++++++++++++- 2 files changed, 25 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts index ba0161a6cd..3f94f22ec3 100644 --- a/packages/app/src/context/settings.test.ts +++ b/packages/app/src/context/settings.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import { + hasExistingWebState, isAppUpgrade, layoutTransitionState, maximumSunsetTimeout, @@ -23,6 +24,12 @@ describe("layout transition", () => { expect(layoutTransitionState(true, true, false, false)).toEqual({ available: true, notice: false }) }) + test("classifies web profiles from existing settings or a recorded version", () => { + expect(hasExistingWebState("{}", undefined)).toBe(true) + expect(hasExistingWebState(null, "1.17.19")).toBe(true) + expect(hasExistingWebState(null, undefined)).toBe(false) + }) + test("preserves explicit and default layout preferences", () => { expect(resolveNewLayoutDesigns(false, false, true)).toBe(false) expect(resolveNewLayoutDesigns(false, undefined, false)).toBe(false) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index c2b5680418..c6d583282b 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -89,6 +89,13 @@ export function shouldDisplayTabsToast( return isAppUpgrade(previous, current) || (!previous && existingInstall) } +export function hasExistingWebState( + settings: Promise | string | null, + previousVersion: string | undefined, +) { + return settings !== null || previousVersion !== undefined +} + export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) { if (!current) return false const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff) @@ -220,7 +227,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont gate: false, init: () => { const platform = usePlatform() - const [store, setStore, _, ready] = persisted("settings.v3", createStore(defaultSettings)) + const [store, setStore, settingsInit, ready] = persisted("settings.v3", createStore(defaultSettings)) const [launch, setLaunch, , launchReady] = persisted( "app-version.v1", createStore<{ version?: string }>({ version: undefined }), @@ -293,6 +300,16 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont setLaunch("version", platform.version) }) + createEffect(() => { + if (!ready() || !launchState.classified || platform.platform !== "web") return + if (layoutTransitionClassified()) return + setStore( + "general", + "layoutTransitionEligible", + hasExistingWebState(settingsInit, launchState.previous), + ) + }) + createEffect(() => { if (!ready() || !launchState.classified || launchState.migrationApplied) return if (layoutUpgrade() && store.general?.newLayoutDesigns !== true) { From c4545ab12fc6fef94be26aa72d7273cb9baed738 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 08:29:45 +0000 Subject: [PATCH 35/48] chore: generate --- packages/app/src/context/settings.tsx | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index c6d583282b..fe8b4e3c03 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -89,10 +89,7 @@ export function shouldDisplayTabsToast( return isAppUpgrade(previous, current) || (!previous && existingInstall) } -export function hasExistingWebState( - settings: Promise | string | null, - previousVersion: string | undefined, -) { +export function hasExistingWebState(settings: Promise | string | null, previousVersion: string | undefined) { return settings !== null || previousVersion !== undefined } @@ -303,11 +300,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont createEffect(() => { if (!ready() || !launchState.classified || platform.platform !== "web") return if (layoutTransitionClassified()) return - setStore( - "general", - "layoutTransitionEligible", - hasExistingWebState(settingsInit, launchState.previous), - ) + setStore("general", "layoutTransitionEligible", hasExistingWebState(settingsInit, launchState.previous)) }) createEffect(() => { From 91ed2567ef7c613228c4adedc52fbf6e935a5333 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:35:10 +0800 Subject: [PATCH 36/48] refactor(app): resolve server protocol state (#38648) --- packages/app/src/context/server-sdk.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 62c5857794..4c879603bd 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -3,7 +3,7 @@ import type { Event } from "@opencode-ai/sdk/v2/client" import { createSimpleContext } from "@opencode-ai/ui/context" import { createGlobalEmitter } from "@solid-primitives/event-bus" import { makeEventListener } from "@solid-primitives/event-listener" -import { type Accessor, batch, createMemo, onCleanup, onMount } from "solid-js" +import { type Accessor, batch, createMemo, createResource, onCleanup, onMount } from "solid-js" import { createApiForServer, createSdkForServer, type ServerApi } from "@/utils/server" import { useLanguage } from "./language" import { usePlatform } from "./platform" @@ -169,6 +169,7 @@ type ServerSDKBase = { server: ServerConnection.Any scope: ServerScope protocol: Promise + protocolKind: Accessor url: string client: ReturnType api: CompatibleApi @@ -205,6 +206,10 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS server: server.http, }) const protocol = detectServerProtocol(server.http, platform.fetch ?? globalThis.fetch) + const [protocolKind] = createResource( + () => protocol, + (value) => value, + ) const emitter = createGlobalEmitter<{ [key: string]: ServerEvent }>() @@ -347,6 +352,7 @@ function createServerSdkContextBase(server: ServerConnection.Any, scope: ServerS server, scope, protocol, + protocolKind, url: server.http.url, client: sdk, api, @@ -394,6 +400,11 @@ export const { use: useServerSDK, provider: ServerSDKProvider } = createSimpleCo }, }) +export function useServerProtocol() { + const serverSDK = useServerSDK() + return createMemo(() => serverSDK().protocolKind()) +} + type SDKEventMap = { [key in Event["type"]]: Extract } From 80a4fe8f39a974327497cc3c774569ee2512b0fc Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:12:23 +0800 Subject: [PATCH 37/48] fix(app): remove diff rendering from file-specific tabs (#38662) --- packages/app/src/pages/session.tsx | 3 - packages/app/src/pages/session/file-tabs.tsx | 58 ++++--------------- .../src/pages/session/session-side-panel.tsx | 12 ---- .../session/v2/session-file-browser-tab.tsx | 15 +---- 4 files changed, 12 insertions(+), 76 deletions(-) diff --git a/packages/app/src/pages/session.tsx b/packages/app/src/pages/session.tsx index 067a796945..d4dd6ff7ef 100644 --- a/packages/app/src/pages/session.tsx +++ b/packages/app/src/pages/session.tsx @@ -2329,9 +2329,6 @@ export default function Page() { reviewHasFocusableContent={() => hasReview() || reviewV2State.sidebarOpened()} reviewCount={reviewCount} reviewPanel={reviewPanelV2} - diffVersion={vcsQuery.dataUpdatedAt} - loadDiff={loadReviewDiff} - expandUnchanged={reviewV2State.expandMode() === "expand"} reviewSidebarToggle={(disabled) => ( Promise - expandUnchanged?: boolean } const selectionSide = (range: SelectedLineRange) => range.endSide ?? range.side ?? "additions" @@ -222,25 +215,10 @@ export function FileTabContent(props: { tab: string }) { export function SessionFileView(props: SessionFileViewProps) { const settings = useSettings() - const detailSource = createMemo(() => { - if (!props.diff || !props.loadDiff || !reviewDiffNeedsLoad(props.diff)) return - return { diff: props.diff, load: props.loadDiff, version: props.diffVersion } - }) - const [loadedDiff] = createResource(detailSource, async ({ diff, load, version }) => { - const value = await load(diff.file, version) - if (value?.file !== diff.file) return - return { source: diff, version, value } - }) - const diff = createMemo(() => { - const source = props.diff - if (!source) return - const loaded = loadedDiff() - return normalize(loaded?.source === source && loaded.version === props.diffVersion ? loaded.value : source) - }) return ( }> - + ) } @@ -530,12 +508,11 @@ function SessionFileViewV1(props: { tab: string }) { return content() } -function SessionFileViewV2(props: { tab: string; diff?: ReturnType; expandUnchanged?: boolean }) { +function SessionFileViewV2(props: { tab: string }) { const file = useFile() const comments = useComments() const language = useLanguage() const prompt = usePrompt() - const layout = useLayout() const fileComponent = useFileComponent() const { sessionKey, tabs, view } = useSessionLayout() const activeFileTab = createSessionTabs({ @@ -581,9 +558,7 @@ function SessionFileViewV2(props: { tab: string; diff?: ReturnType { const source = filePath === path() - ? props.diff - ? text(props.diff, selectionSide(lines)) - : contents() + ? contents() : file.get(filePath)?.content?.content if (!source) return undefined return selectionPreview(source, selectionFromLines(lines)) @@ -761,21 +736,12 @@ function SessionFileViewV2(props: { tab: string; diff?: ReturnType { @@ -824,7 +789,6 @@ function SessionFileViewV2(props: { tab: string; diff?: ReturnType - {renderFile(contents())} {renderFile(contents())}
    {language.t("common.loading")}...
    diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 571428e29f..0a741ef98b 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -71,9 +71,6 @@ export function SessionSidePanel(props: { reviewHasFocusableContent: () => boolean reviewCount: () => number reviewPanel: () => JSX.Element - diffVersion?: number - loadDiff?: (path: string, version?: number) => Promise - expandUnchanged?: boolean reviewSidebarToggle?: (disabled: boolean) => JSX.Element fileBrowserState?: SessionFileBrowserState activeDiff?: string @@ -91,11 +88,6 @@ export function SessionSidePanel(props: { const sdk = useSDK() const { sessionKey, tabs, view, params } = useSessionLayout() const projectDirectory = createMemo(() => sdk().directory) - const diffForTab = (tab: string) => { - const path = file.pathFromTab(tab) - if (!path) return - return props.diffs().find((diff): diff is RenderDiff => renderDiff(diff) && diff.file === path) - } const isDesktop = createMediaQuery("(min-width: 768px)") const shown = settings.visibility.fileTree @@ -747,10 +739,6 @@ export function SessionSidePanel(props: { active={file.pathFromTab(browserTab() ?? activeFileTab() ?? "")} kinds={kinds()} state={props.fileBrowserState!} - diff={diffForTab(browserTab() ?? activeFileTab() ?? "")} - diffVersion={props.diffVersion} - loadDiff={props.loadDiff} - expandUnchanged={props.expandUnchanged} onSelect={(path) => previewTab(file.tab(path))} onSelectPermanent={(path) => openTab(file.tab(path))} filterRef={(element) => (fileFilter = element)} diff --git a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx index 6862f295b9..639429e80b 100644 --- a/packages/app/src/pages/session/v2/session-file-browser-tab.tsx +++ b/packages/app/src/pages/session/v2/session-file-browser-tab.tsx @@ -11,7 +11,6 @@ import { useSDK } from "@/context/sdk" import { displayName } from "@/pages/layout/helpers" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileView } from "@/pages/session/file-tabs" -import type { RenderDiff } from "@/pages/session/v2/review-diff-kinds" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" import { pathKey } from "@/utils/path-key" @@ -31,10 +30,6 @@ export function SessionFileBrowserTab(props: { active?: string kinds: ReadonlyMap state: SessionFileBrowserState - diff?: RenderDiff - diffVersion?: number - loadDiff?: (path: string, version?: number) => Promise - expandUnchanged?: boolean onSelect: (path: string) => void onSelectPermanent: (path: string) => void filterRef?: (element: HTMLInputElement) => void @@ -177,15 +172,7 @@ export function SessionFileBrowserTab(props: { >
    - {(tab) => ( - - )} + {(tab) => }
    From 3337495427a7cdfb6eec2b82073bd8730c38ed6e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 09:13:59 +0000 Subject: [PATCH 38/48] chore: generate --- packages/app/src/pages/session/file-tabs.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/app/src/pages/session/file-tabs.tsx b/packages/app/src/pages/session/file-tabs.tsx index d1e7928f15..b67b810a4b 100644 --- a/packages/app/src/pages/session/file-tabs.tsx +++ b/packages/app/src/pages/session/file-tabs.tsx @@ -556,10 +556,7 @@ function SessionFileViewV2(props: { tab: string }) { } const buildPreview = (filePath: string, lines: SelectedLineRange) => { - const source = - filePath === path() - ? contents() - : file.get(filePath)?.content?.content + const source = filePath === path() ? contents() : file.get(filePath)?.content?.content if (!source) return undefined return selectionPreview(source, selectionFromLines(lines)) } From aaa42fe3bfa89a282c42a8eb3fb4a3665371d0a8 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 17:56:39 +0800 Subject: [PATCH 39/48] fix(app): isolate v2 servers from legacy layout (#38649) --- packages/app/src/app.tsx | 24 +++++++++++++++++++ .../src/components/dialog-select-server.tsx | 20 +++++++++++++++- .../src/components/status-popover-body.tsx | 9 +++++-- packages/app/src/context/layout.tsx | 2 +- 4 files changed, 51 insertions(+), 4 deletions(-) diff --git a/packages/app/src/app.tsx b/packages/app/src/app.tsx index 25d2e3749a..f47c432e42 100644 --- a/packages/app/src/app.tsx +++ b/packages/app/src/app.tsx @@ -237,6 +237,30 @@ function UiI18nBridge(props: ParentProps) { return {props.children} } +function LayoutCompatibility(props: ParentProps) { + const global = useGlobal() + const navigate = useNavigate() + const server = useServer() + const settings = useSettings() + + createEffect(() => { + if (settings.general.newLayoutDesigns()) return + const current = server.current + if (!current) return + const protocol = global.ensureServerCtx(current).sdk.protocolKind() + if (protocol !== "v2") return + const next = global.servers.list().find((s) => { + if (ServerConnection.key(s) === ServerConnection.key(current)) return false + return global.ensureServerCtx(s).sdk.protocolKind() !== "v2" + }) + if (!next) return + navigate("/") + queueMicrotask(() => server.setActive(ServerConnection.key(next))) + }) + + return <>{props.children} +} + declare global { interface Window { __OPENCODE__?: { diff --git a/packages/app/src/components/dialog-select-server.tsx b/packages/app/src/components/dialog-select-server.tsx index 0876906890..aa16976228 100644 --- a/packages/app/src/components/dialog-select-server.tsx +++ b/packages/app/src/components/dialog-select-server.tsx @@ -16,6 +16,7 @@ import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { normalizeServerUrl, ServerConnection, useServer } from "@/context/server" +import { detectServerProtocol } from "@/utils/server-protocol" import { type ServerHealth, useCheckServerHealth } from "@/utils/server-health" import { useSettings } from "@/context/settings" import { useTabs } from "@/context/tabs" @@ -263,6 +264,13 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("addServer", { error: language.t("dialog.server.add.error") }) return } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("addServer", { error: language.t("dialog.server.add.error") }) + return + } resetAdd() if (options.navigateOnAdd === false) { @@ -307,6 +315,13 @@ export function useServerManagementController(options: { onSelect?: () => void; setStore("editServer", { error: language.t("dialog.server.add.error") }) return } + if ( + !settings.general.newLayoutDesigns() && + (await detectServerProtocol(conn.http, platform.fetch ?? globalThis.fetch)) === "v2" + ) { + setStore("editServer", { error: language.t("dialog.server.add.error") }) + return + } if (normalized === input.original.http.url) { server.add(conn) } else { @@ -344,7 +359,10 @@ export function useServerManagementController(options: { onSelect?: () => void; ) const sortedItems = createMemo(() => { - const list = items() + const raw = items() + const list = settings.general.newLayoutDesigns() + ? raw + : raw.filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") if (!list.length) return list const active = current() const order = new Map(list.map((url, index) => [url, index] as const)) diff --git a/packages/app/src/components/status-popover-body.tsx b/packages/app/src/components/status-popover-body.tsx index 68a3f6b226..8046ec3e57 100644 --- a/packages/app/src/components/status-popover-body.tsx +++ b/packages/app/src/components/status-popover-body.tsx @@ -276,7 +276,12 @@ export function StatusPopoverBody(props: { shown: Accessor }) { dialogDead = true dialogRun += 1 }) - const sortedServers = createMemo(() => listServersByHealth(global.servers.list(), server.key, global.servers.health)) + const sortedServers = createMemo(() => { + const list = settings.general.newLayoutDesigns() + ? global.servers.list() + : global.servers.list().filter((x) => global.ensureServerCtx(x).sdk.protocolKind() !== "v2") + return listServersByHealth(list, server.key, global.servers.health) + }) const toggleMcp = useMcpToggle() const defaultServer = useDefaultServerKey(platform.getDefaultServer) const mcpNames = createMemo(() => Object.keys(sync().data.mcp ?? {}).sort((a, b) => a.localeCompare(b))) @@ -303,7 +308,7 @@ export function StatusPopoverBody(props: { shown: Accessor }) { {!settings.general.newLayoutDesigns() && ( - {global.servers.list().length > 0 ? `${global.servers.list().length} ` : ""} + {sortedServers().length > 0 ? `${sortedServers().length} ` : ""} {language.t("status.popover.tab.servers")} )} diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index c039b3d482..d086582035 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -127,7 +127,7 @@ const normalizeStoredSessionTabs = (key: string, tabs: SessionTabs) => { } } -const currentRoute = (pathname: string, search: string): LayoutRoute => { +export const currentRoute = (pathname: string, search: string): LayoutRoute => { const parts = pathname.split("/").filter(Boolean) if (parts.length === 0) return { type: "home" } From 67a04787bf15762abc305081563cfb14a35cb426 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:15:42 +0800 Subject: [PATCH 40/48] fix(app): gate config permission auto-accept (#38650) --- packages/app/src/context/permission.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/app/src/context/permission.tsx b/packages/app/src/context/permission.tsx index 3ed91e60bf..d6d8019262 100644 --- a/packages/app/src/context/permission.tsx +++ b/packages/app/src/context/permission.tsx @@ -212,6 +212,7 @@ function createServerPermissionState(input: { sdk: ServerSDK; sync: ServerSync } ) function enableConfiguredDirectory(directory: string) { + if (input.sdk.protocolKind() !== "v1") return if (meta.disposed || !ready()) return const [childStore] = input.sync.child(directory) if (childStore.config.permission !== "allow") return From ad78ef5a4c65932b8f592f0150a67185813ee5cd Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:20:54 +0800 Subject: [PATCH 41/48] feat(app): support current pty transport (#38463) --- packages/app/V1_API_MIGRATION.md | 220 ++++++++++++++++++ .../remote-session-settings.spec.ts | 41 +++- .../regression/review-line-comment.spec.ts | 5 +- .../review-state-persistence.spec.ts | 15 +- .../review-terminal-stacked.spec.ts | 84 +++++-- .../terminal-composer-focus.spec.ts | 54 +++-- .../e2e/regression/terminal-hidden.spec.ts | 47 +++- .../regression/terminal-tab-switch.spec.ts | 40 +++- .../app/src/components/settings-general.tsx | 12 +- .../src/components/settings-v2/general.tsx | 12 +- packages/app/src/components/terminal.tsx | 83 +++++-- packages/app/src/context/server-sdk.tsx | 1 + packages/app/src/context/terminal.tsx | 95 +++++--- packages/app/src/pages/session/review-tab.tsx | 3 +- .../src/pages/session/session-side-panel.tsx | 8 +- .../src/pages/session/v2/review-diff-kinds.ts | 5 +- .../src/pages/session/v2/review-panel-v2.tsx | 3 +- packages/app/src/utils/diffs.test.ts | 3 +- packages/app/src/utils/diffs.ts | 3 +- .../src/utils/terminal-websocket-url.test.ts | 34 ++- .../app/src/utils/terminal-websocket-url.ts | 13 +- .../session-ui/src/components/session-diff.ts | 3 +- .../src/components/session-review.tsx | 5 +- .../src/components/session-turn.tsx | 3 +- packages/session-ui/src/context/data.tsx | 3 +- .../session-review-file-preview-v2.tsx | 3 +- 26 files changed, 641 insertions(+), 157 deletions(-) create mode 100644 packages/app/V1_API_MIGRATION.md diff --git a/packages/app/V1_API_MIGRATION.md b/packages/app/V1_API_MIGRATION.md new file mode 100644 index 0000000000..2850f10740 --- /dev/null +++ b/packages/app/V1_API_MIGRATION.md @@ -0,0 +1,220 @@ +# V1 API Migration Checklist + +The app is currently hybrid. In this document, V1 refers to the legacy unprefixed server APIs used by `@opencode-ai/sdk/v2`, despite the SDK package name. + +## Events + +- [x] Replace `GET /global/event` with `GET /api/event`. + - `src/context/server-sdk.tsx` +- [x] Reduce current granular session and message events into the existing app projections. + - `src/context/server-session-v2-reducer.ts` + - `src/context/server-session.ts` +- [ ] Remove transitional session event dependencies: `session.created`, `session.updated`, `session.diff`, `session.status`, `session.idle`, and `session.error`. + - `src/context/global-sync/event-reducer.ts` + - `src/context/server-session.ts` + - `src/context/notification.tsx` + - `src/pages/session/usage-exceeded-dialogs.tsx` +- [ ] Remove legacy message event compatibility: `message.updated`, `message.removed`, `message.part.updated`, `message.part.removed`, and `message.part.delta`. + - `src/context/global-sync/event-reducer.ts` + - `src/context/server-session.ts` +- [x] Adapt current permission and question events to the existing request model. + - `src/context/global-sync/event-reducer.ts` + - `src/context/permission.tsx` +- [x] Consume current file watcher events. + - `src/context/file.tsx` +- [x] Consume current VCS events. + - `src/context/global-sync/event-reducer.ts` + - `src/pages/session.tsx` +- [x] Consume current `pty.exited` events. + - `src/context/terminal.tsx` +- [ ] Migrate LSP and reference events. + - `src/context/global-sync/event-reducer.ts` + +## Sessions + +- [x] Replace `GET /session/status` with one server-scoped `GET /api/session/active` snapshot plus V2 execution events. + - `src/context/server-sync.tsx` +- [x] Migrate session listing from `GET /session`. + - `src/context/server-sync.tsx` + - `src/context/directory-sync.ts` + - `src/pages/layout.tsx` +- [x] Migrate the remaining direct session read from `GET /session/:sessionID`. + - `src/components/titlebar.tsx` +- [x] Migrate session updates from `PATCH /session/:sessionID`. + - `src/context/directory-sync.ts` + - `src/context/layout.tsx` + - `src/pages/home.tsx` + - `src/pages/layout.tsx` + - `src/pages/session/timeline/message-timeline.tsx` + - `src/components/titlebar-tab-nav.tsx` + - Renames use `POST /api/session/:sessionID/rename`; archival uses `POST /api/session/:sessionID/archive`. +- [x] Migrate session deletion from `DELETE /session/:sessionID`. + - `src/pages/session/timeline/message-timeline.tsx` +- [x] Remove session diff loading from `GET /session/:sessionID/diff`. + - Historical Session diffs remain unavailable until the current API defines their snapshot semantics. +- [x] Migrate abort from `POST /session/:sessionID/abort`. + - `src/components/prompt-input/submit.ts` + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session.tsx` +- [x] Migrate revert and unrevert from `POST /session/:sessionID/revert` and `POST /session/:sessionID/unrevert`. + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session.tsx` +- [x] Replace `POST /session/:sessionID/summarize` with the current compact API. + - `src/pages/session/use-session-commands.tsx` +- [x] Migrate slash commands from `POST /session/:sessionID/command`. + - `src/components/prompt-input/submit.ts` +- [x] Migrate shell execution from `POST /session/:sessionID/shell`. + - `src/components/prompt-input/submit.ts` +- [x] Migrate session fork from `POST /session/:sessionID/fork`. + - `src/components/dialog-fork.tsx` +- [ ] Migrate sharing from `POST /session/:sessionID/share` and `DELETE /session/:sessionID/share`. + - `src/pages/session/use-session-commands.tsx` + - `src/pages/session/timeline/message-timeline.tsx` + - Blocked: the current API has no sharing contract or implementation. + +## Session Compatibility Fallbacks + +These calls are retained as fallback adapters. The current production path supplies the current session and message APIs. + +- [ ] Remove fallback `GET /session/:sessionID` after compatibility support is unnecessary. + - `src/context/server-session.ts` +- [ ] Remove fallback `GET /session/:sessionID/message` after compatibility support is unnecessary. + - `src/context/server-session.ts` +- [ ] Remove fallback `GET /session/:sessionID/message/:messageID` after compatibility support is unnecessary. + - `src/context/server-session.ts` + +## Filesystem + +- [ ] Migrate file listing from `GET /file`. + - `src/context/file.tsx` +- [ ] Migrate file reads from `GET /file/content`. + - `src/context/file.tsx` + - `src/pages/session/review-tab.tsx` + - `src/pages/session/v2/review-panel-v2.tsx` +- [x] Migrate path discovery from `GET /path` to `GET /api/path`. + - `src/context/global-sync/bootstrap.ts` + - `src/components/dialog-select-directory.tsx` + - `src/components/dialog-select-directory-v2.tsx` + +## Projects And Worktrees + +- [x] Migrate project listing from `GET /project` to `GET /api/project`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate the current project lookup from `GET /project/current` to `GET /api/project/current`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate Git initialization from `POST /project/git/init`. + - `src/pages/session.tsx` +- [x] Migrate project updates from `PATCH /project/:projectID` to `PATCH /api/project/:projectID`. + - `src/context/layout.tsx` + - `src/components/edit-project.ts` + - `src/pages/layout.tsx` +- [ ] Migrate experimental worktree listing, creation, removal, and reset from `/experimental/worktree`. + - `src/pages/layout.tsx` + - `src/components/prompt-input/submit.ts` + - Listing now uses `GET /api/project/:projectID/directories`; create, removal, and reset remain. +- [ ] Migrate instance disposal from `POST /instance/dispose`. + - `src/pages/layout.tsx` + +## VCS + +- [x] Migrate repository information from `GET /vcs` to `GET /api/vcs`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate diffs from `GET /vcs/diff` to `GET /api/vcs/diff`. + - `src/pages/session.tsx` +- [x] Migrate status from `GET /vcs/status` to `GET /api/vcs/status`. + - `src/pages/layout.tsx` + +## Configuration And Authentication + +- [ ] Migrate global configuration reads from `GET /global/config`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate directory configuration reads from `GET /config`. + - `src/context/global-sync/bootstrap.ts` +- [ ] Migrate global configuration updates from `PATCH /global/config`. + - `src/context/server-sync.tsx` +- [x] Migrate provider authentication method discovery from `GET /provider/auth` to `GET /api/integration/:integrationID`. + - `src/components/dialog-connect-provider.tsx` +- [x] Migrate built-in provider OAuth authorization and callbacks to `/api/integration/:integrationID/connect/oauth/*`. + - `src/components/dialog-connect-provider.tsx` +- [ ] Migrate remaining credentials from `PUT /auth/:providerID` and `DELETE /auth/:providerID`. + - Built-in provider key connections now use `POST /api/integration/:integrationID/connect/key`. + - `src/components/dialog-connect-provider.tsx` + - `src/components/dialog-custom-provider.tsx` + - `src/components/settings-providers.tsx` + - `src/components/settings-v2/providers.tsx` +- [ ] Migrate global disposal from `POST /global/dispose`. + - `src/components/dialog-connect-provider.tsx` + - `src/components/settings-providers.tsx` + - `src/components/settings-v2/providers.tsx` + +## Permissions And Questions + +- [x] Migrate permission listing from `GET /permission` to `GET /api/permission/request`. + - `src/context/global-sync/bootstrap.ts` + - `src/context/permission.tsx` +- [x] Migrate permission responses from `/session/:sessionID/permissions/:permissionID`. + - `src/context/permission.tsx` + - `src/pages/session/composer/session-composer-state.ts` +- [x] Migrate question listing from `GET /question` to `GET /api/question/request`. + - `src/context/global-sync/bootstrap.ts` +- [x] Migrate question replies and rejections from `/question/:requestID/*` to `/api/session/:sessionID/question/:requestID/*`. + - `src/pages/session/composer/session-question-dock.tsx` + +## Commands, MCP, LSP, And References + +- [x] Migrate command listing from `GET /command` to `GET /api/command`. + - `src/context/global-sync/bootstrap.ts` + - `src/context/server-sync.tsx` +- [x] Migrate MCP listing, connection, and disconnection from `/mcp` to `/api/mcp`. + - `src/context/server-sync.tsx` +- [ ] Replace legacy MCP authentication with the Integration OAuth workflow. + - `src/context/server-sync.tsx` +- [x] Migrate experimental resource listing from `GET /experimental/resource` to `GET /api/mcp/resource`. + - `src/context/server-sync.tsx` +- [ ] Migrate LSP status from `GET /lsp`. + - `src/context/server-sync.tsx` +- [x] Move `GET /api/reference` off the legacy generated SDK transport. + - `src/context/global-sync/bootstrap.ts` + +## Search + +- [x] Migrate global session search from `GET /experimental/session` to `GET /api/session`. + - `src/components/command-palette.ts` + - `src/components/dialog-command-palette-v2.tsx` + +## PTY And Terminal + +- [x] Migrate PTY creation, reads, updates, and deletion from `/pty` to `/api/pty`. + - `src/context/terminal.tsx` + - `src/components/terminal.tsx` +- [x] Migrate shell listing from `GET /pty/shells` to `GET /api/pty/shells`. + - `src/components/settings-general.tsx` + - `src/components/settings-v2/general.tsx` +- [x] Migrate connection tokens from `POST /pty/:ptyID/connect-token` to `POST /api/pty/:ptyID/connect-token`. + - `src/components/terminal.tsx` +- [x] Migrate the direct WebSocket connection from `/pty/:ptyID/connect` to `/api/pty/:ptyID/connect`. + - `src/components/terminal.tsx` + +## Legacy Types And Adapters + +These are not V1 network requests, but they keep the UI coupled to V1 data contracts. + +- [ ] Replace the current-session-to-legacy-session adapter. + - `src/utils/session.ts` +- [ ] Replace the current-message-to-legacy-message-and-part adapter. + - `src/utils/session-message.ts` +- [ ] Replace current agent, provider, and model adapters to legacy SDK structures. + - `src/context/global-sync/utils.ts` +- [ ] Replace legacy `Session`, `Message`, `Part`, `PermissionRequest`, `QuestionRequest`, `Project`, `FileNode`, `FileDiffInfo`, and `Event` types throughout app state and rendering. +- [ ] Remove the `@opencode-ai/sdk` runtime dependency after all legacy calls and types are gone. + - `package.json` + +## Test Infrastructure + +- [ ] Replace V1 endpoint mocks with current API mocks. + - `e2e/utils/mock-server.ts` +- [x] Replace `/global/event` and `/event` interception with current event transport handling. + - `e2e/utils/sse-transport.ts` +- [ ] Replace `SessionV1` and legacy SDK fixtures in timeline performance tests. + - `e2e/performance/timeline-stability/fixture.ts` +- [ ] Remove remaining legacy SDK type fixtures from unit and browser tests. diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index c17ae5c1c6..4f6d57aa2e 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -1,6 +1,7 @@ import { base64Encode } from "@opencode-ai/core/util/encode" import { expect, test, type Page, type Route } from "@playwright/test" import { installSseTransport } from "../utils/sse-transport" +import { currentSession } from "../utils/mock-server" const serverA = "http://127.0.0.1:4096" const serverB = "http://127.0.0.1:4097" @@ -17,7 +18,7 @@ test("session settings use the remote server context", async ({ page }) => { await page.goto(`/server/${base64Encode(serverB)}/session/${sessionB.id}`) await expect(page.getByText(sessionB.title).first()).toBeVisible() - await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + await page.keyboard.press("Control+,") const dialog = page.locator(".settings-v2-dialog") const autoAccept = dialog.locator('[data-action="settings-auto-accept-permissions"]') @@ -58,7 +59,7 @@ test("auto-accept responds for an unfocused server session", async ({ page }) => const hrefB = `/server/${base64Encode(serverB)}/session/${sessionB.id}` await page.goto(`/server/${base64Encode(serverA)}/session/${sessionA.id}`) await expect(page.getByText(sessionA.title).first()).toBeVisible() - await page.keyboard.press(process.platform === "darwin" ? "Meta+," : "Control+,") + await page.keyboard.press("Control+,") const autoAccept = page.locator(".settings-v2-dialog").locator('[data-action="settings-auto-accept-permissions"]') await autoAccept.locator('[data-slot="switch-control"]').click() await expect(autoAccept.getByRole("switch")).toBeChecked() @@ -180,10 +181,35 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR return json(route, true) } if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) - if (url.pathname === "/session/status") return json(route, {}) - if (url.pathname === "/session") return json(route, sessions) + if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") + return json(route, { data: [] }) + if (url.pathname === "/api/model/default") return json(route, { data: null }) + if (["/api/command", "/api/reference", "/api/permission/request", "/api/question/request"].includes(url.pathname)) + return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp") return json(route, { location: { directory }, data: [] }) + if (url.pathname === "/api/mcp/resource") + return json(route, { location: { directory }, data: { resources: [], templates: [] } }) + if (url.pathname === "/api/project") { + return json(route, [ + { + id: remote ? sessionB.projectID : "project-server-a", + worktree: directory, + vcs: "git", + time: { created: 1, updated: 1 }, + sandboxes: [], + }, + ]) + } + if (url.pathname === "/api/project/current") + return json(route, { id: remote ? sessionB.projectID : "project-server-a", directory }) + if (url.pathname === "/api/session") return json(route, { data: sessions.map(currentSession), cursor: {} }) + if (url.pathname === "/api/session/active") return json(route, { data: {} }) + const currentSessionInfo = sessions.find((session) => url.pathname === `/api/session/${session.id}`) + if (currentSessionInfo) return json(route, { data: currentSession(currentSessionInfo) }) + if (sessions.some((session) => url.pathname === `/api/session/${session.id}/message`)) + return json(route, { data: [], cursor: {} }) const current = sessions.find((session) => url.pathname === `/session/${session.id}`) if (current) return json(route, current) if (/^\/session\/[^/]+$/.test(url.pathname)) return json(route, { name: "NotFoundError" }, 404) @@ -216,7 +242,12 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR directory, home: directory, }) + if (url.pathname === "/api/path") + return json(route, { state: directory, config: directory, worktree: directory, directory, home: directory }) if (url.pathname === "/vcs") return json(route, { branch: "main", default_branch: "main" }) + if (url.pathname === "/api/vcs") + return json(route, { location: { directory }, data: { branch: "main", defaultBranch: "main" } }) + if (url.pathname === "/api/pty/shells") return json(route, { location: { directory }, data: [] }) return json(route, {}) }) } diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 042f926c53..7850f7820a 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -84,6 +84,7 @@ test("stages a submitted line comment in the prompt context", async ({ page }) = async function openReview(page: Page) { await page.setViewportSize({ width: 700, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: "proj_review_line_comment_regression", @@ -143,9 +144,9 @@ async function openReview(page: Page) { await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) - const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/vcs/diff") + const diffResponse = page.waitForResponse((response) => new URL(response.url()).pathname === "/api/vcs/diff") await page.getByRole("tab", { name: "Changes" }).click() - expect(await (await diffResponse).json()).toHaveLength(1) + expect((await (await diffResponse).json()).data).toHaveLength(1) const review = page.locator('[data-component="session-review"]') await expectAppVisible(review) diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 6c27ad6467..4f67756d53 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -65,6 +65,7 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -92,18 +93,20 @@ async function setup(page: Page) { route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "feature", default_branch: "dev" }), + body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }), }), ) await page.route("**/vcs/diff**", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - new URL(route.request().url()).searchParams.get("mode") === "branch" - ? [diff("src/alpha.ts"), diff("src/beta.ts")] - : [diff("src/alpha.ts"), diff("src/gamma.ts")], - ), + body: JSON.stringify({ + location: { directory }, + data: + new URL(route.request().url()).searchParams.get("mode") === "branch" + ? [diff("src/alpha.ts"), diff("src/beta.ts")] + : [diff("src/alpha.ts"), diff("src/gamma.ts")], + }), }), ) await page.addInitScript( diff --git a/packages/app/e2e/regression/review-terminal-stacked.spec.ts b/packages/app/e2e/regression/review-terminal-stacked.spec.ts index afdc93f17e..7cc8723a8c 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -25,6 +25,7 @@ test("keeps the review tree and terminal sized when both panels are open", async let detailFailures = 1 await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -65,39 +66,80 @@ test("keeps the review tree and terminal sized when both panels are open", async route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ branch: "review-pane-performance", default_branch: "dev" }), + body: JSON.stringify({ + location: { directory }, + data: { branch: "review-pane-performance", defaultBranch: "dev" }, + }), }), ) - await page.route("**/vcs/diff**", (route) => { + await page.route("**/api/vcs/diff**", (route) => { const url = new URL(route.request().url()) - const scope = url.searchParams.get("directory")?.replaceAll("\\", "/") + const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/") const detail = scope?.endsWith("/src/branch/d00027") if (detail && detailFailures-- > 0) return route.fulfill({ status: 500, body: "retry detail" }) return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify( - url.searchParams.get("mode") === "branch" - ? detail - ? branchDiffs - .filter((diff) => diff.file.startsWith("src/branch/d00027/")) - .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) - : branchDiffs - : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), - ), + body: JSON.stringify({ + location: { directory: scope ?? directory, project: { id: projectID, directory } }, + data: + url.searchParams.get("mode") === "branch" + ? detail + ? branchDiffs + .filter((diff) => diff.file.startsWith("src/branch/d00027/")) + .map((diff) => fileDiff(diff.file, diff.additions, true, detailVersion)) + : branchDiffs + : Array.from({ length: 7 }, (_, index) => fileDiff(`src/git-${index}.ts`, 1)), + }), }) }) - await page.route("**/pty", (route) => + await page.route("**/api/pty*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: "pty_review_terminal", title: "Terminal 1" }), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), }), ) - await page.route("**/pty/pty_review_terminal", (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + await page.route("**/api/pty/pty_review_terminal*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_review_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), ) - await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) + await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), + ) + await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem( @@ -135,8 +177,8 @@ test("keeps the review tree and terminal sized when both panels are open", async const lazyDiff = page.waitForRequest((request) => { const url = new URL(request.url()) return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) await lastFile.click() @@ -150,8 +192,8 @@ test("keeps the review tree and terminal sized when both panels are open", async const refreshedDiff = page.waitForRequest((request) => { const url = new URL(request.url()) return ( - url.pathname === "/vcs/diff" && - url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/api/vcs/diff" && + url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) sessionStatus[sessionID] = { type: "idle" } diff --git a/packages/app/e2e/regression/terminal-composer-focus.spec.ts b/packages/app/e2e/regression/terminal-composer-focus.spec.ts index f672602782..99bf689085 100644 --- a/packages/app/e2e/regression/terminal-composer-focus.spec.ts +++ b/packages/app/e2e/regression/terminal-composer-focus.spec.ts @@ -13,6 +13,7 @@ test.use({ viewport: { width: 1440, height: 900 } }) test.beforeEach(async ({ page }) => { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -46,25 +47,30 @@ test.beforeEach(async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty*", (route) => { + expect(new URL(route.request().url()).searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), + }) + }) + await page.route(`**/api/pty/${ptyID}*`, (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), }), ) - await page.route(`**/pty/${ptyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), - ) - await page.route(`**/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), }), ) - await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) }) @@ -95,12 +101,12 @@ test("keeps composer focus when a cached terminal finishes mounting", async ({ p const ghostty = Promise.withResolvers() const release = Promise.withResolvers() const created = { count: 0 } - await page.route("**/pty", (route) => { + await page.route("**/api/pty*", (route) => { created.count += 1 return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(ptyID, "Terminal 1") }), }) }) await page.route(/ghostty-web/, async (route) => { @@ -155,27 +161,31 @@ test("keeps newer composer focus while an explicit terminal open finishes", asyn test("focuses a terminal created from the new-terminal button", async ({ page }) => { const created = { count: 0 } - await page.route("**/pty", (route) => { + await page.route("**/api/pty*", (route) => { created.count += 1 - const next = created.count === 1 ? { id: ptyID, title: "Terminal 1" } : { id: newPtyID, title: "Terminal 2" } + const next = created.count === 1 ? ptyInfo(ptyID, "Terminal 1") : ptyInfo(newPtyID, "Terminal 2") return route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify(next), + body: JSON.stringify({ location: ptyLocation(), data: next }), }) }) - await page.route(`**/pty/${newPtyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + await page.route(`**/api/pty/${newPtyID}*`, (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo(newPtyID, "Terminal 2") }), + }), ) - await page.route(`**/pty/${newPtyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${newPtyID}/connect-token*`, (route) => route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), }), ) - await page.routeWebSocket(new RegExp(`/pty/${newPtyID}/connect`), () => undefined) + await page.routeWebSocket(new RegExp(`/api/pty/${newPtyID}/connect`), () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, "Terminal composer focus") @@ -207,3 +217,11 @@ function seedCachedTerminal(page: Page) { { terminalKey: `${base64Encode(directory)}/terminal.v1`, ptyID }, ) } + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo(id: string, title: string) { + return { id, title, command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/e2e/regression/terminal-hidden.spec.ts b/packages/app/e2e/regression/terminal-hidden.spec.ts index 73821580af..8e08d60ff2 100644 --- a/packages/app/e2e/regression/terminal-hidden.spec.ts +++ b/packages/app/e2e/regression/terminal-hidden.spec.ts @@ -10,6 +10,7 @@ const title = "Hidden terminal regression" test("unmounts the terminal panel while it is hidden", async ({ page }) => { await page.setViewportSize({ width: 1400, height: 900 }) await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -43,17 +44,53 @@ test("unmounts the terminal panel while it is hidden", async ({ page }) => { ], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: "pty_hidden_terminal", title: "Terminal 1" }), + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), }), ) - await page.route("**/pty/pty_hidden_terminal", (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), + await page.route("**/api/pty/pty_hidden_terminal*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { + id: "pty_hidden_terminal", + title: "Terminal 1", + command: "cmd.exe", + args: [], + cwd: directory, + status: "running", + pid: 1, + }, + }), + }), ) - await page.routeWebSocket("**/pty/pty_hidden_terminal/connect", () => undefined) + await page.route("**/api/pty/pty_hidden_terminal/connect-token*", (route) => + route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ + location: { directory, project: { id: projectID, directory } }, + data: { ticket: "e2e-ticket", expires_in: 60 }, + }), + }), + ) + await page.routeWebSocket("**/api/pty/pty_hidden_terminal/connect", () => undefined) await page.goto(`/${base64Encode(directory)}/session/${sessionID}`) await expectSessionTitle(page, title) diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index cbb72958ad..0076c5a2ca 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -29,6 +29,10 @@ test("keeps the terminal session alive when switching session tabs in a workspac const terminal = page.locator('[data-component="terminal"]') await expect(terminal).toBeVisible() await expect.poll(() => connections.length).toBe(1) + const connection = new URL(connections[0]!) + expect(connection.pathname).toBe(`/api/pty/${ptyID}/connect`) + expect(connection.searchParams.get("location[directory]")).toBe(directory) + expect(connection.searchParams.get("ticket")).toBe("e2e-ticket") await writeProbe(page) await switchTab(page, titleB) @@ -62,6 +66,7 @@ async function readProbe(page: Page) { async function setup(page: Page) { await mockOpenCodeServer(page, { + protocol: "v2", directory, project: { id: projectID, @@ -85,26 +90,33 @@ async function setup(page: Page) { sessions: [session(sessionA, titleA, 1700000000000), session(sessionB, titleB, 1700000001000)], pageMessages: () => ({ items: [] }), }) - await page.route("**/pty", (route) => + await page.route("**/api/pty*", (route) => route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ id: ptyID, title: "Terminal 1" }), + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), }), ) - await page.route(`**/pty/${ptyID}`, (route) => - route.fulfill({ status: 200, contentType: "application/json", body: "{}" }), - ) - await page.route(`**/pty/${ptyID}/connect-token*`, (route) => + await page.route(`**/api/pty/${ptyID}*`, (route) => route.fulfill({ + status: 200, + contentType: "application/json", + body: JSON.stringify({ location: ptyLocation(), data: ptyInfo() }), + }), + ) + await page.route(`**/api/pty/${ptyID}/connect-token*`, (route) => { + expect(route.request().headers()["x-opencode-ticket"]).toBe("1") + const url = new URL(route.request().url()) + expect(url.searchParams.get("location[directory]")).toBe(directory) + return route.fulfill({ status: 200, contentType: "application/json", headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), - }), - ) + body: JSON.stringify({ location: ptyLocation(), data: { ticket: "e2e-ticket", expires_in: 60 } }), + }) + }) const connections: string[] = [] - await page.routeWebSocket(new RegExp(`/pty/${ptyID}/connect`), (ws) => { + await page.routeWebSocket(new RegExp(`/api/pty/${ptyID}/connect`), (ws) => { connections.push(ws.url()) }) @@ -143,3 +155,11 @@ function session(id: string, title: string, created: number) { function sessionHref(sessionID: string) { return `/server/${base64Encode(server)}/session/${sessionID}` } + +function ptyLocation() { + return { directory, project: { id: projectID, directory } } +} + +function ptyInfo() { + return { id: ptyID, title: "Terminal 1", command: "cmd.exe", args: [], cwd: directory, status: "running", pid: 1 } +} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3beb97225a..3ab265d729 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -127,11 +127,13 @@ export const SettingsGeneral: Component = () => { const serverSdk = useServerSDK() const [shells] = createResource( - () => - serverSdk() - .client.pty.shells() - .then((res) => res.data ?? []) - .catch(() => [] as ShellOption[]), + async () => { + const sdk = serverSdk() + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.shells()).data ?? [] + } + return (await sdk.api.pty.shells()).data + }, { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index ed2328c89a..5a4cb186a6 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -122,11 +122,13 @@ export const SettingsGeneralV2: Component<{ const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) const [shells] = createResource( - () => - serverSdk() - .client.pty.shells() - .then((res) => res.data ?? []) - .catch(() => [] as ShellOption[]), + async () => { + const sdk = serverSdk() + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.shells()).data ?? [] + } + return (await sdk.api.pty.shells()).data + }, { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index c512e782c4..df2827b239 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -178,7 +178,6 @@ export const Terminal = (props: TerminalProps) => { // Terminal captures its connection for the PTY lifetime, so callers must key it per server/session. const connection = useServerSDK()().server const directory = sdk().directory - const client = sdk().client const url = sdk().url const auth = connection.http const username = auth?.username ?? "opencode" @@ -241,10 +240,21 @@ export const Terminal = (props: TerminalProps) => { } } - const pushSize = (cols: number, rows: number) => { - return client.pty + const pushSize = async (cols: number, rows: number) => { + if ((await sdk().protocol) === "v1") { + return sdk().client.pty + .update({ + ptyID: id, + size: { cols, rows }, + }) + .catch((err) => { + debugTerminal("failed to sync terminal size", err) + }) + } + return sdk().api.pty .update({ ptyID: id, + location: { directory }, size: { cols, rows }, }) .catch((err) => { @@ -522,34 +532,60 @@ export const Terminal = (props: TerminalProps) => { local.onConnectError?.(err) } - const gone = () => - client.pty - .get({ ptyID: id }, { throwOnError: false }) - .then((result) => result.response.status === 404) + const gone = async () => { + if ((await sdk().protocol) === "v1") { + return sdk().client.pty + .get({ ptyID: id }, { throwOnError: false }) + .then((result) => result.response.status === 404) + .catch((err) => { + debugTerminal("failed to inspect terminal session", err) + return false + }) + } + return sdk().api.pty + .get({ ptyID: id, location: { directory } }) + .then((result) => result.data.status === "exited") .catch((err) => { + if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true debugTerminal("failed to inspect terminal session", err) return false }) + } const connectToken = async () => { - const result = await client.pty - .connectToken( - { ptyID: id, directory }, - { - throwOnError: false, - headers: { "x-opencode-ticket": "1" }, - }, - ) + if ((await sdk().protocol) === "v1") { + const result = await sdk().client.pty + .connectToken( + { ptyID: id, directory }, + { + throwOnError: false, + headers: { "x-opencode-ticket": "1" }, + }, + ) + .catch((err: unknown) => { + if (err instanceof Error && err.message.includes("Request is not supported")) return + throw err + }) + if (!result) return + if (result.response.status === 200 && result.data?.ticket) return result.data.ticket + if (result.response.status === 404 || result.response.status === 405) return + if (result.response.status === 403) + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + throw new Error(`PTY connect ticket failed with ${result.response.status}`) + } + return sdk().api.pty + .connectToken({ + ptyID: id, + location: { directory }, + "x-opencode-ticket": "1", + }) + .then((result) => result.data.ticket) .catch((err: unknown) => { - if (err instanceof Error && err.message.includes("Request is not supported")) return + if (err && typeof err === "object" && "_tag" in err && err._tag === "ForbiddenError") { + throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") + } throw err }) - if (!result) return - if (result.response.status === 200 && result.data?.ticket) return result.data.ticket - if (result.response.status === 404 || result.response.status === 405) return - if (result.response.status === 403) - throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") - throw new Error(`PTY connect ticket failed with ${result.response.status}`) } const retry = (err: unknown) => { @@ -579,11 +615,14 @@ export const Terminal = (props: TerminalProps) => { fail(err) return undefined }) + const protocol = await sdk().protocol + if (protocol === "v2" && !ticket) return if (once.value) return if (disposed) return const socket = new WebSocket( terminalWebSocketURL({ + protocol, url, id, directory, diff --git a/packages/app/src/context/server-sdk.tsx b/packages/app/src/context/server-sdk.tsx index 4c879603bd..7dd2a6e59e 100644 --- a/packages/app/src/context/server-sdk.tsx +++ b/packages/app/src/context/server-sdk.tsx @@ -424,6 +424,7 @@ function createDirSdkContext(directory: string, serverSDK: ServerSDKBase) { return { scope: serverSDK.scope, + protocol: serverSDK.protocol, directory, client, api: createCompatibleApi({ diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index d2d616248d..906f2436d5 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -149,6 +149,7 @@ function createWorkspaceTerminalSession( scope: ServerScopeValue, legacySessionID?: string, ) { + const location = { directory: sdk.directory } const legacy = scope === ServerScope.local ? getLegacyTerminalStorageKeys(dir, legacySessionID) : [] const [store, setStore, _, ready] = persisted( @@ -240,47 +241,61 @@ function createWorkspaceTerminalSession( }) onCleanup(unsub) - const update = (client: DirectorySDK["client"], pty: Partial & { id: string }) => { + const update = (pty: Partial & { id: string }) => { const index = store.all.findIndex((x) => x.id === pty.id) const previous = index >= 0 ? store.all[index] : undefined if (index >= 0) { setStore("all", index, (item) => ({ ...item, ...pty })) } - client.pty - .update({ - ptyID: pty.id, - title: pty.title, - size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, - }) - .catch((error: unknown) => { - if (previous) { - const currentIndex = store.all.findIndex((item) => item.id === pty.id) - if (currentIndex >= 0) setStore("all", currentIndex, previous) - } - console.error("Failed to update terminal", error) - }) + const doUpdate = async () => { + if ((await sdk.protocol) === "v1") { + await sdk.client.pty.update({ + ptyID: pty.id, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + } else { + await sdk.api.pty.update({ + ptyID: pty.id, + location, + title: pty.title, + size: pty.cols && pty.rows ? { rows: pty.rows, cols: pty.cols } : undefined, + }) + } + } + doUpdate().catch((error: unknown) => { + if (previous) { + const currentIndex = store.all.findIndex((item) => item.id === pty.id) + if (currentIndex >= 0) setStore("all", currentIndex, previous) + } + console.error("Failed to update terminal", error) + }) } - const clone = async (client: DirectorySDK["client"], id: string) => { + const clone = async (id: string) => { const index = store.all.findIndex((x) => x.id === id) const pty = store.all[index] if (!pty) return - const next = await client.pty - .create({ + const data = await (async () => { + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.create({ title: pty.title })).data + } + return (await sdk.api.pty.create({ + location, title: pty.title, - }) - .catch((error: unknown) => { - console.error("Failed to clone terminal", error) - return undefined - }) - if (!next?.data) return + })).data + })().catch((error: unknown) => { + console.error("Failed to clone terminal", error) + return undefined + }) + if (!data?.id) return const active = store.active === pty.id batch(() => { setStore("all", index, { - id: next.data.id, - title: next.data.title ?? pty.title, + id: data.id, + title: data.title ?? pty.title, titleNumber: pty.titleNumber, buffer: undefined, cursor: undefined, @@ -289,7 +304,7 @@ function createWorkspaceTerminalSession( cols: undefined, }) if (active) { - setStore("active", next.data.id) + setStore("active", data.id) } }) } @@ -308,17 +323,22 @@ function createWorkspaceTerminalSession( const nextNumber = pickNextTerminalNumber() const focusRequest = options?.focus ? requestFocus(undefined, true) : undefined - sdk.client.pty - .create({ title: defaultTitle(nextNumber) }) - .then((pty: { data?: { id?: string; title?: string } }) => { - const id = pty.data?.id + const doCreate = async () => { + if ((await sdk.protocol) === "v1") { + return (await sdk.client.pty.create({ title: defaultTitle(nextNumber) })).data + } + return (await sdk.api.pty.create({ location, title: defaultTitle(nextNumber) })).data + } + doCreate() + .then((data) => { + const id = data?.id if (!id) { if (focusRequest !== undefined) cancelFocus(focusRequest) return } const newTerminal = { id, - title: pty.data?.title ?? defaultTitle(nextNumber), + title: data?.title ?? defaultTitle(nextNumber), titleNumber: nextNumber, } batch(() => { @@ -335,7 +355,7 @@ function createWorkspaceTerminalSession( }) }, update(pty: Partial & { id: string }) { - update(sdk.client, pty) + update(pty) }, trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -350,10 +370,9 @@ function createWorkspaceTerminalSession( }) }, async clone(id: string) { - await clone(sdk.client, id) + await clone(id) }, bind() { - const client = sdk.client return { trim(id: string) { const index = store.all.findIndex((x) => x.id === id) @@ -361,10 +380,10 @@ function createWorkspaceTerminalSession( setStore("all", index, (pty) => trimTerminal(pty)) }, update(pty: Partial & { id: string }) { - update(client, pty) + update(pty) }, async clone(id: string) { - await clone(client, id) + await clone(id) }, } }, @@ -412,7 +431,9 @@ function createWorkspaceTerminalSession( }) } - await sdk.client.pty.remove({ ptyID: id }).catch((error: unknown) => { + const removePromise = + (await sdk.protocol) === "v1" ? sdk.client.pty.remove({ ptyID: id }) : sdk.api.pty.remove({ ptyID: id, location }) + await removePromise.catch((error: unknown) => { console.error("Failed to close terminal", error) }) }, diff --git a/packages/app/src/pages/session/review-tab.tsx b/packages/app/src/pages/session/review-tab.tsx index 3854bf0276..1b65af7121 100644 --- a/packages/app/src/pages/session/review-tab.tsx +++ b/packages/app/src/pages/session/review-tab.tsx @@ -1,6 +1,7 @@ import { createEffect, onCleanup, type JSX } from "solid-js" import { makeEventListener } from "@solid-primitives/event-listener" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SessionReview } from "@opencode-ai/session-ui/session-review" import type { SessionReviewCommentActions, @@ -14,7 +15,7 @@ import type { LineComment } from "@/context/comments" export type DiffStyle = "unified" | "split" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff export interface SessionReviewTabProps { title?: JSX.Element diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 0a741ef98b..22c52e73fe 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -24,6 +24,7 @@ import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { KeybindV2 } from "@opencode-ai/ui/v2/keybind-v2" import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { ConstrainDragYAxis, getDraggableId } from "@/utils/solid-dnd" import { useDialog } from "@opencode-ai/ui/context/dialog" @@ -56,15 +57,16 @@ import { setSessionHandoff } from "@/pages/session/handoff" import { useSessionLayout } from "@/pages/session/session-layout" import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/session/v2/session-file-browser-tab" -type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff +type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff -function renderDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" } export function SessionSidePanel(props: { canReview: () => boolean - diffs: () => (SnapshotFileDiff | VcsFileDiff)[] + diffs: () => ReviewDiff[] diffsReady: () => boolean empty: () => string hasReview: () => boolean diff --git a/packages/app/src/pages/session/v2/review-diff-kinds.ts b/packages/app/src/pages/session/v2/review-diff-kinds.ts index 49cec334bc..d3adb1f2ff 100644 --- a/packages/app/src/pages/session/v2/review-diff-kinds.ts +++ b/packages/app/src/pages/session/v2/review-diff-kinds.ts @@ -1,14 +1,15 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Kind } from "@/components/file-tree-v2" import { normalizeFileTreeV2Path } from "@/components/file-tree-v2-model" -export type RenderDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +export type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff export function normalizePath(p: string) { return normalizeFileTreeV2Path(p) } -export function filterRenderableDiff(value: SnapshotFileDiff | VcsFileDiff): value is RenderDiff { +export function filterRenderableDiff(value: FileDiffInfo | SnapshotFileDiff | VcsFileDiff): value is RenderDiff { return typeof value.file === "string" } diff --git a/packages/app/src/pages/session/v2/review-panel-v2.tsx b/packages/app/src/pages/session/v2/review-panel-v2.tsx index 4f0cf612e1..fcd6bbb79f 100644 --- a/packages/app/src/pages/session/v2/review-panel-v2.tsx +++ b/packages/app/src/pages/session/v2/review-panel-v2.tsx @@ -1,5 +1,6 @@ import { createMemo, createResource, createSignal, Show, type JSX } from "solid-js" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { SESSION_REVIEW_V2_SIDEBAR_WIDTH_MAX, SESSION_REVIEW_V2_SIDEBAR_WIDTH_MIN, @@ -30,7 +31,7 @@ import { import type { ReviewPanelV2State } from "@/pages/session/v2/review-panel-v2-state" import { applyFileListKeyDown, SessionFileListV2 } from "@/pages/session/v2/session-file-list-v2" -type ReviewDiff = SnapshotFileDiff | VcsFileDiff +type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff export type ReviewPanelV2Props = { title?: JSX.Element diff --git a/packages/app/src/utils/diffs.test.ts b/packages/app/src/utils/diffs.test.ts index 5fbca469b7..a3d25f4279 100644 --- a/packages/app/src/utils/diffs.test.ts +++ b/packages/app/src/utils/diffs.test.ts @@ -1,5 +1,6 @@ import { describe, expect, test } from "bun:test" import type { SnapshotFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" import { diffs, message } from "./diffs" @@ -9,7 +10,7 @@ const item = { additions: 1, deletions: 1, status: "modified", -} satisfies SnapshotFileDiff +} satisfies FileDiffInfo & SnapshotFileDiff describe("diffs", () => { test("keeps valid arrays", () => { diff --git a/packages/app/src/utils/diffs.ts b/packages/app/src/utils/diffs.ts index 0cb2504fbe..a8eec75a9a 100644 --- a/packages/app/src/utils/diffs.ts +++ b/packages/app/src/utils/diffs.ts @@ -1,7 +1,8 @@ import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { Message } from "@opencode-ai/sdk/v2/client" -type Diff = SnapshotFileDiff | VcsFileDiff +type Diff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff function diff(value: unknown): value is Diff { if (!value || typeof value !== "object" || Array.isArray(value)) return false diff --git a/packages/app/src/utils/terminal-websocket-url.test.ts b/packages/app/src/utils/terminal-websocket-url.test.ts index 5fa1506b1e..aac854ca82 100644 --- a/packages/app/src/utils/terminal-websocket-url.test.ts +++ b/packages/app/src/utils/terminal-websocket-url.test.ts @@ -2,8 +2,28 @@ import { describe, expect, test } from "bun:test" import { terminalWebSocketURL } from "./terminal-websocket-url" describe("terminalWebSocketURL", () => { - test("uses query auth without embedding credentials in websocket URL", () => { + test("uses the current ticketed PTY route", () => { const url = terminalWebSocketURL({ + url: "http://127.0.0.1:49365", + id: "pty_test", + directory: "/tmp/project", + cursor: 0, + ticket: "connect-ticket", + }) + + expect(url.protocol).toBe("ws:") + expect(url.username).toBe("") + expect(url.password).toBe("") + expect(url.pathname).toBe("/api/pty/pty_test/connect") + expect(url.searchParams.get("location[directory]")).toBe("/tmp/project") + expect(url.searchParams.get("cursor")).toBe("0") + expect(url.searchParams.get("ticket")).toBe("connect-ticket") + expect(url.searchParams.has("auth_token")).toBe(false) + }) + + test("uses query auth without embedding credentials in websocket URL for v1", () => { + const url = terminalWebSocketURL({ + protocol: "v1", url: "http://127.0.0.1:49365", id: "pty_test", directory: "/tmp/project", @@ -16,11 +36,14 @@ describe("terminalWebSocketURL", () => { expect(url.protocol).toBe("ws:") expect(url.username).toBe("") expect(url.password).toBe("") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) - test("omits query auth for same-origin saved credentials", () => { + test("omits query auth for same-origin saved credentials for v1", () => { const url = terminalWebSocketURL({ + protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -31,11 +54,14 @@ describe("terminalWebSocketURL", () => { }) expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.has("auth_token")).toBe(false) }) - test("uses query auth for same-origin credentials from auth_token", () => { + test("uses query auth for same-origin credentials from auth_token for v1", () => { const url = terminalWebSocketURL({ + protocol: "v1", url: "https://app.example.test", id: "pty_test", directory: "/tmp/project", @@ -47,6 +73,8 @@ describe("terminalWebSocketURL", () => { }) expect(url.protocol).toBe("wss:") + expect(url.pathname).toBe("/pty/pty_test/connect") + expect(url.searchParams.get("directory")).toBe("/tmp/project") expect(url.searchParams.get("auth_token")).toBe(btoa("opencode:secret")) }) }) diff --git a/packages/app/src/utils/terminal-websocket-url.ts b/packages/app/src/utils/terminal-websocket-url.ts index 06facdc7d2..a32b239cc9 100644 --- a/packages/app/src/utils/terminal-websocket-url.ts +++ b/packages/app/src/utils/terminal-websocket-url.ts @@ -1,6 +1,7 @@ import { authTokenFromCredentials } from "@/utils/server" export function terminalWebSocketURL(input: { + protocol?: "v1" | "v2" url: string id: string directory: string @@ -11,18 +12,24 @@ export function terminalWebSocketURL(input: { password?: string authToken?: boolean }) { - const next = new URL(`${input.url}/pty/${input.id}/connect`) - next.searchParams.set("directory", input.directory) + const isV1 = input.protocol === "v1" + const next = new URL(`${input.url}${isV1 ? `/pty/${input.id}/connect` : `/api/pty/${input.id}/connect`}`) + if (isV1) { + next.searchParams.set("directory", input.directory) + } else { + next.searchParams.set("location[directory]", input.directory) + } next.searchParams.set("cursor", String(input.cursor)) next.protocol = next.protocol === "https:" ? "wss:" : "ws:" if (input.ticket) { next.searchParams.set("ticket", input.ticket) return next } - if (input.password && (!input.sameOrigin || input.authToken)) + if (isV1 && input.password && (!input.sameOrigin || input.authToken)) { next.searchParams.set( "auth_token", authTokenFromCredentials({ username: input.username, password: input.password }), ) + } return next } diff --git a/packages/session-ui/src/components/session-diff.ts b/packages/session-ui/src/components/session-diff.ts index 48e8eee310..2fbd022235 100644 --- a/packages/session-ui/src/components/session-diff.ts +++ b/packages/session-ui/src/components/session-diff.ts @@ -1,6 +1,7 @@ import { parseDiffFromFile, parsePatchFiles, type FileDiffMetadata } from "@pierre/diffs" import { parsePatch } from "diff" import type { SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" type LegacyDiff = { file: string @@ -13,7 +14,7 @@ type LegacyDiff = { } type SnapshotDiff = SnapshotFileDiff & { file: string } -type ReviewDiff = SnapshotDiff | VcsFileDiff | LegacyDiff +type ReviewDiff = SnapshotDiff | FileDiffInfo | VcsFileDiff | LegacyDiff export type DiffSource = Pick export type ViewDiff = { diff --git a/packages/session-ui/src/components/session-review.tsx b/packages/session-ui/src/components/session-review.tsx index 8db21f025b..1585a8aa32 100644 --- a/packages/session-ui/src/components/session-review.tsx +++ b/packages/session-ui/src/components/session-review.tsx @@ -16,6 +16,7 @@ import { checksum } from "@opencode-ai/core/util/encode" import { createEffect, createMemo, For, Match, onCleanup, Show, Switch, untrack, type JSX } from "solid-js" import { createStore } from "solid-js/store" import { type FileContent, type SnapshotFileDiff, type VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" import { type SelectedLineRange } from "@pierre/diffs" import { Dynamic } from "solid-js/web" @@ -62,10 +63,10 @@ export type SessionReviewCommentActions = { export type SessionReviewFocus = { file: string; id: string } -type RawReviewDiff = (SnapshotFileDiff | VcsFileDiff) & { +type RawReviewDiff = (SnapshotFileDiff | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } -type ReviewDiff = ((SnapshotFileDiff & { file: string }) | VcsFileDiff) & { +type ReviewDiff = ((SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff) & { preloaded?: PreloadMultiFileDiffResult } type Item = ViewDiff & { preloaded?: PreloadMultiFileDiffResult } diff --git a/packages/session-ui/src/components/session-turn.tsx b/packages/session-ui/src/components/session-turn.tsx index 75274fc50e..301a74d3f0 100644 --- a/packages/session-ui/src/components/session-turn.tsx +++ b/packages/session-ui/src/components/session-turn.tsx @@ -4,6 +4,7 @@ import { Message as MessageType, Part as PartType, } from "@opencode-ai/sdk/v2/client" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import type { SessionStatus } from "@opencode-ai/sdk/v2" import { useData } from "../context" import { useFileComponent } from "@opencode-ai/ui/context/file" @@ -90,7 +91,7 @@ function list(value: T[] | undefined | null, fallback: T[]) { return fallback } -type SummaryDiff = SnapshotFileDiff & { file: string } +type SummaryDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo function summaryDiff(value: SnapshotFileDiff): value is SummaryDiff { return typeof value.file === "string" diff --git a/packages/session-ui/src/context/data.tsx b/packages/session-ui/src/context/data.tsx index 999ff510d5..056fc9c0fd 100644 --- a/packages/session-ui/src/context/data.tsx +++ b/packages/session-ui/src/context/data.tsx @@ -1,4 +1,5 @@ import type { Message, Session, Part, SnapshotFileDiff, SessionStatus, Provider } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createSimpleContext } from "@opencode-ai/ui/context" import { PreloadMultiFileDiffResult } from "@pierre/diffs/ssr" @@ -21,7 +22,7 @@ type Data = { [sessionID: string]: SessionStatus } session_diff: { - [sessionID: string]: SnapshotFileDiff[] + [sessionID: string]: (SnapshotFileDiff | FileDiffInfo)[] } session_diff_preload?: { [sessionID: string]: PreloadMultiFileDiffResult[] diff --git a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx index 9f42cd90fe..ba276a8f52 100644 --- a/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-file-preview-v2.tsx @@ -7,6 +7,7 @@ import { useI18n } from "@opencode-ai/ui/context/i18n" import { mediaKindFromPath } from "../../pierre/media" import { cloneSelectedLineRange, previewSelectedLines } from "../../pierre/selection-bridge" import type { FileContent, SnapshotFileDiff, VcsFileDiff } from "@opencode-ai/sdk/v2" +import type { FileDiffInfo } from "@opencode-ai/client/promise" import { createEffect, createMemo, onCleanup, Show, untrack } from "solid-js" import { createStore } from "solid-js/store" import { Dynamic } from "solid-js/web" @@ -27,7 +28,7 @@ import { LineCommentV2OverflowIcon } from "@opencode-ai/ui/v2/line-comment-v2" import { MenuV2 } from "@opencode-ai/ui/v2/menu-v2" import "./session-review-v2.css" -type ReviewDiff = (SnapshotFileDiff & { file: string }) | VcsFileDiff +type ReviewDiff = (SnapshotFileDiff & { file: string }) | FileDiffInfo | VcsFileDiff export type SessionReviewFilePreviewV2Props = { file: string From ae4be983cbec7b8275efaab63572e279471694a7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 10:22:28 +0000 Subject: [PATCH 42/48] chore: generate --- .../remote-session-settings.spec.ts | 3 ++- packages/app/src/components/terminal.tsx | 24 +++++++++---------- packages/app/src/context/terminal.tsx | 14 +++++++---- 3 files changed, 23 insertions(+), 18 deletions(-) diff --git a/packages/app/e2e/regression/remote-session-settings.spec.ts b/packages/app/e2e/regression/remote-session-settings.spec.ts index 4f6d57aa2e..35a0aa44cd 100644 --- a/packages/app/e2e/regression/remote-session-settings.spec.ts +++ b/packages/app/e2e/regression/remote-session-settings.spec.ts @@ -181,7 +181,8 @@ async function mockServers(page: Page, permissionRequests: string[], permissionR return json(route, true) } if (requestDirectory && requestDirectory !== directory) return json(route, { name: "InvalidDirectory" }, 500) - if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") return sse(route) + if (url.pathname === "/global/event" || url.pathname === "/event" || url.pathname === "/api/event") + return sse(route) if (url.pathname === "/global/health") return json(route, { healthy: true }) if (url.pathname === "/api/provider" || url.pathname === "/api/model" || url.pathname === "/api/agent") return json(route, { data: [] }) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index df2827b239..b2e827f73a 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -242,8 +242,8 @@ export const Terminal = (props: TerminalProps) => { const pushSize = async (cols: number, rows: number) => { if ((await sdk().protocol) === "v1") { - return sdk().client.pty - .update({ + return sdk() + .client.pty.update({ ptyID: id, size: { cols, rows }, }) @@ -251,8 +251,8 @@ export const Terminal = (props: TerminalProps) => { debugTerminal("failed to sync terminal size", err) }) } - return sdk().api.pty - .update({ + return sdk() + .api.pty.update({ ptyID: id, location: { directory }, size: { cols, rows }, @@ -534,16 +534,16 @@ export const Terminal = (props: TerminalProps) => { const gone = async () => { if ((await sdk().protocol) === "v1") { - return sdk().client.pty - .get({ ptyID: id }, { throwOnError: false }) + return sdk() + .client.pty.get({ ptyID: id }, { throwOnError: false }) .then((result) => result.response.status === 404) .catch((err) => { debugTerminal("failed to inspect terminal session", err) return false }) } - return sdk().api.pty - .get({ ptyID: id, location: { directory } }) + return sdk() + .api.pty.get({ ptyID: id, location: { directory } }) .then((result) => result.data.status === "exited") .catch((err) => { if (err && typeof err === "object" && "_tag" in err && err._tag === "PtyNotFoundError") return true @@ -554,8 +554,8 @@ export const Terminal = (props: TerminalProps) => { const connectToken = async () => { if ((await sdk().protocol) === "v1") { - const result = await sdk().client.pty - .connectToken( + const result = await sdk() + .client.pty.connectToken( { ptyID: id, directory }, { throwOnError: false, @@ -573,8 +573,8 @@ export const Terminal = (props: TerminalProps) => { throw new Error("PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.") throw new Error(`PTY connect ticket failed with ${result.response.status}`) } - return sdk().api.pty - .connectToken({ + return sdk() + .api.pty.connectToken({ ptyID: id, location: { directory }, "x-opencode-ticket": "1", diff --git a/packages/app/src/context/terminal.tsx b/packages/app/src/context/terminal.tsx index 906f2436d5..df575b71f7 100644 --- a/packages/app/src/context/terminal.tsx +++ b/packages/app/src/context/terminal.tsx @@ -280,10 +280,12 @@ function createWorkspaceTerminalSession( if ((await sdk.protocol) === "v1") { return (await sdk.client.pty.create({ title: pty.title })).data } - return (await sdk.api.pty.create({ - location, - title: pty.title, - })).data + return ( + await sdk.api.pty.create({ + location, + title: pty.title, + }) + ).data })().catch((error: unknown) => { console.error("Failed to clone terminal", error) return undefined @@ -432,7 +434,9 @@ function createWorkspaceTerminalSession( } const removePromise = - (await sdk.protocol) === "v1" ? sdk.client.pty.remove({ ptyID: id }) : sdk.api.pty.remove({ ptyID: id, location }) + (await sdk.protocol) === "v1" + ? sdk.client.pty.remove({ ptyID: id }) + : sdk.api.pty.remove({ ptyID: id, location }) await removePromise.catch((error: unknown) => { console.error("Failed to close terminal", error) }) From b62806683eead4a47cc89029ea6085b4cb7a06c1 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:28:09 +0800 Subject: [PATCH 43/48] fix(app): preserve inline file mentions (#38663) --- .../components/prompt-input/submit.test.ts | 3 ++ .../app/src/components/prompt-input/submit.ts | 1 + packages/app/src/utils/server-compat.test.ts | 50 +++++++++++++++++-- packages/app/src/utils/server-compat.ts | 14 ++++-- .../app/src/utils/session-message.test.ts | 18 ++++++- packages/app/src/utils/session-message.ts | 7 +++ .../src/components/message-file.test.ts | 24 ++++----- .../session-ui/src/components/message-file.ts | 3 +- 8 files changed, 98 insertions(+), 22 deletions(-) diff --git a/packages/app/src/components/prompt-input/submit.test.ts b/packages/app/src/components/prompt-input/submit.test.ts index ac06916464..b3201b3ef6 100644 --- a/packages/app/src/components/prompt-input/submit.test.ts +++ b/packages/app/src/components/prompt-input/submit.test.ts @@ -489,6 +489,9 @@ describe("prompt submit worktree selection", () => { agents: [], }) expect((promptInputs[0] as { id?: string }).id).toStartWith("msg_") + expect((promptInputs[0] as { legacyParts?: { id: string; type: string; text?: string }[] }).legacyParts).toEqual([ + { id: expect.stringMatching(/^prt_/), type: "text", text: "ls" }, + ]) }) test("submits slash commands through the current session API", async () => { diff --git a/packages/app/src/components/prompt-input/submit.ts b/packages/app/src/components/prompt-input/submit.ts index 2cd30da3ef..051bf4d06c 100644 --- a/packages/app/src/components/prompt-input/submit.ts +++ b/packages/app/src/components/prompt-input/submit.ts @@ -162,6 +162,7 @@ export async function sendFollowupDraft(input: FollowupSendInput) { agent: input.draft.agent, model: input.draft.model, variant: input.draft.variant, + legacyParts: requestParts, text: requestParts.flatMap((part) => (part.type === "text" ? [part.text] : [])).join("\n"), files: requestParts.flatMap((part) => { if (part.type !== "file") return [] diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index f46c5f86e0..3f4b8f2205 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -69,18 +69,62 @@ describe("createCompatibleApi", () => { await api.session.prompt({ sessionID: "ses_1", id: "msg_1", - text: "hello", + text: "hello @src/index.ts", agent: "build", model: { providerID: "provider", modelID: "model" }, + files: [ + { uri: "file:///repo/src/index.ts", name: "index.ts", mention: { text: "@src/index.ts", start: 6, end: 19 } }, + { uri: "data:text/plain;base64,aGVsbG8=", name: "notes.txt" }, + ], }) expect(new URL(requests[0]!.url).pathname).toBe("/session/ses_1/prompt_async") - expect(await requests[0]!.json()).toMatchObject({ + const body = await requests[0]!.json() + expect(body).toMatchObject({ messageID: "msg_1", agent: "build", model: { providerID: "provider", modelID: "model" }, - parts: [{ type: "text", text: "hello" }], + parts: [ + { type: "text", text: "hello @src/index.ts" }, + { + type: "file", + mime: "text/plain", + url: "file:///repo/src/index.ts", + filename: "index.ts", + source: { + type: "file", + text: { value: "@src/index.ts", start: 6, end: 19 }, + path: "file:///repo/src/index.ts", + }, + }, + { + type: "file", + mime: "text/plain", + url: "data:text/plain;base64,aGVsbG8=", + filename: "notes.txt", + }, + ], }) + expect(body.parts[2]).not.toHaveProperty("source") + }) + + test("preserves original parts for V1 optimistic reconciliation", async () => { + const { api, requests } = setup("v1") + await api.session.prompt({ + sessionID: "ses_1", + id: "msg_1", + text: "look", + files: [{ uri: "data:image/png;base64,AAAA", name: "image.png" }], + legacyParts: [ + { id: "prt_text", type: "text", text: "look" }, + { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, + ], + }) + + expect((await requests[0]!.json()).parts).toEqual([ + { id: "prt_text", type: "text", text: "look" }, + { id: "prt_image", type: "file", mime: "image/png", url: "data:image/png;base64,AAAA", filename: "image.png" }, + ]) }) test("keeps V2 session actions on the current API", async () => { diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index ec4b5ede3d..72e74438ed 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -1,6 +1,6 @@ import type { ServerApi } from "./server" import type { ServerProtocol } from "./server-protocol" -import type { OpencodeClient, Session } from "@opencode-ai/sdk/v2/client" +import type { AgentPartInput, FilePartInput, OpencodeClient, Session, TextPartInput } from "@opencode-ai/sdk/v2/client" import type { Project, ProjectCurrent, @@ -43,6 +43,7 @@ type LegacyPrompt = { agent?: string model?: { providerID: string; modelID: string } variant?: string + legacyParts?: (TextPartInput | FilePartInput | AgentPartInput)[] } type LegacyLocation = { directory?: string } type CompatibleInput = { @@ -203,13 +204,20 @@ function createV1Api(input: CompatibleInput): CompatibleApi { agent: value.agent, model: value.model, variant: value.variant, - parts: [ + parts: value.legacyParts ?? [ { type: "text", text: value.text }, ...(value.files ?? []).map((file) => ({ type: "file" as const, - mime: mime(file.uri), + mime: file.mention ? "text/plain" : mime(file.uri), url: file.uri, filename: file.name, + source: file.mention + ? { + type: "file" as const, + text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, + path: file.uri, + } + : undefined, })), ...(value.agents ?? []).map((agent) => ({ type: "agent" as const, diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index 4f55f3f2c6..d998c0c045 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -15,7 +15,7 @@ describe("normalizeSessionMessages", () => { { id: "msg_3", type: "user", - text: "inspect this", + text: "inspect @src/client.ts", files: [ { data: "aGVsbG8=", @@ -23,6 +23,13 @@ describe("normalizeSessionMessages", () => { name: "note.txt", source: { type: "inline" }, }, + { + data: "ZXhwb3J0IHt9", + mime: "text/plain", + name: "client.ts", + source: { type: "inline" }, + mention: { text: "@src/client.ts", start: 8, end: 22 }, + }, ], agents: [{ name: "review", mention: { text: "@review", start: 0, end: 7 } }], time: { created: 3 }, @@ -76,9 +83,18 @@ describe("normalizeSessionMessages", () => { expect(result.parts.get("msg_3")?.map((part) => part.id)).toEqual([ "msg_3:text:0", "msg_3:file:0", + "msg_3:file:1", "msg_3:agent:0", "msg_5:compaction", ]) + expect(result.parts.get("msg_3")?.[2]).toMatchObject({ + type: "file", + source: { + type: "file", + path: "src/client.ts", + text: { value: "@src/client.ts", start: 8, end: 22 }, + }, + }) expect(result.parts.get("msg_4")?.map((part) => part.id)).toEqual(["msg_4:reasoning:0", "msg_4:text:0", "call_1"]) expect(result.parts.get("msg_4")?.[2]).toMatchObject({ type: "tool", diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index 71eebb864e..c67c6c717c 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -206,6 +206,13 @@ function userParts(sessionID: string, message: SessionMessageUser): Part[] { mime: file.mime, filename: file.name, url: file.source.type === "uri" ? file.source.uri : `data:${file.mime};base64,${file.data}`, + source: file.mention + ? { + type: "file", + text: { value: file.mention.text, start: file.mention.start, end: file.mention.end }, + path: file.mention.text.startsWith("@") ? file.mention.text.slice(1) : (file.name ?? file.mention.text), + } + : undefined, }), ), ...(message.agents ?? []).map( diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index 3882be027e..a769ae01bb 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -21,7 +21,7 @@ describe("message-file", () => { expect(attached(file())).toBe(false) }) - test("treats only non-attachment source ranges as inline references", () => { + test("keeps data-backed file mentions inline", () => { expect( inline( file({ @@ -34,18 +34,16 @@ describe("message-file", () => { ), ).toBe(true) - expect( - inline( - file({ - url: "data:text/plain;base64,SGVsbG8=", - source: { - type: "file", - path: "/repo/README.txt", - text: { value: "@README.txt", start: 0, end: 11 }, - }, - }), - ), - ).toBe(false) + const mentioned = file({ + url: "data:text/plain;base64,SGVsbG8=", + source: { + type: "file", + path: "/repo/README.txt", + text: { value: "@README.txt", start: 0, end: 11 }, + }, + }) + expect(inline(mentioned)).toBe(true) + expect(attached(mentioned)).toBe(false) }) test("separates image and file attachment kinds", () => { diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index 81ce97827f..e09269f33f 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -3,11 +3,10 @@ import { getFilename } from "@opencode-ai/core/util/path" import type { FilePart } from "@opencode-ai/sdk/v2" export function attached(part: FilePart) { - return part.url.startsWith("data:") + return part.url.startsWith("data:") && !inline(part) } export function inline(part: FilePart) { - if (attached(part)) return false return part.source?.text?.start !== undefined && part.source?.text?.end !== undefined } From 9ba82a1b8c67f251adbcf9ae0fe36b4e76a64236 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Fri, 24 Jul 2026 18:57:51 +0800 Subject: [PATCH 44/48] fix(app): gate legacy server features (#38651) Co-authored-by: opencode-agent[bot] --- .../src/components/dialog-custom-provider.tsx | 1 + .../app/src/components/settings-providers.tsx | 57 ++++++++++--------- .../src/components/settings-v2/providers.tsx | 57 ++++++++++--------- .../src/components/status-popover-body.tsx | 50 +++++++++------- 4 files changed, 91 insertions(+), 74 deletions(-) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 9e04cd83ad..5db684a2da 100644 --- a/packages/app/src/components/dialog-custom-provider.tsx +++ b/packages/app/src/components/dialog-custom-provider.tsx @@ -131,6 +131,7 @@ export function CustomProviderForm(props: { autofocus?: boolean } = {}) { const saveMutation = useMutation(() => ({ mutationFn: async (result: NonNullable>) => { + if ((await serverSDK().protocol) !== "v1") throw new Error("Custom providers are unavailable on this server") const disabledProviders = serverSync().data.config.disabled_providers ?? [] const nextDisabled = disabledProviders.filter((id) => id !== result.providerID) diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index bcd30edbc7..7a15d82eaf 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -6,7 +6,7 @@ import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" import { createMemo, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" -import { useServerSDK } from "@/context/server-sdk" +import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" import { DialogConnectProvider, useProviderConnectController } from "./dialog-connect-provider" import { DialogCustomProvider } from "./dialog-custom-provider" @@ -39,6 +39,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => const dialog = useDialog() const language = useLanguage() const serverSDK = useServerSDK() + const protocol = useServerProtocol() const serverSync = useServerSync() const providers = useProviders() const providerConnect = useProviderConnectController({ onBack: props.onBack }) @@ -83,7 +84,8 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => return language.t("settings.providers.tag.other") } - const canDisconnect = (item: ProviderItem) => source(item) !== "env" + const canDisconnect = (item: ProviderItem) => + source(item) !== "env" && (protocol() === "v1" || !isConfigCustom(item.id)) const note = (id: string) => PROVIDER_NOTES.find((item) => item.match(id))?.key @@ -96,6 +98,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => } const disableProvider = async (providerID: string, name: string) => { + if (protocol() !== "v1") return const before = serverSync().data.config.disabled_providers ?? [] const next = before.includes(providerID) ? before : [...before, providerID] serverSync().set("config", "disabled_providers", next) @@ -218,31 +221,33 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => )} -
    -
    -
    - - {language.t("provider.custom.title")} - {language.t("settings.providers.tag.custom")} -
    - - {language.t("settings.providers.custom.description")} - -
    - -
    +
    +
    + + {language.t("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
    + + {language.t("settings.providers.custom.description")} + +
    + +
    +