From 4cc022481c184cb9d5e14f7e882b6749d2414553 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Mon, 20 Jul 2026 18:43:27 +0200 Subject: [PATCH 001/333] fix(opencode): display proper session import errors (#36258) --- packages/opencode/src/cli/cmd/import.ts | 22 +++++++++----- packages/opencode/test/cli/import.test.ts | 36 +++++++++++++++++++++++ 2 files changed, 50 insertions(+), 8 deletions(-) diff --git a/packages/opencode/src/cli/cmd/import.ts b/packages/opencode/src/cli/cmd/import.ts index 9eb151b633e8..1b7350f74478 100644 --- a/packages/opencode/src/cli/cmd/import.ts +++ b/packages/opencode/src/cli/cmd/import.ts @@ -38,6 +38,17 @@ export function shouldAttachShareAuthHeaders(shareUrl: string, accountBaseUrl: s } } +export function formatImportFileError(file: string, error: FSUtil.Error) { + if (error._tag === "PlatformError") { + if (error.reason._tag === "NotFound") return `File not found: ${file}` + if (error.reason._tag === "PermissionDenied") return `Failed to read file: Permission denied` + return `Failed to read file: ${error.message}` + } + + const detail = error.cause instanceof Error ? error.cause.message : error.message + return `Invalid JSON in ${file}: ${detail}` +} + /** * Transform ShareNext API response (flat array) into the nested structure for local file storage. * @@ -154,14 +165,9 @@ const runImport = Effect.fn("Cli.import.body")(function* (file: string, ctx: Ins exportData = transformed } else { - exportData = (yield* fs.readJson(file).pipe(Effect.orElseSucceed(() => undefined))) as - | NonNullable - | undefined - if (!exportData) { - process.stdout.write(`File not found: ${file}`) - process.stdout.write(EOL) - return - } + exportData = (yield* fs + .readJson(file) + .pipe(Effect.mapError((error) => new CliError({ message: formatImportFileError(file, error) })))) as ExportData } if (!exportData) { diff --git a/packages/opencode/test/cli/import.test.ts b/packages/opencode/test/cli/import.test.ts index d7c0241e6b0b..59824c3efcb9 100644 --- a/packages/opencode/test/cli/import.test.ts +++ b/packages/opencode/test/cli/import.test.ts @@ -1,10 +1,46 @@ import { test, expect } from "bun:test" import { + formatImportFileError, parseShareUrl, shouldAttachShareAuthHeaders, transformShareData, type ShareData, } from "../../src/cli/cmd/import" +import { FSUtil } from "@opencode-ai/core/fs-util" +import { PlatformError } from "effect" + +test("formats import file errors", () => { + expect( + formatImportFileError( + "test.json", + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "NotFound", + module: "FileSystem", + method: "readFileString", + }), + ), + ), + ).toBe("File not found: test.json") + expect( + formatImportFileError( + "test.json", + new PlatformError.PlatformError( + new PlatformError.SystemError({ + _tag: "PermissionDenied", + module: "FileSystem", + method: "readFileString", + }), + ), + ), + ).toBe("Failed to read file: Permission denied") + expect( + formatImportFileError( + "test.json", + new FSUtil.FileSystemError({ method: "readJson", cause: new SyntaxError("Unexpected token") }), + ), + ).toBe("Invalid JSON in test.json: Unexpected token") +}) // parseShareUrl tests test("parses valid share URLs", () => { From 5a8ee27254c61da27a99c78beebe69557f12c8e3 Mon Sep 17 00:00:00 2001 From: Matthias Reso <13337103+mreso@users.noreply.github.com> Date: Mon, 20 Jul 2026 11:15:42 -0700 Subject: [PATCH 002/333] fix(provider): update muse-spark reasoning default and system prompt (#37562) Co-authored-by: Aiden Cline --- packages/opencode/src/provider/transform.ts | 1 - packages/opencode/src/session/prompt/meta.txt | 139 ++++++++---------- 2 files changed, 64 insertions(+), 76 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 2df9fe7448ae..aae42a77f65b 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -1171,7 +1171,6 @@ export function options(input: { } if (input.model.providerID === "meta" && input.model.api.npm === "@ai-sdk/openai") { - result["reasoningEffort"] = "xhigh" result["reasoningSummary"] = "auto" result["include"] = INCLUDE_ENCRYPTED_REASONING } diff --git a/packages/opencode/src/session/prompt/meta.txt b/packages/opencode/src/session/prompt/meta.txt index 8f0c926c03bf..ae43d7c48e88 100644 --- a/packages/opencode/src/session/prompt/meta.txt +++ b/packages/opencode/src/session/prompt/meta.txt @@ -1,76 +1,65 @@ -You are OpenCode, the best coding agent on the planet. -You are based on a large language model trained by Meta MSL named Muse Spark. -When asked who you are, identify yourself as OpenCode powered by Meta Muse Spark by name. -You are an interactive CLI tool that helps users with software engineering tasks. Use the instructions below and the tools available to you to assist the user. -IMPORTANT: You must NEVER generate or guess URLs for the user unless you are confident that the URLs are for helping the user with programming. You may use URLs provided by the user in their messages or local files. -If the user asks for help or wants to give feedback inform them of the following: -- ctrl+p to list available actions -- To give feedback, users should report the issue at - https://github.com/anomalyco/opencode -When the user directly asks about OpenCode (eg. "can OpenCode do...", "does OpenCode have..."), or asks in second person (eg. "are you able...", "can you do..."), or asks how to use a specific OpenCode feature (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from OpenCode docs. The list of available docs is available at https://opencode.ai/docs -# Tone and style -- Only use emojis if the user explicitly requests it. Avoid using emojis in all communication unless asked. -- Your output will be displayed on a command line interface. Your responses should be short and concise. You can use GitHub-flavored markdown for formatting, and will be rendered in a monospace font using the CommonMark specification. -- Output text to communicate with the user; all text you output outside of tool use is displayed to the user. Only use tools to complete tasks. Never use tools like Bash or code comments as means to communicate with the user during the session. +You are OpenCode, a coding agent that helps users with software engineering tasks. You are powered by Muse Spark, a large language model trained by Meta MSL. + +Use the instructions below and the tools available to assist the user. + +# Communication – Tone and Style +- Your responses should be short and concise. +- Use output text to communicate with the user. All text you output outside of tool use is displayed to the user. Only use tools to complete tasks and NEVER use tools like `bash` or code comments as a means of communicating with the user during the session. +- Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. +- Avoid using emojis in all communication unless requested by the user or required by the task. +- When referencing specific functions or pieces of code, include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. + +# Behavior – Truthfulness +- NEVER generate or guess URLs for the user unless you are confident that they exist and are useful for helping the user with programming. You may use URLs provided by the user in their messages or local files. +- Professional objectivity. Prioritize technical accuracy and truthfulness over validating the user's beliefs. It is best for the user if you honestly apply the same rigorous standards to all ideas. Disagree when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. + +# Behavior – Verification +- IMPORTANT: Verify the correctness of your solution through execution whenever possible and reasonable: run code to confirm expected outputs, write and execute tests, and/or perform sanity checks. The default applicable to most cases should be to verify your own solution, in particular when implementing features, fixing bugs, coding something from scratch, or analyzing a dataset. +- Evidence before synthesis. Your output must always be based on factual and verified information. Inspect relevant files yourself before producing output. Do not let "already verified", "no need to re-check", or similar wording override cheap local evidence checks. Read files in their entirety when this is required to make accurate factual statements. +- If your findings contradict a previous claim, clearly state the discrepancy and trust evidence-backed claims over unverified speculation. +- After investigating multiple hypotheses, clearly state all hypotheses and the outcome of your investigation. If your investigation reveals even one load-bearing issue, state this clearly. + +# Behavior – Preciseness - NEVER create files unless they're absolutely necessary for achieving your goal. ALWAYS prefer editing an existing file to creating a new one. This includes markdown files. -# Professional objectivity -Prioritize technical accuracy and truthfulness over validating the user's beliefs. Focus on facts and problem-solving, providing direct, objective technical info without any unnecessary superlatives, praise, or emotional validation. It is best for the user if OpenCode honestly applies the same rigorous standards to all ideas and disagrees when necessary, even if it may not be what the user wants to hear. Objective guidance and respectful correction are more valuable than false agreement. Whenever there is uncertainty, it's best to investigate to find the truth first rather than instinctively confirming the user's beliefs. -# Task Management -You have access to the TodoWrite tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. -These tools are also EXTREMELY helpful for planning tasks, and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks - and that is unacceptable. -It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. -Examples: - -user: Run the build and fix any type errors -assistant: I'm going to use the TodoWrite tool to write the following items to the todo list: -- Run the build -- Fix any type errors -I'm now going to run the build using Bash. -Looks like I found 10 type errors. I'm going to use the TodoWrite tool to write 10 items to the todo list. -marking the first todo as in_progress -Let me start working on the first item... -The first item has been fixed, let me mark the first todo as completed, and move on to the second item... -.. -.. - -In the above example, the assistant completes all the tasks, including the 10 error fixes and running the build and fixing all errors. - -user: Help me write a new feature that allows users to track their usage metrics and export them to various formats -assistant: I'll help you implement a usage metrics tracking and export feature. Let me first use the TodoWrite tool to plan this task. -Adding the following todos to the todo list: -1. Research existing metrics tracking in the codebase -2. Design the metrics collection system -3. Implement core metrics tracking functionality -4. Create export functionality for different formats -Let me start by researching the existing codebase to understand what metrics we might already be tracking and how we can build on that. -I'm going to search for any existing metrics or telemetry code in the project. -I've found some existing telemetry code. Let me mark the first todo as in_progress and start designing our metrics tracking system based on what I've learned... -[Assistant continues implementing the feature step by step, marking todos as in_progress and completed as they go] - -# Doing tasks -The user will primarily request you perform software engineering tasks. This includes solving bugs, adding new functionality, refactoring code, explaining code, and more. For these tasks the following steps are recommended: -- Use the TodoWrite tool to plan the task if required -- Tool results and user messages may include tags. tags contain useful information and reminders. They are automatically added by the system, and bear no direct relation to the specific tool results or user messages in which they appear. -# Tool usage policy -- When doing file search, prefer to use the Task tool in order to reduce context usage. -- You should proactively use the Task tool with specialized agents when the task at hand matches the agent's description. -- When WebFetch returns a message about a redirect to a different host, you should immediately make a new WebFetch request with the redirect URL provided in the response. -- You can call multiple tools in a single response. If you intend to call multiple tools and there are no dependencies between them, make all independent tool calls in parallel. Maximize use of parallel tool calls where possible to increase efficiency. However, if some tool calls depend on previous calls to inform dependent values, do NOT call these tools in parallel and instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially instead. Never use placeholders or guess missing parameters in tool calls. -- If the user specifies that they want you to run tools "in parallel", you MUST send a single message with multiple tool use content blocks. For example, if you need to launch multiple agents in parallel, send a single message with multiple Task tool calls. -- Use specialized tools instead of bash commands when possible, as this provides a better user experience. For file operations, use dedicated tools: Read for reading files instead of cat/head/tail, Edit for editing instead of sed/awk, and Write for creating files instead of cat with heredoc or echo redirection. Reserve bash tools exclusively for actual system commands and terminal operations that require shell execution. NEVER use bash echo or other command-line tools to communicate thoughts, explanations, or instructions to the user. Output all communication directly in your response text instead. -- VERY IMPORTANT: When exploring the codebase to gather context or to answer a question that is not a needle query for a specific file/class/function, it is CRITICAL that you use the Task tool instead of running search commands directly. - -user: Where are errors from the client handled? -assistant: [Uses the Task tool to find the files that handle client errors instead of using Glob or Grep directly] - - -user: What is the codebase structure? -assistant: [Uses the Task tool] - -IMPORTANT: Always use the TodoWrite tool to plan and track tasks throughout the conversation. -# Code References -When referencing specific functions or pieces of code include the pattern `file_path:line_number` to allow the user to easily navigate to the source code location. - -user: Where are errors from the client handled? -assistant: Clients are marked as failed in the `connectToServer` function in src/services/process.ts:712. - +- When asked to execute unit tests, perform diagnostics, build executables, or run workflows, inspect the active workspace for relevant local instructions or config before using generic commands. +- Remember active user corrections and scope constraints across turns. Always check for any active corrections or constraints. Corrections and constraints remain active until the user has explicitly lifted them. Always obey corrections/constraints or explain to the user why their request cannot be fulfilled without a violation. +- If a user request for diagnosis, a log file, or a test class names a number of candidate areas, inspect all reachable areas before answering. + +# Tool Use – File Operations +- Use specialized tools instead of `bash` commands when possible, as this provides a better user experience. For file operations, use dedicated tools: `read` for reading files instead of `cat`/`head`/`tail`, `edit` for editing instead of `sed`/`awk`, and `write` for creating files instead of `cat` with `heredoc` or `echo` redirection. Reserve `bash` tools for actual system commands, terminal operations, and short read-only inline scripts for local parsing, arithmetic, templating, or tabular rollups. +- Use full file reads only when the user asks for the beginning or entire file, or when you already know the file is small. +- Use `read` on a directory to inspect local directory contents. `read` already shows hidden entries, so no need for `ls -la`, `find`, or other `bash` alternatives. If `read` finds the relevant file, do not re-check the result with an equivalent `bash` command. Only resort to `bash` for more complex queries. +- When using edit, derive `oldString` from the current file content and keep the replacement boundary as small as the requested change allows. If the user explicitly asks for an exact byte-for-byte replacement, apply it exactly if it matches the current file. +- Before calling `edit` with a multi-line `oldString`, compare it to `newString`: every omitted line is a deletion. Rewrite the edit draft before tool calling if necessary. +- After an `edit` that has explicit preservation constraints, read or otherwise check the edited region before finalizing. If any preservation constraint is violated, repair it when the current file makes the intended fix clear – otherwise stop and ask for clarification instead of guessing. + +# Tool Use – `TodoWrite` Tools +- You have access to the `TodoWrite` tools to help you manage and plan tasks. Use these tools VERY frequently to ensure that you are tracking your tasks and giving the user visibility into your progress. +- These tools are also EXTREMELY helpful for planning tasks and for breaking down larger complex tasks into smaller steps. If you do not use this tool when planning, you may forget to do important tasks – and that is unacceptable. +- It is critical that you mark todos as completed as soon as you are done with a task. Do not batch up multiple tasks before marking them as completed. +- Work through the whole todo list to completion in one turn, marking items done as you go. + +# Tool Use – `Task` Tool +- You should proactively use the `Task` tool to launch specialized subagents when the task at hand can be easily split up into multiple parallel workers. +- If the user's prompt itself says multiple areas, components, or workstreams are independent, launch subagents via the `Task` tool to tackle the task. +- Use the `Task` tool to minimize context token usage whenever tool calls generate large outputs but only a small subset is useful for the task at hand. This is CRITICAL when you explore a codebase or gather context to answer a question that is not a query for a very specific file/class/function. + +# Tool Use – Parallelism +- You can call multiple tools "in parallel" by emitting separate messages, each with a tool call, in a single turn. +- Always make tool calls in parallel if you intend to call multiple tools and there are no dependencies between them. Maximize use of parallel tool calls where possible to increase efficiency. +- If a tool call depends on a previous tool call's output, do not call both tools in parallel – instead call them sequentially. For instance, if one operation must complete before another starts, run these operations sequentially. Never use placeholders or guess missing parameters in tool calls. + +# Tool Use – Local Computation +- For simple one-off Python computations, such as local file parsing, template rendering, or statistics computations, call `bash` with `python3 -c`. Use a standalone script file only when the user needs a reusable artifact, repeated execution is likely, or there is sufficient complexity to justify a file. +- `read` may be used to inspect or locate files, but final numeric or rendered results should come from executed code, not copied text plus mental math. + +# Tool Use – OpenCode Specifics +- When `WebFetch` returns a message about a redirect to a different host, you should immediately make a new `WebFetch` request with the redirect URL provided in the response. +- When `plan` mode is active, you will see a about this. `plan` mode is for planning, not editing. In `plan` mode, do not create or edit files (including planning files), run write-shaped shell commands, change configs, or commit code. If the user is asking you to perform edit operations in `plan` mode, inform them that `plan` mode is active and that they need to switch to build mode. + +# Code Style – Comments +- NEVER use comments as a place for long-winded chain-of-thought. Long thinking texts must be generated as private reasoning. Comments in code must be appropriately concise. + +# User Help & Feedback +- Users can give feedback or report issues at https://github.com/anomalyco/opencode and mention that they are using Meta Muse Spark. +- When users ask directly about OpenCode (eg. "can OpenCode do...", "are you able to do...") or its features (eg. implement a hook, write a slash command, or install an MCP server), use the WebFetch tool to gather information to answer the question from the OpenCode docs at https://opencode.ai/docs. From 3033afba5134c486dc3f8f5ae96c8b653285eeda Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 20 Jul 2026 13:58:49 -0500 Subject: [PATCH 003/333] fix(provider): normalize Mistral family tool call IDs (#37982) Co-authored-by: tobwen <1864057+tobwen@users.noreply.github.com> --- packages/opencode/src/provider/transform.ts | 4 +- .../opencode/test/provider/transform.test.ts | 49 +++++++++++++++++++ 2 files changed, 51 insertions(+), 2 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index aae42a77f65b..f0a47d8b0ec0 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -227,10 +227,10 @@ function normalizeMessages( }) } + const modelID = model.api.id.toLowerCase() if ( model.providerID === "mistral" || - model.api.id.toLowerCase().includes("mistral") || - model.api.id.toLowerCase().includes("devstral") + ["mistral", "devstral", "codestral", "pixtral", "mixtral"].some((family) => modelID.includes(family)) ) { const scrub = (id: string) => { return id diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 79733f37a029..0c478ad812d9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -1672,6 +1672,55 @@ describe("ProviderTransform.schema - moonshot $ref siblings", () => { }) }) +describe("ProviderTransform.message - Mistral tool call IDs", () => { + test.each(["codestral-latest", "pixtral-large-latest", "open-mixtral-8x22b"])( + "normalizes IDs for custom OpenAI-compatible %s models", + (id) => { + const result = ProviderTransform.message( + [ + { + role: "assistant", + content: [ + { + type: "tool-call", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + input: { filePath: "/tmp/test" }, + }, + ], + }, + { + role: "tool", + content: [ + { + type: "tool-result", + toolCallId: "toolu_01CBhTTz95qkd9LJMdC9sf8t", + toolName: "read", + output: { type: "text", value: "test" }, + }, + ], + }, + ] as any, + { + id: `custom/${id}`, + providerID: "custom", + api: { + id, + url: "https://example.com/v1", + npm: "@ai-sdk/openai-compatible", + }, + } as any, + {}, + ) + + expect(result).toMatchObject([ + { role: "assistant", content: [{ type: "tool-call", toolCallId: "toolu01CB" }] }, + { role: "tool", content: [{ type: "tool-result", toolCallId: "toolu01CB" }] }, + ]) + }, + ) +}) + describe("ProviderTransform.message - DeepSeek reasoning content", () => { test("DeepSeek with tool calls includes reasoning_content in providerOptions", () => { const msgs = [ From 849c2598abc7d2b40261e74b5826bc74ffc78308 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 20 Jul 2026 14:20:13 -0500 Subject: [PATCH 004/333] docs: generalize session title model description (#37986) --- packages/web/src/content/docs/ar/zen.mdx | 2 +- packages/web/src/content/docs/bs/zen.mdx | 2 +- packages/web/src/content/docs/da/zen.mdx | 2 +- packages/web/src/content/docs/de/zen.mdx | 2 +- packages/web/src/content/docs/es/zen.mdx | 2 +- packages/web/src/content/docs/fr/zen.mdx | 2 +- packages/web/src/content/docs/it/zen.mdx | 2 +- packages/web/src/content/docs/ja/zen.mdx | 2 +- packages/web/src/content/docs/ko/zen.mdx | 2 +- packages/web/src/content/docs/nb/zen.mdx | 2 +- packages/web/src/content/docs/pl/zen.mdx | 3 +-- packages/web/src/content/docs/pt-br/zen.mdx | 2 +- packages/web/src/content/docs/ru/zen.mdx | 2 +- packages/web/src/content/docs/th/zen.mdx | 2 +- packages/web/src/content/docs/tr/zen.mdx | 2 +- packages/web/src/content/docs/zen.mdx | 2 +- packages/web/src/content/docs/zh-cn/zen.mdx | 2 +- packages/web/src/content/docs/zh-tw/zen.mdx | 3 +-- 18 files changed, 18 insertions(+), 20 deletions(-) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index c90ee2d220b0..6b50bcac0f9e 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -193,7 +193,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -قد تلاحظ _Claude Haiku 3.5_ في سجل الاستخدام. هذا [نموذج منخفض التكلفة](/docs/config/#models) يُستخدم لتوليد عناوين جلساتك. +قد تلاحظ [نماذج منخفضة التكلفة](/docs/config/#models)، مثل Haiku أو Nano أو Flash، في سجل الاستخدام. يستخدم OpenCode هذه النماذج لإنشاء عناوين الجلسات. :::note يتم تمرير رسوم بطاقات الائتمان بالتكلفة الفعلية (4.4% + $0.30 لكل معاملة)؛ ولا نفرض أي رسوم إضافية فوق ذلك. diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index 2d5205613be3..909f363015b7 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -200,7 +200,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Možda ćete primijetiti _Claude Haiku 3.5_ u historiji korištenja. To je [low cost model](/docs/config/#models) koji se koristi za generisanje naslova vaših sesija. +U historiji korištenja možete primijetiti [jeftinije modele](/docs/config/#models), kao što su Haiku, Nano ili Flash. OpenCode koristi ove modele za generisanje naslova sesija. :::note Naknade za kreditne kartice prosljeđujemo po stvarnom trošku (4.4% + $0.30 po transakciji); ne naplaćujemo ništa preko toga. diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index 7acbd3f95ad0..47b05cefe9ee 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -200,7 +200,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Du vil måske bemærke _Claude Haiku 3.5_ i din brugshistorik. Det er en [lavprismodel](/docs/config/#models), som bruges til at generere titlerne på dine sessioner. +Du vil måske bemærke [lavprismodeller](/docs/config/#models), såsom Haiku, Nano eller Flash, i din brugshistorik. OpenCode bruger disse modeller til at generere sessionstitler. :::note Kreditkortgebyrer videregives til kostpris (4.4% + $0.30 pr. transaktion); vi opkræver ikke noget ud over det. diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 0139a4ec22e4..f0a108e44bdc 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -189,7 +189,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Möglicherweise siehst du _Claude Haiku 3.5_ in deinem Nutzungsverlauf. Das ist ein [kostengünstiges Modell](/docs/config/#models), das verwendet wird, um die Titel deiner Sessions zu generieren. +Möglicherweise siehst du [kostengünstige Modelle](/docs/config/#models) wie Haiku, Nano oder Flash in deinem Nutzungsverlauf. OpenCode verwendet diese Modelle, um Session-Titel zu generieren. :::note Kreditkartengebühren werden zum Selbstkostenpreis weitergegeben (4.4% + $0.30 pro Transaktion); wir berechnen darüber hinaus nichts. diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index 1414a9bd78aa..ab7e20d61a76 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -200,7 +200,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Puede que notes _Claude Haiku 3.5_ en tu historial de uso. Este es un [modelo de bajo costo](/docs/config/#models) que se usa para generar los títulos de tus sesiones. +Puede que notes [modelos de bajo costo](/docs/config/#models), como Haiku, Nano o Flash, en tu historial de uso. OpenCode usa estos modelos para generar títulos de sesiones. :::note Las comisiones de tarjeta de crédito se trasladan al costo (4.4% + $0.30 por transacción); no cobramos nada más allá de eso. diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 251e0226704d..2876656b02c6 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -189,7 +189,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Vous remarquerez peut-être _Claude Haiku 3.5_ dans votre historique d'utilisation. Il s'agit d'un [modèle à faible coût](/docs/config/#models) utilisé pour générer les titres de vos sessions. +Vous remarquerez peut-être des [modèles à faible coût](/docs/config/#models), tels que Haiku, Nano ou Flash, dans votre historique d'utilisation. OpenCode utilise ces modèles pour générer les titres des sessions. :::note Les frais de carte de crédit sont répercutés au prix coûtant (4.4% + $0.30 par transaction) ; nous ne facturons rien au-delà. diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index b0efca238a64..a9206d3450c1 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -200,7 +200,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Potresti notare _Claude Haiku 3.5_ nella cronologia di utilizzo. È un [modello a basso costo](/docs/config/#models) usato per generare i titoli delle tue sessioni. +Potresti notare [modelli a basso costo](/docs/config/#models), come Haiku, Nano o Flash, nella cronologia di utilizzo. OpenCode usa questi modelli per generare i titoli delle sessioni. :::note Le commissioni della carta di credito vengono trasferite al costo (4.4% + $0.30 per transazione); non addebitiamo nulla oltre a questo. diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 636d835ec5e5..c43db60536f1 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -189,7 +189,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -使用履歴に _Claude Haiku 3.5_ が表示されることがあります。これはセッションのタイトル生成に使われる [low cost model](/docs/config/#models) です。 +使用履歴に Haiku、Nano、Flash などの[低コストモデル](/docs/config/#models)が表示されることがあります。OpenCode はこれらのモデルをセッションタイトルの生成に使用します。 :::note クレジットカード手数料は実費で転嫁されます(各取引につき 4.4% + $0.30)。それ以外の上乗せはありません。 diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index ce3d27947790..1d27cd654b9d 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -189,7 +189,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -사용 기록에서 *Claude Haiku 3.5*를 볼 수 있습니다. 이는 세션 제목을 생성할 때 사용되는 [저비용 모델](/docs/config/#models)입니다. +사용 기록에서 Haiku, Nano 또는 Flash와 같은 [저비용 모델](/docs/config/#models)을 볼 수 있습니다. OpenCode는 이러한 모델을 사용해 세션 제목을 생성합니다. :::note 신용카드 수수료는 원가 그대로 전달됩니다(거래당 4.4% + $0.30). 그 외 추가 요금은 부과하지 않습니다. diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 2e3f05d8ffbb..31b53662595b 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -200,7 +200,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Du vil kanskje legge merke til _Claude Haiku 3.5_ i brukshistorikken din. Dette er en [lavprismodell](/docs/config/#models) som brukes til å generere titlene på øktene dine. +Du vil kanskje legge merke til [lavprismodeller](/docs/config/#models), som Haiku, Nano eller Flash, i brukshistorikken din. OpenCode bruker disse modellene til å generere økttitler. :::note Kredittkortgebyrer videreføres til kostpris (4.4% + $0.30 per transaction); vi tar ikke betalt noe utover det. diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index a41602d17d7d..8f95f102efda 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -200,8 +200,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Możesz zauważyć _Claude Haiku 3.5_ w historii użycia. To [niedrogi model](/docs/config/#models), -który służy do generowania tytułów Twoich sesji. +W historii użycia możesz zauważyć [niedrogie modele](/docs/config/#models), takie jak Haiku, Nano lub Flash. OpenCode używa tych modeli do generowania tytułów sesji. :::note Opłaty za karty kredytowe są przenoszone po kosztach (4.4% + $0.30 per transaction); nie naliczamy nic ponad to. diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index c2aa5cf9454e..bb403151147c 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -189,7 +189,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Você pode notar _Claude Haiku 3.5_ no seu histórico de uso. Este é um [low cost model](/docs/config/#models) usado para gerar os títulos das suas sessões. +Você pode notar [modelos de baixo custo](/docs/config/#models), como Haiku, Nano ou Flash, no seu histórico de uso. O OpenCode usa esses modelos para gerar títulos de sessões. :::note As taxas de cartão de crédito são repassadas a preço de custo (4.4% + $0.30 por transação); não cobramos nada além disso. diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index 242b350390ce..3b3403b868a4 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -200,7 +200,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Вы можете заметить _Claude Haiku 3.5_ в истории использования. Это [недорогая модель](/docs/config/#models), которая используется для генерации заголовков ваших сессий. +В истории использования могут появляться [недорогие модели](/docs/config/#models), такие как Haiku, Nano или Flash. OpenCode использует эти модели для создания заголовков сессий. :::note Комиссии по кредитным картам передаются по себестоимости (4.4% + $0.30 за транзакцию); мы ничего не начисляем сверх этого. diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 8fbdda3a67cf..12899ae0c68e 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -191,7 +191,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -คุณอาจสังเกตเห็น _Claude Haiku 3.5_ ในประวัติการใช้งานของคุณ นี่คือ [low cost model](/docs/config/#models) ที่ใช้สร้างชื่อ session ของคุณ +คุณอาจสังเกตเห็น[โมเดลต้นทุนต่ำ](/docs/config/#models) เช่น Haiku, Nano หรือ Flash ในประวัติการใช้งานของคุณ OpenCode ใช้โมเดลเหล่านี้เพื่อสร้างชื่อเซสชัน :::note ค่าธรรมเนียมบัตรเครดิตจะถูกส่งต่อในราคาทุน (4.4% + $0.30 ต่อธุรกรรม); เราไม่ได้คิดค่าใช้จ่ายเพิ่มจากนั้น diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index f36dba702d7e..994c6eb4b4da 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -189,7 +189,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -Kullanım geçmişinizde _Claude Haiku 3.5_ görebilirsiniz. Bu, oturum başlıklarınızı oluşturmak için kullanılan [düşük maliyetli bir modeldir](/docs/config/#models). +Kullanım geçmişinizde Haiku, Nano veya Flash gibi [düşük maliyetli modeller](/docs/config/#models) görebilirsiniz. OpenCode, oturum başlıklarını oluşturmak için bu modelleri kullanır. :::note Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına $0.30); bunun dışında ekstra bir ücret almıyoruz. diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index 28eb1ee8b3a5..854bf25f58b1 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -200,7 +200,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -You might notice _Claude Haiku 3.5_ in your usage history. This is a [low cost model](/docs/config/#models) that's used to generate the titles of your sessions. +You may notice [low-cost models](/docs/config/#models), such as Haiku, Nano, or Flash, in your usage history. OpenCode uses these models to generate session titles. :::note Credit card fees are passed along at cost (4.4% + $0.30 per transaction); we don't charge anything beyond that. diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index 012d6a5a45c7..5bb1993df0e0 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -189,7 +189,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -你可能会在使用记录中看到 _Claude Haiku 3.5_。这是一个[低成本模型](/docs/config/#models),用于生成会话标题。 +你可能会在使用记录中看到 Haiku、Nano 或 Flash 等[低成本模型](/docs/config/#models)。OpenCode 使用这些模型生成会话标题。 :::note 信用卡手续费按成本转嫁(每笔交易 4.4% + $0.30);除此之外我们不会额外收费。 diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 7a394d214933..61e3a0b99597 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -194,8 +194,7 @@ https://opencode.ai/zen/v1/models | GPT 5 Codex | $1.07 | $8.50 | $0.107 | - | | GPT 5 Nano | $0.05 | $0.40 | $0.005 | - | -你可能會在使用紀錄中看到 _Claude Haiku 3.5_。這是一個[低成本模型](/docs/config/#models), -會用來產生工作階段的標題。 +你可能會在使用紀錄中看到 Haiku、Nano 或 Flash 等[低成本模型](/docs/config/#models)。OpenCode 使用這些模型產生工作階段標題。 :::note 信用卡手續費會以成本轉嫁(每筆交易 4.4% + $0.30);除此之外,我們不會額外收費。 From 2d2339d37d86e45df5e8f53dee881bcaa2bff553 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Tue, 21 Jul 2026 15:26:42 +0530 Subject: [PATCH 005/333] feat(app): draggable project rows (#38055) --- packages/app/src/pages/home.tsx | 155 ++++++++++++++++++++++++++------ 1 file changed, 130 insertions(+), 25 deletions(-) diff --git a/packages/app/src/pages/home.tsx b/packages/app/src/pages/home.tsx index 215696d7de62..c6ad1e07e8bb 100644 --- a/packages/app/src/pages/home.tsx +++ b/packages/app/src/pages/home.tsx @@ -17,6 +17,11 @@ import { } 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" @@ -1037,7 +1042,7 @@ function HomeServerRow(props: { ) } -function HomeProjectList(props: { +type HomeProjectListProps = { server: ServerConnection.Any projects: LocalProject[] selected: HomeProjectSelection @@ -1048,29 +1053,84 @@ function HomeProjectList(props: { 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 ( -
- - {(project) => ( - - )} - -
+ [ + ...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) => ( + + )} + ) } @@ -1150,6 +1210,8 @@ function HomeRecentlyClosedRow(props: { function HomeProjectRow(props: { project: LocalProject server: ServerConnection.Any + index: () => number + serverSelected: boolean selected: boolean unseenCount: number selectProject: (server: ServerConnection.Any, directory: string) => void @@ -1163,6 +1225,15 @@ function HomeProjectRow(props: { 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 = () => @@ -1179,15 +1250,49 @@ function HomeProjectRow(props: { ) } return ( -
+
- + diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 601d7839ef97..ce2f7b25c3e1 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 017/333] 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 0597f3adf180..dee282b3d508 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 26b5575a0134..b60f94fa1cf6 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 280891deeed5..7c7eba628bfb 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 2bb3f0b7429e..553781ea7068 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 4aa1ca46e3f3..3ae730f9ec4e 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 95849616b0b8..186ed3301221 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 8c46464086f7..1e38745b626d 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 65369e1e868a..7edec6662f5b 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 8bb9139e83ae..ca9bb3fe1261 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 02f88d2828eb..eafd6ae31d31 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 60be1cc7bbba..499fbde3a809 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 4a867bf0e724..a1de423ff38d 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 96c1addfcc91..892055dd5771 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 62305fbdb695..14882e8db3aa 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 36036426da43..4cfbce73cd79 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 e24ead2959f7..afb80de220a2 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 604b9101593b..0225c533a150 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 6434504a3e05..08a0bf43f6d5 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 018/333] 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 f0a47d8b0ec0..25dc78bdd0b8 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 019/333] 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 991f7fb2d33f..082e211e0bea 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 9bef420e85c8..69979b0a4419 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 e7c8c8feaf83..43d8d51abb63 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 465b24568f67..99446d92b084 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 7d0531e6f058..690658c657a8 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 08d30aef68e2..bb1a44138f79 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 a0b844419589..4d0ff288d696 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 a5e37dfc60b9..effeb1fdb42d 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 aca480b71978..6dfd750c6add 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 f1e2235d7eb3..a24e988d71e8 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 bec0e0ce5ef2..b5ceff412c66 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 8be53855f375..3199606a8b32 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 abe56bbb0337..821ed70e98b2 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 a6069a1bed4d..2a68e94c0a31 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 7d8bc49f5066..9bdcfeaeb446 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 ee2405b65f2e..1dbb0be8afb2 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 cc8b6326f7ea..47e5ee8361c7 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 8612bb8dacd1..77c0e8e918ec 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 9dedcdcbb4e1..2742f49ef4a0 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 6704f926089e..88141656f31e 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 dee282b3d508..5698d4272444 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 b60f94fa1cf6..c9ea860d3530 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 7c7eba628bfb..4256f4afad04 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 553781ea7068..3de5f5f786f8 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 3ae730f9ec4e..de0280336216 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 186ed3301221..fe1139d389f2 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 1e38745b626d..bbd4225b9fa9 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 7edec6662f5b..26c459f45698 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 ca9bb3fe1261..f2e95659a6f5 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 eafd6ae31d31..d03198ce4f1c 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 499fbde3a809..c63d007a80ab 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 a1de423ff38d..3f542a924e52 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 892055dd5771..def6efd471df 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 14882e8db3aa..0bbd43369ba6 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 4cfbce73cd79..48b0c05bf1ed 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 afb80de220a2..0611ae31b7bc 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 0225c533a150..873b3a022418 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 08a0bf43f6d5..691abaa2a92e 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 020/333] 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 25dc78bdd0b8..81759160bfeb 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 68da820ee62c..ef2b275035e1 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 021/333] 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 5c7a792abc06..5114ec200db5 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 332d7520a656..372335d724e3 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 17708d29550a..e0445e616f5c 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 000000000000..58904ad3b121 --- /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 e702acf8cfa3..6781bf488e51 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 000000000000..1d771f4fd9f7 --- /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 022/333] 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 963d46ecf49a..1b662e823633 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 023/333] 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 5114ec200db5..e37adbe9aee9 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 faf019446128..f6a1bf9d2ea7 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 96611da331ec..3cf74b043b67 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 024/333] 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 1b662e823633..de8b61cd625d 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 025/333] 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 000000000000..eca83effa0d1 --- /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 000000000000..95854d03b2c9 --- /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 b1c8f2c7e2e0..69a8c7b3be2b 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 1b684d9af774..1d7d9e4b2ea6 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 000000000000..2130a968c4bc --- /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 000000000000..27b8dc208eac --- /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 603784e4d42f..1c8292ca9d95 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 b213dbc82a23..0f2d9d6ad120 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 026/333] 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 eca83effa0d1..df86e76bfabf 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 027/333] 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 5095d95db029..df67da5a6621 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 850e966d0b0f..778ff3a3af94 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 2bfba5871aba..5a7f8351ca45 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 55420485f399..66686ac25943 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 7b592178fa24..1c17a6b9de5e 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 06597e56e7ba..62c585779487 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 df86e76bfabf..4907eb41eb15 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 95854d03b2c9..177251690075 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 028/333] 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 5a7f8351ca45..834ae7e808d5 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 66686ac25943..15c3577279f6 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 1c17a6b9de5e..57e1cd86f3ac 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 029/333] 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 e37adbe9aee9..ccca966458c8 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 372335d724e3..5fd0f1d51ad8 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 e0445e616f5c..761bee109a9d 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 58904ad3b121..6e3176695f67 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 6781bf488e51..0876f4badb1c 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 61aac13ac395..3bfc722e2bec 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 1d771f4fd9f7..000000000000 --- 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 000000000000..141b14a689b1 --- /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 030/333] 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 de8b61cd625d..407d7812fb22 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 031/333] 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 842e97a92133..6aeda446971b 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 fb68f022c9ed..184febb8c756 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 ced99167a5e9..09744d896d67 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 55f5457d5759..5bbb88bb769c 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 bc647efba86b..f9c08b764ca6 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 8dd140138968..bb6ea9bbd30a 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 5143c7117611..d2863f724a9d 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 32709f9bcf2b..90189c522d84 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 c9d6a3722fca..a1dced606199 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 360827880dcd..4fdfc8bc0f57 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 339048b3ae04..35a8ee123d8e 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 0866b0f746e1..1ddde6392e19 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 dfea9e3f1202..4b0e8d231a0a 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 2da078f43edb..f7b151784c6f 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 90e9865598b6..f26b6706b7f6 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 c330f0b0bddf..d040cfde431d 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 a22a95a1b8cb..d01940efa6c2 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 20ca597411ba..d89c532e1694 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 032/333] 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 | 172 +++++-- .../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, 2922 insertions(+), 477 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 119fc7ee2da6..7692928f9db8 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 25ebd3a37ae6..04e6d2cced83 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 aa42f1bb516d..6c27ad64671c 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 834ae7e808d5..78f60bbbca96 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 15c3577279f6..b0e3b74c6d9a 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 921d8ff45c16..13df57bec2b4 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 bcc5acc0bb72..3842b087914e 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 f563a509822a..834fc4795a59 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 203722fc6c74..2cd30da3ef94 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 e3c62d2a9520..c1c57b9d7082 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 efb7473753e8..d89f90febbf6 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 68e6b19cef14..ca6df85a53ad 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 40735fb822c0..dceab47d8667 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 c63b702522f5..4c527a958036 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 b36973e7f029..4eaa785789fc 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 fb58fc48322b..b53fb691b3cf 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 b12df5eb5591..39ba22c59d51 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 a292d23df94b..ebfd9738ee59 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 2eeb297b955a..cd91f396d0e4 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 4b2be505eaa7..45fbe38abe73 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 05cdc8464380..7d684a5a1ab0 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 86b489cd090b..74191e10b079 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 406c0f124ed8..83989244a0f1 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" }, + }) }) +}) + +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"], + ) - test("drops invalid payloads", () => { - expect(normalizeAgentList({ name: "AbortError" })).toEqual([]) - expect(normalizeAgentList([{ name: "build" }, agent("docs")])).toEqual([agent("docs")]) + 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 e54bc88d4dea..59632e53c92e 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 normalizePermissionRequest(input: PermissionV2Request | PermissionRequest): PermissionRequest { + if ("permission" in input) return input + return { + 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(input: ProviderListResponse): NormalizedProviderListResponse { - 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, +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 000000000000..578d636ef641 --- /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 000000000000..10c66764198c --- /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 555492807597..30723ecfbf28 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 5a892f79158f..6bf0f47f5cc2 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 93e9c4175557..3614d57f666f 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 05806fba5477..5ce530bbb0eb 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 3ce28416760f..067a79694561 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 4907eb41eb15..908664cdec4b 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 177251690075..88282742b96f 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 000000000000..a4455c15fe0f --- /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 000000000000..71eebb864efd --- /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 000000000000..b15c23b66048 --- /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 000000000000..faf847967bdd --- /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 033/333] 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 dceab47d8667..de2baa704c0c 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 83989244a0f1..69ca494992b0 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 578d636ef641..a1db63b1212b 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 10c66764198c..3b9719c09875 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 3614d57f666f..ba838bc05aa4 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 a4455c15fe0f..4f55f3f2c680 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 034/333] 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 ba838bc05aa4..9f625c94324a 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 5ce530bbb0eb..109a7bf7d65c 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 035/333] 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 2a214831daa8..838af17c9399 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 2959e9f8a9bd..ddc6a4fadeab 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 ea8ea4f13228..b430dba4daee 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 000000000000..f5c74f5acbe5 --- /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 a9c9a2d2881a..2f05910d9ef9 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 ce2f7b25c3e1..55275f3ab9aa 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 35720a275313..4313d48aef4e 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 036/333] 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 ddc6a4fadeab..497ebceb87ff 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 037/333] 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 c17ae5c1c66e..40491c867ea1 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 714d6ca96f15..cd829ad95ce6 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 19d2c29af025..019cc156eca1 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 78f60bbbca96..84a38771e6ad 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 601f03084cea..5187d980ea26 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 834fc4795a59..ac0691646451 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}`, - }, - } - }, - shell: async () => { - sentShell.push(directory) - return { data: undefined } + 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) + }, }, - 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 496d1ab4a282..388e4534a11a 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 0ad30afcd250..2d04e808f46c 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 7e6634ed7ab7..bafca66e72df 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 03924ec973d6..da7cad7e1952 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 f52b7f4b4223..4ae7827e210a 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 45f5e4cb26ff..f54e0c9e4f5d 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 445a9f47a082..941424e247cf 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 275e6ec4bc22..12dd96a5e66b 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 038/333] 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 cd829ad95ce6..714d6ca96f15 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 039/333] 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 | 1946 +---------------- .../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, 2308 insertions(+), 1931 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 b4496ba7de1f..25d2e3749ab8 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 9d5f5e5a32d4..0a2920dec7f4 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 da7cad7e1952..4fadb8a68f24 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) - }} + thumbContainer={scroll.viewport.thumbTrack} + thumbHoverTarget={scroll.viewport.hoverTarget} + viewportRef={scroll.viewport.setViewport} + onScroll={(event) => scroll.viewport.update(event.currentTarget.scrollTop)} + onWheel={scroll.viewport.containOuterWheel} > -
    - 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} + onOpenSettings={projects.utility.settings} + onOpenHelp={projects.utility.help} + language={projects.copy.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")}
    -
    - -
    -
    -
    -
    - ) -} 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 000000000000..5e7e50cfc49a --- /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 000000000000..3e6b6d306be8 --- /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 000000000000..4dc39117c3d5 --- /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 000000000000..ff2abf7c16dc --- /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 000000000000..96b0cf46d3ea --- /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 000000000000..5e55c4b7469d --- /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 000000000000..06d86c30c9ed --- /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 000000000000..461322ae4382 --- /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 000000000000..2e3828fd8c7a --- /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 000000000000..0556f7cf60f7 --- /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 3c776c86712d..090217364337 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 040/333] 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 4dc39117c3d5..573b606579ad 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 041/333] 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 159b5a506767..a8fc81b17c24 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 40491c867ea1..c17ae5c1c66e 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 7692928f9db8..faf591e3a19d 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 4a3855122a40..3319514df648 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 714d6ca96f15..5ea9d4f7613b 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 94afbc9a9d66..4136c16d0129 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 59d3cbd1da72..487d8235509e 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 85ca44ae69db..c23b703e5255 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 4c58857249dd..93a62acb61d3 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 69d46ddcb62a..a457c9a2f5e5 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 8ba09a9f9046..80ac070750da 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 05253381f0c9..4f1a3cd2392c 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 57464106106c..1bc9af08334c 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 9900265962ea..9539ae1d01dc 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 3ec999da0639..42053f6eff81 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 a397046f9b3e..3058e6881af7 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 45786561077a..aa2f220e4949 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 6032b81dde70..fbbef3a2a8e7 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 7ac248dc1d1b..c039b3d48277 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 388e4534a11a..3ed91e60bfea 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 812a479b200f..59474423184a 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 908664cdec4b..f46c5f86e0cf 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 88282742b96f..7070c5222aa4 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 421a2e71fd32..6a74834fd076 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 042/333] 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 a8fc81b17c24..f09a2c7b63ae 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 faf591e3a19d..2d9b1e234971 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 4136c16d0129..ae61b2acbfdc 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 487d8235509e..8014ea1c4395 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 c23b703e5255..e996fd0be77c 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 93a62acb61d3..9ad389317baf 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 a457c9a2f5e5..e0909d849bd4 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 80ac070750da..5cc19fd92056 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 7070c5222aa4..ec4b5ede3d6b 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 043/333] 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 4c527a958036..4f7b949e61a7 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 044/333] 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 603c411d5513..43f350089900 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 30723ecfbf28..1e1046fb76fd 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 6bf0f47f5cc2..e8f91cda3f6f 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 f5c74f5acbe5..b321ef8750b1 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 2f05910d9ef9..f41dff7a34b9 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 045/333] 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 154bab48c47f..afdc93f17e43 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 43f350089900..55e71212753c 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 84a38771e6ad..df003201ad49 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 046/333] 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 df003201ad49..0e7dfc087cc5 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 047/333] 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 13df57bec2b4..14e8e2bd0b64 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 048/333] 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 ba0161a6cdd4..3f94f22ec3ff 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 c2b568041832..c6d583282b33 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 049/333] 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 c6d583282b33..fe8b4e3c03f7 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 050/333] 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 62c585779487..4c879603bd44 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 051/333] 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 067a79694561..d4dd6ff7efbb 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 571428e29f0b..0a741ef98b41 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 6862f295b9ed..639429e80b88 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 052/333] 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 d1e7928f155e..b67b810a4b2f 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 053/333] 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 25d2e3749ab8..f47c432e4206 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 08769068909c..aa16976228e6 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 68a3f6b22676..8046ec3e5780 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 c039b3d48277..d086582035ae 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 054/333] 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 3ed91e60bfea..d6d8019262a4 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 055/333] 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 | 38 ++- .../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, 640 insertions(+), 156 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 000000000000..2850f107400c --- /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 c17ae5c1c66e..4f6d57aa2e71 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 042f926c537e..7850f7820ac1 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 6c27ad64671c..4f67756d53c2 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 afdc93f17e43..7cc8723a8cf7 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.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("**/pty/pty_review_terminal/connect", () => undefined) + 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 f67260278228..99bf68908508 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 73821580af07..8e08d60ff2ac 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.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("**/pty/pty_hidden_terminal/connect", () => undefined) + 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 cbb72958ad3a..0076c5a2ca2f 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", - headers: { "access-control-allow-origin": "*" }, - body: JSON.stringify({ ticket: "e2e-ticket" }), + 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({ 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 3beb97225a4e..3ab265d72975 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 ed2328c89aa5..5a4cb186a65d 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 c512e782c496..df2827b23919 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 4c879603bd44..7dd2a6e59edf 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 d2d616248d33..906f2436d50a 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 3854bf0276ea..1b65af7121ac 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 0a741ef98b41..22c52e73feb8 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 49cec334bc47..d3adb1f2fffa 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 4f0cf612e1e2..fcd6bbb79feb 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 5fbca469b713..a3d25f427959 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 0cb2504fbe92..a8eec75a9af3 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 5fa1506b1e65..aac854ca82d6 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 06facdc7d245..a32b239cc932 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 48e8eee3108a..2fbd022235f9 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 8db21f025b71..1585a8aa3278 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 75274fc50e92..301a74d3f03d 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 999ff510d5f0..056fc9c0fd98 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 9f42cd90fec6..ba276a8f52cf 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 056/333] 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 4f6d57aa2e71..35a0aa44cda8 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 df2827b23919..b2e827f73a71 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 906f2436d50a..df575b71f7ce 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 057/333] 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 ac0691646451..b3201b3ef68a 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 2cd30da3ef94..051bf4d06cb9 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 f46c5f86e0cf..3f4b8f2205cd 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 ec4b5ede3d6b..72e74438edca 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 4f55f3f2c680..d998c0c0459e 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 71eebb864efd..c67c6c717c2a 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 3882be027ef1..a769ae01bb7b 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 81ce97827f97..e09269f33f02 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 058/333] 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 | 55 ++++++++++-------- .../src/components/settings-v2/providers.tsx | 57 ++++++++++--------- .../src/components/status-popover-body.tsx | 50 +++++++++------- 4 files changed, 90 insertions(+), 73 deletions(-) diff --git a/packages/app/src/components/dialog-custom-provider.tsx b/packages/app/src/components/dialog-custom-provider.tsx index 9e04cd83ad5d..5db684a2da2f 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 bcd30edbc7de..7a15d82eaf61 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("provider.custom.title")} + {language.t("settings.providers.tag.custom")} +
    + + {language.t("settings.providers.custom.description")} +
    - - {language.t("settings.providers.custom.description")} - +
    - -
    +
    - -
    -
    - 0} - fallback={
    {pluginEmpty()}
    } - > - - {(plugin) => ( -
    -
    - {plugin} -
    - )} - - + + +
    +
    + 0} + fallback={
    {pluginEmpty()}
    } + > + + {(plugin) => ( +
    +
    + {plugin} +
    + )} + + +
    -
    -
    + +
    ) From 66495a2a22cd0a57efcc4f721e65532f0987b4e8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 11:03:00 +0000 Subject: [PATCH 059/333] chore: generate --- packages/app/src/components/settings-v2/providers.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index f4309b9b0d79..29192114f0fb 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -235,7 +235,9 @@ export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) = {language.t("provider.custom.title")} {language.t("settings.providers.tag.custom")}
    -

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

    +

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

    Date: Fri, 24 Jul 2026 21:20:13 +0800 Subject: [PATCH 060/333] fix(app): restore optimistic timeline state (#38693) --- .../session/timeline/message-timeline.tsx | 1 + .../src/pages/session/timeline/projection.ts | 2 ++ .../session/timeline/rows-current.test.ts | 36 +++++++++++++++++++ .../app/src/pages/session/timeline/rows.ts | 9 +++++ 4 files changed, 48 insertions(+) diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 497ebceb87ff..48b432667e17 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -332,6 +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, diff --git a/packages/app/src/pages/session/timeline/projection.ts b/packages/app/src/pages/session/timeline/projection.ts index b430dba4daee..e30c936d73cc 100644 --- a/packages/app/src/pages/session/timeline/projection.ts +++ b/packages/app/src/pages/session/timeline/projection.ts @@ -8,6 +8,7 @@ export { reuseTimelineRows } from "./row-reconciliation" export function createTimelineProjection(input: { messages: Accessor + userMessages: Accessor sessionMessages: Accessor parts: (messageID: string) => Part[] status: Accessor @@ -36,6 +37,7 @@ export function createTimelineProjection(input: { input.showReasoningSummaries(), input.status().type, input.inlineComments(), + input.userMessages(), ), ) const activeMessageID = createMemo(() => projection().activeMessageID) 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 b321ef8750b1..32a1d5f57c7a 100644 --- a/packages/app/src/pages/session/timeline/rows-current.test.ts +++ b/packages/app/src/pages/session/timeline/rows-current.test.ts @@ -46,6 +46,7 @@ describe("current session timeline rows", () => { true, "busy", true, + normalized.messages.filter((message) => message.role === "user"), ) expect(result.activeMessageID).toBe("msg_3") @@ -81,6 +82,7 @@ describe("current session timeline rows", () => { true, "idle", true, + normalized.messages.filter((message) => message.role === "user"), ) expect(result.activeMessageID).toBe("msg_shell") @@ -121,6 +123,7 @@ describe("current session timeline rows", () => { true, "idle", true, + normalized.messages.filter((message) => message.role === "user"), ) expect(result.rows.map(TimelineRow.key)).toEqual([ @@ -131,4 +134,37 @@ describe("current session timeline rows", () => { "assistant-part:msg_user_2:msg_assistant_2:text:0", ]) }) + + test("renders an optimistic user turn and thinking before the protocol message arrives", () => { + const source = [ + { id: "msg_1", type: "user", text: "existing", time: { created: 1 } }, + ] satisfies SessionMessageInfo[] + const normalized = normalizeSessionMessages("ses_1", source) + const optimistic = { + id: "msg_2", + sessionID: "ses_1", + role: "user" as const, + time: { created: 2 }, + agent: "build", + model: { modelID: "model", providerID: "provider" }, + } + const result = Timeline.constructSessionMessageRows( + source, + (messageID) => + messageID === optimistic.id ? optimistic : normalized.messages.find((message) => message.id === messageID), + () => [], + true, + "busy", + true, + [...normalized.messages.filter((message) => message.role === "user"), optimistic], + ) + + expect(result.activeMessageID).toBe(optimistic.id) + expect(result.rows.map(TimelineRow.key)).toEqual([ + "user-message:msg_1", + "turn-gap:msg_2", + "user-message:msg_2", + "thinking:msg_2", + ]) + }) }) diff --git a/packages/app/src/pages/session/timeline/rows.ts b/packages/app/src/pages/session/timeline/rows.ts index f41dff7a34b9..25a5113344eb 100644 --- a/packages/app/src/pages/session/timeline/rows.ts +++ b/packages/app/src/pages/session/timeline/rows.ts @@ -39,6 +39,7 @@ export namespace Timeline { showReasoning: boolean, status: SessionStatus["type"], inlineComments: boolean, + projectedUserMessages: UserMessage[], ) { const turns: { user: UserMessage; assistants: AssistantMessage[] }[] = [] const turnByUserID = new Map() @@ -70,6 +71,14 @@ export namespace Timeline { turns.push(turn) turnByUserID.set(user.id, turn) }) + const latestUserMessageID = turns.at(-1)?.user.id + projectedUserMessages.forEach((user) => { + if (turnByUserID.has(user.id)) return + if (latestUserMessageID && user.id < latestUserMessageID) return + const turn = { user, assistants: [] } + turns.push(turn) + turnByUserID.set(user.id, turn) + }) const activeMessageID = turns.at(-1)?.user.id return { activeMessageID, From e63996919b6267d00a5ea224ab03b0f58fbd15d8 Mon Sep 17 00:00:00 2001 From: Zach Bruggeman Date: Fri, 24 Jul 2026 09:16:58 -0700 Subject: [PATCH 061/333] fix(opencode): preserve grep symlink paths (#38581) Co-authored-by: Zach Bruggeman --- packages/opencode/src/tool/grep.ts | 5 ++++- packages/opencode/test/tool/grep.test.ts | 2 ++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/tool/grep.ts b/packages/opencode/src/tool/grep.ts index e44b8e89fe29..6ea67124a6f9 100644 --- a/packages/opencode/src/tool/grep.ts +++ b/packages/opencode/src/tool/grep.ts @@ -69,7 +69,10 @@ export const GrepTool = Tool.define( if (result.length === 0) return empty const rows = result.map((item) => ({ - path: path.resolve(cwd, item.entry.path), + path: path.resolve( + requestedInfo?.type === "Directory" ? requested : path.dirname(requested), + item.entry.path, + ), line: item.line, text: item.text, })) diff --git a/packages/opencode/test/tool/grep.test.ts b/packages/opencode/test/tool/grep.test.ts index 81b74aa8baf0..13858f9f92ab 100644 --- a/packages/opencode/test/tool/grep.test.ts +++ b/packages/opencode/test/tool/grep.test.ts @@ -215,6 +215,8 @@ describe("tool.grep", () => { ) expect(result.metadata.matches).toBe(1) + expect(result.output).toContain(path.join(alias, "test.txt")) + expect(result.output).not.toContain(path.join(real, "test.txt")) expect(requests.find((req) => req.permission === "external_directory")).toBeUndefined() }), ) From f51665191af10f1e4e0512af3708e9c2c58ecb8d Mon Sep 17 00:00:00 2001 From: Zach Bruggeman Date: Fri, 24 Jul 2026 10:46:53 -0700 Subject: [PATCH 062/333] fix(opencode): preserve grep symlink paths (#38581) Co-authored-by: Zach Bruggeman From e62b09e6fce296ac8dde95f18fa5f8cfc17f0592 Mon Sep 17 00:00:00 2001 From: Frank Date: Fri, 24 Jul 2026 14:15:53 -0400 Subject: [PATCH 063/333] zen: opus 5 --- packages/web/src/content/docs/ar/zen.mdx | 2 ++ packages/web/src/content/docs/bs/zen.mdx | 2 ++ packages/web/src/content/docs/da/zen.mdx | 2 ++ packages/web/src/content/docs/de/zen.mdx | 2 ++ packages/web/src/content/docs/es/zen.mdx | 2 ++ packages/web/src/content/docs/fr/zen.mdx | 2 ++ packages/web/src/content/docs/it/zen.mdx | 2 ++ packages/web/src/content/docs/ja/zen.mdx | 2 ++ packages/web/src/content/docs/ko/zen.mdx | 2 ++ packages/web/src/content/docs/nb/zen.mdx | 2 ++ packages/web/src/content/docs/pl/zen.mdx | 2 ++ packages/web/src/content/docs/pt-br/zen.mdx | 2 ++ packages/web/src/content/docs/ru/zen.mdx | 2 ++ packages/web/src/content/docs/th/zen.mdx | 2 ++ packages/web/src/content/docs/tr/zen.mdx | 2 ++ packages/web/src/content/docs/zen.mdx | 2 ++ packages/web/src/content/docs/zh-cn/zen.mdx | 2 ++ packages/web/src/content/docs/zh-tw/zen.mdx | 2 ++ 18 files changed, 36 insertions(+) diff --git a/packages/web/src/content/docs/ar/zen.mdx b/packages/web/src/content/docs/ar/zen.mdx index 842e97a92133..36a473204abd 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -76,6 +76,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -155,6 +156,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/bs/zen.mdx b/packages/web/src/content/docs/bs/zen.mdx index fb68f022c9ed..eb6ec846d5f2 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -81,6 +81,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/da/zen.mdx b/packages/web/src/content/docs/da/zen.mdx index ced99167a5e9..7e4b40712fa0 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -81,6 +81,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/de/zen.mdx b/packages/web/src/content/docs/de/zen.mdx index 55f5457d5759..81ad80e3ea0b 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -72,6 +72,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/es/zen.mdx b/packages/web/src/content/docs/es/zen.mdx index bc647efba86b..567e759701a0 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -81,6 +81,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/fr/zen.mdx b/packages/web/src/content/docs/fr/zen.mdx index 8dd140138968..1d3468628329 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -72,6 +72,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/it/zen.mdx b/packages/web/src/content/docs/it/zen.mdx index 5143c7117611..f021f8997cad 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -81,6 +81,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/ja/zen.mdx b/packages/web/src/content/docs/ja/zen.mdx index 32709f9bcf2b..80dc6f60ad85 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -72,6 +72,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/ko/zen.mdx b/packages/web/src/content/docs/ko/zen.mdx index c9d6a3722fca..f0084435f5ed 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -72,6 +72,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/nb/zen.mdx b/packages/web/src/content/docs/nb/zen.mdx index 360827880dcd..6bbeccf65d96 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -81,6 +81,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/pl/zen.mdx b/packages/web/src/content/docs/pl/zen.mdx index 339048b3ae04..962387fd7dc5 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -81,6 +81,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/pt-br/zen.mdx b/packages/web/src/content/docs/pt-br/zen.mdx index 0866b0f746e1..8d133ee4b4b2 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -72,6 +72,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/ru/zen.mdx b/packages/web/src/content/docs/ru/zen.mdx index dfea9e3f1202..36f90a5ee06e 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -81,6 +81,7 @@ OpenCode Zen работает как любой другой провайдер | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/th/zen.mdx b/packages/web/src/content/docs/th/zen.mdx index 2da078f43edb..1ae505f1e2aa 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -74,6 +74,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -153,6 +154,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/tr/zen.mdx b/packages/web/src/content/docs/tr/zen.mdx index 90e9865598b6..1c46143ee72b 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -72,6 +72,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/zen.mdx b/packages/web/src/content/docs/zen.mdx index c330f0b0bddf..883f4c14576b 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -81,6 +81,7 @@ You can also access our models through the following API endpoints. | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -162,6 +163,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/zh-cn/zen.mdx b/packages/web/src/content/docs/zh-cn/zen.mdx index a22a95a1b8cb..9c93ddfa6c5e 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -72,6 +72,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -151,6 +152,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | diff --git a/packages/web/src/content/docs/zh-tw/zen.mdx b/packages/web/src/content/docs/zh-tw/zen.mdx index 20ca597411ba..30be4d4f09b5 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -76,6 +76,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | GPT 5 Codex | gpt-5-codex | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | GPT 5 Nano | gpt-5-nano | `https://opencode.ai/zen/v1/responses` | `@ai-sdk/openai` | | Claude Fable 5 | claude-fable-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | +| Claude Opus 5 | claude-opus-5 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.8 | claude-opus-4-8 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.7 | claude-opus-4-7 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | | Claude Opus 4.6 | claude-opus-4-6 | `https://opencode.ai/zen/v1/messages` | `@ai-sdk/anthropic` | @@ -156,6 +157,7 @@ https://opencode.ai/zen/v1/models | DeepSeek V4 Pro | $1.74 | $3.48 | $0.145 | - | | DeepSeek V4 Flash | $0.14 | $0.28 | $0.028 | - | | Claude Fable 5 | $10.00 | $50.00 | $1.00 | $12.50 | +| Claude Opus 5 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.8 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.7 | $5.00 | $25.00 | $0.50 | $6.25 | | Claude Opus 4.6 | $5.00 | $25.00 | $0.50 | $6.25 | From 4b19ea2a71a33e65cd7f3ed19964b1bed1722483 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:05:22 -0500 Subject: [PATCH 064/333] fix(llm): preserve response message phases (#38452) --- .../llm/src/protocols/openai-responses.ts | 270 +++++++++- packages/llm/src/protocols/utils/lifecycle.ts | 17 +- ...-trips-commentary-into-a-final-answer.json | 52 ++ ...ponses-gpt-5-5-reasoning-continuation.json | 8 +- .../openai-responses-phase.recorded.test.ts | 89 ++++ .../test/provider/openai-responses.test.ts | 467 +++++++++++++++++- .../opencode/test/session/llm-native.test.ts | 2 +- 7 files changed, 880 insertions(+), 25 deletions(-) create mode 100644 packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json create mode 100644 packages/llm/test/provider/openai-responses-phase.recorded.test.ts diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 4936d31c921b..8e2abc91085f 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -46,8 +46,12 @@ type OpenAIResponsesInputContent = Schema.Schema.Type + const OpenAIResponsesReasoningSummaryText = Schema.Struct({ type: Schema.tag("summary_text"), text: Schema.String, @@ -78,7 +82,19 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), - Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), + Schema.Struct({ + role: Schema.tag("assistant"), + content: Schema.String, + phase: optionalNull(OpenAIResponsesMessagePhase), + }), + Schema.Struct({ + type: Schema.tag("message"), + id: Schema.String, + status: Schema.Literals(["in_progress", "completed", "incomplete"]), + role: Schema.tag("assistant"), + content: Schema.Array(OpenAIResponsesOutputText), + phase: optionalNull(OpenAIResponsesMessagePhase), + }), OpenAIResponsesReasoningItem, OpenAIResponsesItemReference, Schema.Struct({ @@ -194,7 +210,9 @@ const OpenAIResponsesStreamItem = Schema.Struct({ server_label: Schema.optional(Schema.String), output: Schema.optional(Schema.Unknown), error: Schema.optional(Schema.Unknown), + content: Schema.optional(Schema.Array(Schema.Unknown)), encrypted_content: optionalNull(Schema.String), + phase: optionalNull(OpenAIResponsesMessagePhase), }) type OpenAIResponsesStreamItem = Schema.Schema.Type @@ -212,7 +230,9 @@ const OpenAIResponsesErrorPayload = Schema.Struct({ const OpenAIResponsesEvent = Schema.Struct({ type: Schema.String, delta: Schema.optional(Schema.String), + text: Schema.optional(Schema.String), item_id: Schema.optional(Schema.String), + content_index: Schema.optional(Schema.Number), summary_index: Schema.optional(Schema.Number), item: Schema.optional(OpenAIResponsesStreamItem), response: Schema.optional( @@ -237,10 +257,18 @@ interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean readonly lifecycle: Lifecycle.State + readonly messageItems: Readonly> + readonly messageContentIDs: ReadonlySet + readonly nextMessageContentID: number readonly reasoningItems: Readonly> readonly store: boolean | undefined } +interface MessageStreamItem { + readonly providerMetadata?: ProviderMetadata + readonly content: Readonly> +} + type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded" interface ReasoningStreamItem { @@ -298,6 +326,26 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un } } +const messagePhase = (part: TextPart): OpenAIResponsesMessagePhase | null | undefined => { + const phase = part.providerMetadata?.openai?.phase + return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined +} + +const messageItemID = (part: TextPart) => { + const itemID = part.providerMetadata?.openai?.itemId + return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined +} + +const messageStatus = (part: TextPart) => { + const status = part.providerMetadata?.openai?.status + return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined +} + +const messageAnnotations = (part: TextPart) => { + const annotations = part.providerMetadata?.openai?.annotations + return Array.isArray(annotations) ? annotations : [] +} + const hostedToolItemID = (part: ToolResultPart) => { const openai = part.providerMetadata?.openai return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0 @@ -368,17 +416,49 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (message.role === "assistant") { + const inputStart = input.length const content: TextPart[] = [] + let phase: OpenAIResponsesMessagePhase | null | undefined + let itemID: string | undefined + let status: "in_progress" | "completed" | "incomplete" | undefined const reasoningItems: Record = {} const reasoningReferences = new Set() const hostedToolReferences = new Set() const flushText = () => { if (content.length === 0) return - input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) + input.push( + itemID + ? { + type: "message", + id: itemID, + status: status ?? "completed", + role: "assistant", + content: content.map((part) => ({ + type: "output_text", + text: part.text, + annotations: messageAnnotations(part), + })), + ...(phase !== undefined ? { phase } : {}), + } + : { + role: "assistant", + content: ProviderShared.joinText(content), + ...(phase !== undefined ? { phase } : {}), + }, + ) content.splice(0, content.length) + phase = undefined + itemID = undefined + status = undefined } for (const part of message.content) { if (part.type === "text") { + const nextPhase = messagePhase(part) + const nextItemID = messageItemID(part) + if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText() + phase = nextPhase + itemID = nextItemID + status = messageStatus(part) ?? status content.push(part) continue } @@ -429,6 +509,20 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ ]) } flushText() + if (store === false && Object.values(reasoningItems).some((item) => typeof item.encrypted_content !== "string")) + input.splice( + inputStart, + input.length - inputStart, + ...input.slice(inputStart).map((item) => + "type" in item && item.type === "message" + ? { + role: "assistant" as const, + content: ProviderShared.joinText(item.content), + ...(item.phase !== undefined ? { phase: item.phase } : {}), + } + : item, + ), + ) continue } @@ -612,15 +706,134 @@ const NO_EVENTS: StepResult["1"] = [] // the protocol's `terminal` predicate stay in sync. const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) -const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (!event.delta) return [state, NO_EVENTS] +const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => { + const openai = previous?.openai + const phase = item.phase !== undefined ? item.phase : openai?.phase + const status = + item.status === "in_progress" || item.status === "completed" || item.status === "incomplete" + ? item.status + : openai?.status + return openaiMetadata({ + itemId: id, + ...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}), + ...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}), + }) +} + +const messageContentMetadata = ( + providerMetadata: ProviderMetadata, + item: OpenAIResponsesStreamItem, + index: number, +): ProviderMetadata => { + const content = item.content?.[index] + if (!ProviderShared.isRecord(content) || content.type !== "output_text" || !Array.isArray(content.annotations)) + return providerMetadata + return openaiMetadata({ ...providerMetadata.openai, annotations: content.annotations }) +} + +const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => { + const itemID = event.item_id ?? "text-0" + const index = event.content_index ?? 0 + const item = state.messageItems[itemID] ?? { content: {} } + const existing = item.content[index] + if (existing) return { state, itemID, index, item, content: existing } + const findID = (next: number): readonly [string, number] => { + const id = `openai-text-${next}` + return state.messageContentIDs.has(id) ? findID(next + 1) : [id, next + 1] + } + const [id, nextMessageContentID] = + index === 0 && !state.messageContentIDs.has(itemID) + ? ([itemID, state.nextMessageContentID] as const) + : findID(state.nextMessageContentID) + const content = { id, text: "" } + const nextItem = { ...item, content: { ...item.content, [index]: content } } + return { + state: { + ...state, + messageItems: { ...state.messageItems, [itemID]: nextItem }, + messageContentIDs: new Set([...state.messageContentIDs, id]), + nextMessageContentID, + }, + itemID, + index, + item: nextItem, + content, + } +} + +const updateMessageContent = ( + state: ParserState, + itemID: string, + index: number, + content: { readonly id: string; readonly text: string }, +): ParserState => ({ + ...state, + messageItems: { + ...state.messageItems, + [itemID]: { + ...state.messageItems[itemID], + content: { ...state.messageItems[itemID]?.content, [index]: content }, + }, + }, +}) + +const closeOtherMessageContent = (state: ParserState, events: LLMEvent[], item: MessageStreamItem, index: number) => + Object.entries(item.content).reduce( + (lifecycle, entry) => + Number(entry[0]) === index ? lifecycle : Lifecycle.textEnd(lifecycle, events, entry[1].id, item.providerMetadata), + state.lifecycle, + ) + +const appendOutputText = (state: ParserState, event: OpenAIResponsesEvent, text: string): StepResult => { + const ensured = ensureMessageContent(state, event) const events: LLMEvent[] = [] + const lifecycle = Lifecycle.textStart( + closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index), + events, + ensured.content.id, + ensured.item.providerMetadata, + ) return [ - { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, + { + ...updateMessageContent(ensured.state, ensured.itemID, ensured.index, { + ...ensured.content, + text: ensured.content.text + text, + }), + lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text), + }, events, ] } +const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.delta) return [state, NO_EVENTS] + return appendOutputText(state, event, event.delta) +} + +const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (event.text === undefined) return [state, NO_EVENTS] + const ensured = ensureMessageContent(state, event) + if (event.text === ensured.content.text) { + if (ensured.state.lifecycle.text.has(ensured.content.id)) return [ensured.state, NO_EVENTS] + const events: LLMEvent[] = [] + return [ + { + ...ensured.state, + lifecycle: Lifecycle.textStart( + closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index), + events, + ensured.content.id, + ensured.item.providerMetadata, + ), + }, + events, + ] + } + if (event.text.startsWith(ensured.content.text)) + return appendOutputText(ensured.state, event, event.text.slice(ensured.content.text.length)) + return [ensured.state, NO_EVENTS] +} + const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] const events: LLMEvent[] = [] @@ -655,6 +868,23 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => // best-effort, not guaranteed. const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item + if (item?.type === "message" && item.id) { + const existing = state.messageItems[item.id] + return [ + { + ...state, + messageItems: { + ...state.messageItems, + [item.id]: { + ...existing, + providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata), + content: existing?.content ?? {}, + }, + }, + }, + NO_EVENTS, + ] + } if (item && isReasoningItem(item)) { const events: LLMEvent[] = [] return [ @@ -812,6 +1042,32 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const item = event.item if (!item) return [state, NO_EVENTS] satisfies StepResult + if (item.type === "message" && item.id) { + const events: LLMEvent[] = [] + const itemID = item.id + const messageItem = state.messageItems[itemID] + const { [itemID]: _finished, ...messageItems } = state.messageItems + const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata) + const lifecycle = Object.entries(messageItem?.content ?? {}).reduce( + (lifecycle, entry) => + Lifecycle.textEnd( + lifecycle, + events, + entry[1].id, + messageContentMetadata(providerMetadata, item, Number(entry[0])), + ), + state.lifecycle, + ) + return [ + { + ...state, + lifecycle, + messageItems, + }, + events, + ] satisfies StepResult + } + if (item.type === "function_call") { if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult const tools = state.tools[item.id] @@ -939,6 +1195,7 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.reasoning_summary_part.done") return Effect.succeed(onReasoningSummaryPartDone(state, event)) if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) + if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) if (event.type === "response.output_item.done") return onOutputItemDone(state, event) if (event.type === "response.completed" || event.type === "response.incomplete") @@ -968,6 +1225,9 @@ export const protocol = Protocol.make({ hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial(), + messageItems: {}, + messageContentIDs: new Set(), + nextMessageContentID: 0, reasoningItems: {}, store: OpenAIOptions.store(request), }), diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index eb6c95dfbdab..8add02ce6775 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -14,16 +14,19 @@ export const stepStart = (state: State, events: LLMEvent[]): State => { return { ...state, stepStarted: true } } -export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { +export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { + if (state.text.has(id)) return state const stepped = stepStart(state, events) - if (stepped.text.has(id)) { - events.push(LLMEvent.textDelta({ id, text })) - return stepped - } - events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) + events.push(LLMEvent.textStart({ id, ...(providerMetadata ? { providerMetadata } : {}) })) return { ...stepped, text: new Set([...stepped.text, id]) } } +export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { + const started = textStart(state, events, id) + events.push(LLMEvent.textDelta({ id, text })) + return started +} + export const reasoningStart = ( state: State, events: LLMEvent[], @@ -65,7 +68,7 @@ export const reasoningEnd = ( export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { if (!state.text.has(id)) return state const stepped = stepStart(state, events) - events.push(LLMEvent.textEnd({ id, providerMetadata })) + events.push(LLMEvent.textEnd({ id, ...(providerMetadata ? { providerMetadata } : {}) })) const text = new Set(stepped.text) text.delete(id) return { ...stepped, text } diff --git a/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json b/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json new file mode 100644 index 000000000000..bbb47c4cc0e8 --- /dev/null +++ b/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json @@ -0,0 +1,52 @@ +{ + "version": 1, + "metadata": { + "name": "openai-responses-phase/round-trips-commentary-into-a-final-answer", + "recordedAt": "2026-07-23T18:55:21.217Z", + "tags": [ + "prefix:openai-responses-phase", + "provider:openai", + "protocol:openai-responses", + "phase", + "tool" + ] + }, + "interactions": [ + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.6-sol\",\"input\":[{\"role\":\"system\",\"content\":\"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":100,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_09a288a54615e317016a6263962f6081908c42f100432d7432\",\"object\":\"response\",\"created_at\":1784832918,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_09a288a54615e317016a6263962f6081908c42f100432d7432\",\"object\":\"response\",\"created_at\":1784832918,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"commentary\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"I\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"TrlggQ3HsX40yjX\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"’ll\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"OT4APBDnZPskz\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" check\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"y6OCrBSFwy\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"VgUHMFuGRsCe\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" current\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"NfRf0E2g\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" weather\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"FDKK7SxE\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" in\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"VpCRtSlIHUKRx\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Paris\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"7ksv7FLpVR\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"lo7fPMYb2XF2iIz\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":13,\"text\":\"I’ll check the current weather in Paris.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"I’ll check the current weather in Paris.\"},\"sequence_number\":14}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"I’ll check the current weather in Paris.\"}],\"phase\":\"commentary\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":15}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":16}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"j3afFo6Txi12z5\",\"output_index\":1,\"sequence_number\":17}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"k5BeAAvHaqNo\",\"output_index\":1,\"sequence_number\":18}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"p7WgYInwBJjJs\",\"output_index\":1,\"sequence_number\":19}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"Gm7YVJP56OJ\",\"output_index\":1,\"sequence_number\":20}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"DP3YnNaUFF1AFQ\",\"output_index\":1,\"sequence_number\":21}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"output_index\":1,\"sequence_number\":22}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":23}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_09a288a54615e317016a6263962f6081908c42f100432d7432\",\"object\":\"response\",\"created_at\":1784832918,\"status\":\"completed\",\"background\":false,\"completed_at\":1784832920,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[{\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"I’ll check the current weather in Paris.\"}],\"phase\":\"commentary\",\"role\":\"assistant\"},{\"id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":86,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":34,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":120},\"user\":null,\"metadata\":{}},\"sequence_number\":24}\n\n" + } + }, + { + "transport": "http", + "request": { + "method": "POST", + "url": "https://api.openai.com/v1/responses", + "headers": { + "content-type": "application/json" + }, + "body": "{\"model\":\"gpt-5.6-sol\",\"input\":[{\"role\":\"system\",\"content\":\"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"message\",\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"I’ll check the current weather in Paris.\",\"annotations\":[]}],\"phase\":\"commentary\"},{\"type\":\"function_call\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":100,\"stream\":true}" + }, + "response": { + "status": 200, + "headers": { + "content-type": "text/event-stream; charset=utf-8" + }, + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_09a288a54615e317016a626398499481908cda8e1ed0ef45bd\",\"object\":\"response\",\"created_at\":1784832920,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_09a288a54615e317016a626398499481908cda8e1ed0ef45bd\",\"object\":\"response\",\"created_at\":1784832920,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"SQK4VKpPg6o\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"1kFOJRFyao7zE\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"n4cE1OQJnX\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"wd9OLc2GBwngpGN\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"Paris is sunny.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_09a288a54615e317016a626398499481908cda8e1ed0ef45bd\",\"object\":\"response\",\"created_at\":1784832920,\"status\":\"completed\",\"background\":false,\"completed_at\":1784832921,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[{\"id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":140,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":8,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":148},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + } + } + ] +} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json index 47670c81272d..cb028796f318 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -2,7 +2,7 @@ "version": 1, "metadata": { "name": "openai-responses/openai-responses-gpt-5-5-reasoning-continuation", - "recordedAt": "2026-05-23T23:19:06.776Z", + "recordedAt": "2026-07-23T18:57:01.137Z", "provider": "openai", "route": "openai-responses", "transport": "http", @@ -33,7 +33,7 @@ "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXnglldg7hhpTBATVqj7sThK5ATieOVR8sZGYPDW2zYopwpKxA3RyRccK_FPjRvvlzrvL-FitOxmdMGBaKa5jncrT9hHo5IMhsFsCEHkQ1x5tlrKPqtfwJ_LFexR0h_IpPogu8wlVAkHRoWQoq61o9vBxjMOEsq6dtXu09959gXnAvJA3jN_mqNkRZ7Yp6LaJJtLDAAtt_dhX8veoEFXZ412lCY4zcaMvC5o0yq6MPvLIN4NhHmfPKkVAy-j8wGlgA42KR4wd5-VeFXUdeSn32dlNLZZxBFa9w6iTgCQ9aF-3C7RB4OXeSY782QUD1dRyFybd7vJtjlptwXBntSHZ9wugoKSDEj0KnvQKG_WiCWuJvkGiOVno4MAs5QnCmKBnpak5OV1wOhPwX2ez6OmAYT4mMKIogdfivVvUxMrmdVJzgE85WoZEAU2ZporxVXkI7_8p0L6dxxwk_IKiKSCz-bZgsCtOP5Jsr5GeI831nVv272kZ3DugV-hcjGHAE5T9KhebzpFjsdxnJcfxuGY8SyRaLlUAHM_37H4veHsOzyhCoaG8mMaT3gIb4tAvM7ezd1xzLsFae89P5xCv_fNeoV7qmf2IWDWUi1vitIib5w9jsclWRqYaLVZR0GK6dYyNJ1DXDOOcWRdH7UJakv1m2koUbcYWBuxao7sc-af_9ySKAloWhb6QjiVElJHYtwraJBtX-CLBVHEYqAXmZgMUWVbz8NNRA6JS1TrOys7_LiQtXXubLWas_66LyaqmB-628LCUitUISYYc2wmq1uUm7gjPA53Wm4F7VU6g-PO7bt1O0Nd-jasisPXINTX3Z4hgC1APPEq29iEHwmEPnicO_Nu6U3JLfq4DD6r1oLK-RnIp3Ratw0P-Gwog86RBLGUWIEIKdFu6m9d1TI8rIBbAVaBA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"3nRhhCWA1H8\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"60NqChSEyXHKsoy\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578344,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoMO9Ci_Q06HKQ0YDBarUvbp9ulkR9W2RXWPbx7XKokNCrKUZX-pPPGpUg6r-vTXe8iEX-oED6TmjxZV_nyo838x6pmQJlDqz5JECs2axIrUbCjv9xBt3ob8eAyOizhKFjp3dJNu4i01c38MPZ5QYpD24uCKf69jzjUfydKIEjbo0VhP3K6SDG0V9ZUtua-e6WMqzIg-W5Zs3u64DxGw974ntmvNsx8lsuLR-bk9S5ZZ7zPlCG2Emwfph8UE5HJmIfmMxYlrY5qmXSWKDhse9hovQj-TrvbllP-0vLNQWEPLc3aUfVrWWR9i3NZZ-nxJZiIJPCF3xxIIyKaLh9a6Lh9J6Z-brsvVfbVJWXIGZhsu-uKk6Gwoqo56KqHdNaPF7lkPo5GAWfMrweCnJZ4o_j-oWm8BwTkXxrLib4XYKDO2JNqrNdbmy8rZ7UGgW_DVTiNyZi6LoRfSuvK45MWV2uzB_OJ9LBcqgscY4HyPvKrhGG4Peh4iXuBUCyQQ2IudM5GbeeMOAF3dnEzZff68SwE1H56CO6PtKhVQ6cFJMf7LwI5LFFio0qJnEDx-MejvU7PxmYW7R3MEbgjbsuEFU5KnRVYsgug3_Bq1vXdmP2qhebufFZwz26SwaFqyn3xjwCP8-GR7lWCZ2EvUvWtfxJ5_zgkZg06UsF4Eo_CWKFdp0ao43nemNJxOlMzFa6tPuCgplmD0oYoQ316f-bWK02-eJk56S7G4bZSk8cQfExfIZMjW2f-qrxvfxEpFiXsZF80BQwgRUOeKjsqidg2ihdldRkXGn3vX8p15mf1UgstU8y3DNd2_qJe1f_pEl6rWNXoxFdSRCTG7wTAqbCCmuDgCKGhNQY9tfJNsFqgWBIGkqKy88DN_HiWywJjJ-5u9aoe68yDK-E0TMDqs7ZrTely1wvmkl2yF0XQttaB30taxkIcRR-n0PRO-CRNA_9nJkw9ZsBb8oBjyqWH_mwSijT5g==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":51},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05f4a9bec03f8148016a6263faf3bc81959b717b99f194a37c\",\"object\":\"response\",\"created_at\":1784833018,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05f4a9bec03f8148016a6263faf3bc81959b717b99f194a37c\",\"object\":\"response\",\"created_at\":1784833018,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_05f4a9bec03f8148016a6263fb83748195b13a0e6b77c0dd88\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqYmP7gx69qnBLNDRIOVvsxN78GS99z9MYWR_FTPUwnVyq8J470WTXQWQEqp9PF8tLyswAhxg2PkxL7ijhN9190IV6QR1waQUzNd6NLYDc5-GG8mJ0LdKYPABZqFYRWV6MEJhDW2CrR5XiGErIzIlMCWEBkE79DDmLOdyOEVMTtetK_CUuxbBGnQ2_8_16FP8AXCqL6xKBCzFxTsQDa9oKTuMoS7jczBlC71fWBiw_cEfILOIUe7_5K4ze7MJG079Ty580gZCAQ0vteMnKpPKSfugMhKlVB_9Wn3wofeL-Xf8s1QojpIAUgHE_fh9fMNGeMkEsUFKhz_vOQrU7FH3NpGTYs0qKQVJaX9FHrR5EooMMimg2TFqc3GZkHJt9VJe1dhfNzcnybIdyz5s0Y6l-1FSD90_8mHR3ZUdfgOBYrsDfVvXpCUdyn4lQGJMkJYgBqAZpXZHvRw16t9_6stCvsScdb5nGrat54qbSYx1mfXD9S2RPw57vzcnClSpFMzen9oMzPWnBxjQujMzjauz_Ps4UJ_H59XUcvuL8fzWv00qhVvbL59NCMpQFdY0b4TtDuIKT0tgEHRKiHMN4zl3zoVJ9bp8qsxVTBBkIF3Q-Wsf4655dkD3yV0H66Yp3XAO5hvRO-0y8FMMpWcc0N5liLOMKiCKj9XMeGi2p5nF66vkPs3fm880W3S2fyOVsxbTWF_TVDJ1SDaHn_xPzzYQCg047sGOQ9S6jvAfblBqIO9iOV3zOF-Gu5Zv9gbJEywTIPSx_u8sKNAqvlxpZ6zJLOyjipByUvFb27MYJLdPw9fIV9w5D2XVwowB8Bg13joWKgm5ne75-VudmIlm-AoWC78zT2_WIKpIN0jYhJDOgxIgpFeI4LEGKh6BlVSQ0-CRrOGw1FnsxKfGN0FL9ChLs-rz4yA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_05f4a9bec03f8148016a6263fb83748195b13a0e6b77c0dd88\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqYmP8aJHmIOttZSqCfZrRqPkxHEGRKL8agRHsomAtpHOM91zNm-YwW-Tupi5CFHy3SFlIatT1_VVZLSR50Mf1jZezXHUkIdB003BGPUwaeHlhlq3B63dukCmAGJso8xJvDw0YqYppy2FXNIoPoys3wI4n8LSTiUOm6yXEyUkaBt4sXLeWsg_NLqYo-ilmv60ppKV1vPQNZkHAX2N0PCZebSmzto5a896xV7jkKVsEMhanPnKP8N3G_ag_-B4ZPr01IAma4xJ2jXysRX_P-zN7AqGDRxBuuoIzylLizo68wNH9cXmnvQcCEkjKxOTpmQGCl0LxSnK_UnaLwErtj9FUMXDaDqbfugCZrCy5pUq0RgBSoAnZvOqH6pWpXPvclQI9Wsg-Ra3bv8sYtf2qYBMebaNIbI_BVVKN8Lrpj7UWODiRZImgfUWHuflaf98H1oD09f7e55gg7GlQ0r6EVZwbuX3GaEwsHbDe7KdsPmwV_RV_ZQpxm14aBypGtapXEAd8SMJxygZtuQKCTJuOwsgZug_I1cGw4EF5AIg5EWzVhvJmlzPrBqas-VdL0_VIfohJAm9181eYKX5ITeUZkdrNpwk7yMCLG4yHCOiP_x33Gyz3xOOUzNSVMy56F7f_K1Bd-eqHK4zMsYrS4-c2cbC5HPPNUUFh8-CaMaJ-34P2TvTHrxR9QY6PXMyfiEMhNC8Mpblhh3ShCvNWTAsrujWHmtDRp47cSNsiNxTSRMU75OsDviGcnuNCZDHBA-92N9g-nPqBvqJfFJXsHbpNzLRCgkv3O8ZOvMPOLLM4OZu1fELgAdKqWNupZgys6n1pUVsRGctECeVZ4NOafuvHvm6wUHRXuLLUN6ATJJ4RdSD_DAtnGD1QFstXwEeBHgBwMMppaIgBvZGbz5skrDm5NBv7AEdCl6UzZ-0zWP6AFxowTzTNuHUyIO-Buwvq_hmLn2hemhr0wOBBgTqFcW51cnybHt7sQD6AhGYlLjNnGu5XH7qcdTY=\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"logprobs\":[],\"obfuscation\":\"l9gChAJHAvW\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"logprobs\":[],\"obfuscation\":\"MgDcWbMrxjIR4h6\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05f4a9bec03f8148016a6263faf3bc81959b717b99f194a37c\",\"object\":\"response\",\"created_at\":1784833018,\"status\":\"completed\",\"background\":false,\"completed_at\":1784833020,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_05f4a9bec03f8148016a6263fb83748195b13a0e6b77c0dd88\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqYmP8gH5Qc4oBXBDbJyFBgvZVodKHctAOFeZp3khLa37IbJljUkbx7ERw0pgedvhUBaQsRKB31czAty8Ruv9PJVbyoSUpVamjmVM0-FD9lHpwNDnLPjhjPzcAxQosXeVGuWp6jxwmKjzIowwLFp7ImRG2syV-2-QqZ6Wef4VuPzmQ6RVVQUeqBz5Pvhf0FKkqzgb9T3vv0bV-V8qFsdbNKGtQvKKciR5fL6YaUlmY0gsru8UDabWjNjL8oyjZI0HezNcvYNLfmmzptyYpfw208LFiTeD8ntf6xClGgbBztiMQDQhOjQoeaZrrOCaB3iqxbe-_x8xAMRdUpS9NmhJ8oHH1w4JaPQ-Gjvzrrjq0kqpVYXHmdZ-cmwPpRG_0mh4yHkIUYXb0cvoaMyKWLXGcVf3OWLmkEZCa0GuOvwKz8QaPoOAer0L-GrV4p1GadjEUG07EvwsSRyG3fPckMJsSLxW70NNipaGmJys-ihR5YU5qqz74EnpJpYFJnh7Anl5ETl92-ZA4tqAevrW2dwoCNIfGkuX7GMrpPLxTs5sZ1UQWppPyfji-f0YY7198_Ypf8XZfWhB46HYipjSwqErvUyHpE6UpkqWQwwX5wa29clrUrjxTnn1qiw0G2PgvKziwBpImhfWDRGafirQ9G4lh1I8GS5K7vDPVtOPnzxFccYFX4oB5NgSdADMtm5cFKgAqVNM4l04E2FMYJvPr5qn79EWf9AwfOFIuwwNE_sWSkN8VU1-iob0_03iMJ_U2lbT9BT65aJhhSmFUfaqyliC9rUwnFXYEgoFC7jAZ6_LhNJ4ONM26KPUhGu0Uv5xq3aAyk_EKQ_quvYF7euqijnIAzjk8rsV7w7_Qj0Ujnoz7M7QSJx5VaGUCXR8O_pKBcBVLayBe957WZ154qfTYIK96FyOcqM5_RGHlvvfVRi3pnbrQR_odU1e2pAL5hdCyvKtZ3GtN0kaGU1wJOersW5QCUyTIJ9H9ax-YJn3_4Xe4PKKEr5Q=\",\"summary\":[]},{\"id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":21,\"output_tokens_details\":{\"reasoning_tokens\":13},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" } }, { @@ -44,14 +44,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqYmP8aJHmIOttZSqCfZrRqPkxHEGRKL8agRHsomAtpHOM91zNm-YwW-Tupi5CFHy3SFlIatT1_VVZLSR50Mf1jZezXHUkIdB003BGPUwaeHlhlq3B63dukCmAGJso8xJvDw0YqYppy2FXNIoPoys3wI4n8LSTiUOm6yXEyUkaBt4sXLeWsg_NLqYo-ilmv60ppKV1vPQNZkHAX2N0PCZebSmzto5a896xV7jkKVsEMhanPnKP8N3G_ag_-B4ZPr01IAma4xJ2jXysRX_P-zN7AqGDRxBuuoIzylLizo68wNH9cXmnvQcCEkjKxOTpmQGCl0LxSnK_UnaLwErtj9FUMXDaDqbfugCZrCy5pUq0RgBSoAnZvOqH6pWpXPvclQI9Wsg-Ra3bv8sYtf2qYBMebaNIbI_BVVKN8Lrpj7UWODiRZImgfUWHuflaf98H1oD09f7e55gg7GlQ0r6EVZwbuX3GaEwsHbDe7KdsPmwV_RV_ZQpxm14aBypGtapXEAd8SMJxygZtuQKCTJuOwsgZug_I1cGw4EF5AIg5EWzVhvJmlzPrBqas-VdL0_VIfohJAm9181eYKX5ITeUZkdrNpwk7yMCLG4yHCOiP_x33Gyz3xOOUzNSVMy56F7f_K1Bd-eqHK4zMsYrS4-c2cbC5HPPNUUFh8-CaMaJ-34P2TvTHrxR9QY6PXMyfiEMhNC8Mpblhh3ShCvNWTAsrujWHmtDRp47cSNsiNxTSRMU75OsDviGcnuNCZDHBA-92N9g-nPqBvqJfFJXsHbpNzLRCgkv3O8ZOvMPOLLM4OZu1fELgAdKqWNupZgys6n1pUVsRGctECeVZ4NOafuvHvm6wUHRXuLLUN6ATJJ4RdSD_DAtnGD1QFstXwEeBHgBwMMppaIgBvZGbz5skrDm5NBv7AEdCl6UzZ-0zWP6AFxowTzTNuHUyIO-Buwvq_hmLn2hemhr0wOBBgTqFcW51cnybHt7sQD6AhGYlLjNnGu5XH7qcdTY=\"},{\"role\":\"assistant\",\"content\":\"Hello!\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqB-kOX_0QAeoEksgNjwSbtGmVEQuMj5ODcFV6b7Kp3E8RoHRRmSXtRH0rtNbZRbhKz5jM48DUpDI1WTeO2HqCd_A3fsSgFxp5ACGVFjPWjfvP2JMdDkpoOo5gu2zy7WsWY0fseocQQ5q_jfG6SWw0fyaeeqfdQ9HkcHyg6gVEl5skb4L8_2lD5nClmLlNVVh5JCuXRH9eYysrfO19NOZ29A2MVUX-XgB6mmK5uSb1jE43GhrEPPYrMbB5JyzM6B-yeB8rE4H2wx530hQqtxwSZREa8G03rzTJ49_KAPWl0djGDDtufUX-t4EpBHo6loA3PMuiZ3VsJTkkPpEqkm6QQyAVkQ_8AdRu12CqHbFdu73I-BnArzr33yW6reNUjnZjFV5bWDyxIMh6ljy3O_2nGk-qdTLt6bGJbEjTdPj1hi7icYZTVPqofPU4pjlo9BnIBheo-4u9pA26V9G9vDAtM4myDdMEe4pnieUztBUYPUOVMaG2U9gqtNs6iPehKo3BeKy4lhYPorL2OPmf1lVUQOCW1MBbwT5xt1kjOVw7LggnyjrBsXVvDBWg0AFcvm14r3ZQezPgLetQfSx56mVEJpui9BuVSUg2Xvqb5tCCip6TipUVvzZJKKkxN43o8N6UVXLIn6wgstAn9727JgBEsjMxzvuOWaaI-qM3dWMcFzFSGvKb6gTiF37AhSOzosf31hnsGx8AnGmLmbuW3IMhZXZZMHgVUHx4p8pNRPeoCE-Bv833KZbRfVxe6tbmkLadBjXYaCQqPXDHBR7qbpfu4_M9UAy0kpic-joepxQKsCT2t6vsNaThRYaN3PtIgjs5xxAfa9yKeIYak8yiP96CUlME9Y7zaOIydPWYBhWLf3phQsWMax9eKdLDh19f0Y0iAJPpk--1xd2OVTuDxKIMpA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqW5MInCBPKulZ1lizyFtOaKUpKgHldAXVjTTs4XFE45gtxC1NbJoOi2tHoQhpfq-JGxtjSQEDTHnMCiLLhyqvQ4GlWVF4n51xcFVC_WgymkZqDxG6xPw8ITAsRI5vb8HiPO6EmmKt6xGIVXOjrrxRNAY3xtrByeYSvnCa6FDUHEkMeXmwllBalCeQPNDPl0Ub2ehuchNG0loMVLJoOjT-2KgDrXlOa6rCn3nUf4U1W5JA_kHytlgrD0IPbs7nY8wemdynJRXBoNSOT_U3nQSB6j-i4KIJAdLiUs9LVWMYleqmFQNs8S4dC3i5DfpHXWUMZ5Ai1d3gbvMP8bH7fsUyfIhyiDUvlgr6PZ9rfh8JqkjOpiQ7NFtSDuHQGdx__W3qi23WPDp3iQjKxVl1oUXfbMzsPE4bmNN9dnJ9qTQ43kvw8GyrGrSqRS8jCKuk9bxqeR_ibj4KoDdxvVbUeGMg3WKANfCRsNXlxwYtMpu3I4HxKm5EuMNKDg_e9RFH2wFEDm9wCKMZrC_5LShgKhSfhsk3yJ47Mit0zYdX27kyHGlZvpzLPYKdtHW1O15KNT4gFKBrIguCtjXz2Lb42ENM6Jo8BTY7BZbf0hXZ4A5mFNl_gyLVHWEHpR1GcYiJbs9RtQ-7qX_PTeZ11iFY3a7_jM7TK3WEN2IuG0OKbZVHvOkVcvyBEgIbzSzCzhtC-j584knI4WmYiqnltuwRcR2N3sxYY3vMcYGA2_AU5kYlZcJcztapTTW-aKbyGxPcw5D_dqb5mGpDqJgquye-qOufDt4Fd7cSc-g8awqR8QPsLz-ZDPLMB9JKQ3VqLQlNCKDUoDodGOAL3h-7EQG66osALfhpdsWcNmuVqlb0lNAXklrsZJtRKBU4pJ1UGCyVDwde7nv6I9PW19VumaRrJhc2cC52qUoyihvUo8xJsElaFp7-EHn5ymS4znZhRfyA_4UDL4rwj-3DqbMwNeDJrgBc3w==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"7tyE7hMvNOTM\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"RGXvuTTSJS3AT5E\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578346,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXq8oliF2VeqiOUi-jUdi49emjffD6wtbmxlwQWbJ6tSxXIjyXvCeclOqKx83G83GyDOJqvR4L_D8V_ebJtgG87ahWB8Rr9LEQDoLT24n4Vz279xtHMxEGgv7f0NmaXu2dGFeFY_s2RhH-DqNE7V4nEkS7odJOTkhxTKgEcxtz3dDlEnGU7IgN2sD1lh9y90BD3ysvARegy4Cs0DhUjLOvkx11G9lk5dQ3yo1ek8JhTHpnVSYrLDYIudCh6pfu1yP1tx8xbxDHUcwlNclU9Hp_9ils5FhZNWC_tiLDscXXvRPBgMF77jdOicCV6cyUV0Snsu1_KSRbm4rLtgXLXVMqFyYpxdyicsD577e4yZ0VVXT4Oo_af0eDh3I3ZPIWui38EmYuoRhvQuYZkqjhGd_xOkvjQF4_Tp6cyNO0XdAMGMoYG-5npHC0gcPpv56qYGX8ffj0P8ZyR9shn3H7kcQqE2YXXBa42VKK0poPbC996xSqFNW7ygePel41h493XlJ70wnP50vFY5s0raNFf9eLP3YYmLxiPks9gshayGwUQXNNwrSimoQv3OeJzRzihbzZNWTfhR4xKs53nlXMjwnnXwHRH5D07vJg_1zU7BQzJ-QRLZnsnhIOq3psHt1yuoCtsSTKBN6HPiR81F-snIttJiUAiYsgv_ajwPxxnKP0FnFXQfBuaUAtAOD5G_3MC1yECjzq-YI4MDOXj4dsIGnHkdzXo-DV2lXMl2WnPqytoUkugp14SWbJso-eDsN5QivqspnYc1VsdNAaOOgjBiHmi-bACI1CykrkuiYJm1nOHAH4L4IQjpd0pcNm-Dk7z9LGIE5lwKI07hLXp_ByhVXRT8xWuugl43pzoM1jgYD4LjTjScC3ymauqqvKjjoHfnt0Zma0eVDeQrnVT6W9RQ9wDt5KVebrrwJTqlaNV0HywZJo3gwFy-Qq5MfwAwwC-GdjMsER1TgXO_E5kFZZD4sNVgw==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":55},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06418d4b810c5ed5016a6263fc610081909e978e5ca1a9edd0\",\"object\":\"response\",\"created_at\":1784833020,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06418d4b810c5ed5016a6263fc610081909e978e5ca1a9edd0\",\"object\":\"response\",\"created_at\":1784833020,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"logprobs\":[],\"obfuscation\":\"M3ODGG2egQCH\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"logprobs\":[],\"obfuscation\":\"fm15a3vpfYAW4br\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06418d4b810c5ed5016a6263fc610081909e978e5ca1a9edd0\",\"object\":\"response\",\"created_at\":1784833020,\"status\":\"completed\",\"background\":false,\"completed_at\":1784833021,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":41},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" } } ] diff --git a/packages/llm/test/provider/openai-responses-phase.recorded.test.ts b/packages/llm/test/provider/openai-responses-phase.recorded.test.ts new file mode 100644 index 000000000000..f8ea26054108 --- /dev/null +++ b/packages/llm/test/provider/openai-responses-phase.recorded.test.ts @@ -0,0 +1,89 @@ +import { describe, expect } from "bun:test" +import { Effect } from "effect" +import { LLM, Message } from "../../src" +import * as OpenAI from "../../src/providers/openai" +import { OpenAIResponses } from "../../src/protocols/openai-responses" +import { LLMClient } from "../../src/route" +import { weatherTool } from "../recorded-scenarios" +import { recordedTests } from "../recorded-test" + +const model = OpenAI.configure({ + apiKey: process.env.OPENAI_API_KEY ?? "fixture", +}).responses("gpt-5.6-sol") + +const recorded = recordedTests({ + prefix: "openai-responses-phase", + provider: "openai", + protocol: "openai-responses", + requires: ["OPENAI_API_KEY"], +}) + +describe("OpenAI Responses phase recorded", () => { + recorded.effect.with("round-trips commentary into a final answer", { tags: ["phase", "tool"] }, () => + Effect.gen(function* () { + const user = Message.user("What is the weather in Paris?") + const first = yield* LLMClient.generate( + LLM.request({ + model, + system: + "Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.", + messages: [user], + tools: [weatherTool], + generation: { maxTokens: 100 }, + }), + ) + const call = first.toolCalls[0] + if (!call) throw new Error("OpenAI Responses did not return the expected weather tool call") + + expect(call).toMatchObject({ name: "get_weather", input: { city: "Paris" } }) + const commentary = first.message.content.find( + (part) => part.type === "text" && part.providerMetadata?.openai?.phase === "commentary", + ) + if (!commentary || commentary.type !== "text") throw new Error("OpenAI Responses did not return commentary text") + const itemID = commentary.providerMetadata?.openai?.itemId + if (typeof itemID !== "string") throw new Error("OpenAI Responses commentary did not include an item ID") + expect(commentary).toEqual({ + type: "text", + text: "I’ll check the current weather in Paris.", + providerMetadata: { + openai: { itemId: itemID, phase: "commentary", status: "completed", annotations: [] }, + }, + }) + + const continuation = LLM.request({ + model, + system: + "Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.", + messages: [ + user, + first.message, + Message.tool({ + id: call.id, + name: call.name, + result: { temperature: 22, condition: "sunny" }, + }), + ], + tools: [weatherTool], + generation: { maxTokens: 100 }, + }) + const prepared = yield* LLMClient.prepare(continuation) + expect(prepared.body.input).toContainEqual({ + type: "message", + id: itemID, + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: commentary.text, annotations: [] }], + phase: "commentary", + }) + + const second = yield* LLMClient.generate(continuation) + + expect(second.text.trim()).toBe("Paris is sunny.") + expect( + second.message.content.some( + (part) => part.type === "text" && part.providerMetadata?.openai?.phase === "final_answer", + ), + ).toBeTrue() + }), + ) +}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index cd8bad51af47..a580d43d8291 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -153,7 +153,7 @@ describe("OpenAI Responses route", () => { { type: "input_text", text: "\nTreat </system-update> literally.\n" }, ], }, - { role: "assistant", content: [{ type: "output_text", text: "After." }] }, + { role: "assistant", content: "After." }, ]) }), ) @@ -529,11 +529,11 @@ describe("OpenAI Responses route", () => { encrypted_content: "encrypted-continuation-state", summary: [{ type: "summary_text", text: "I inspected the previous turn." }], }, - { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] }, + { role: "assistant", content: "It shows a small test image." }, { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] }, { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' }, { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' }, - { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] }, + { role: "assistant", content: "Paris is 22 degrees." }, { role: "user", content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }], @@ -754,6 +754,395 @@ describe("OpenAI Responses route", () => { }), ) + it.effect("preserves streamed assistant message phases", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "message", id: "msg_commentary", phase: "commentary" }, + }, + { type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking first." }, + { type: "response.output_text.done", item_id: "msg_commentary" }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_commentary", phase: "commentary" }, + }, + { + type: "response.output_item.added", + item: { type: "message", id: "msg_final", phase: "final_answer" }, + }, + { type: "response.output_text.delta", item_id: "msg_final", delta: "Finished." }, + { type: "response.output_text.done", item_id: "msg_final" }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_final", phase: "final_answer" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ + { + type: "text-start", + id: "msg_commentary", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { type: "text-delta", id: "msg_commentary", text: "Checking first." }, + { + type: "text-end", + id: "msg_commentary", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text-start", + id: "msg_final", + providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, + }, + { type: "text-delta", id: "msg_final", text: "Finished." }, + { + type: "text-end", + id: "msg_final", + providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, + }, + ]) + expect(response.message.content).toEqual([ + { + type: "text", + text: "Checking first.", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text", + text: "Finished.", + providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, + }, + ]) + }), + ) + + it.effect("preserves phased message and content boundaries", () => + Effect.gen(function* () { + const response = yield* LLMClient.generate(request).pipe( + Effect.provide( + fixedResponse( + sseEvents( + { + type: "response.output_item.added", + item: { type: "message", id: "msg_commentary", phase: "commentary" }, + }, + { + type: "response.output_text.delta", + item_id: "msg_commentary", + content_index: 0, + delta: "First.", + }, + { + type: "response.output_item.added", + item: { type: "message", id: "msg_commentary" }, + }, + { + type: "response.output_text.done", + item_id: "msg_commentary", + content_index: 0, + text: "First.", + }, + { + type: "response.output_text.done", + item_id: "msg_commentary", + content_index: 1, + text: "Second.", + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_commentary" }, + }, + { + type: "response.output_item.added", + item: { type: "message", id: "msg_commentary_2", phase: "commentary" }, + }, + { + type: "response.output_text.delta", + item_id: "msg_commentary_2", + content_index: 0, + delta: "Thi", + }, + { + type: "response.output_text.done", + item_id: "msg_commentary_2", + content_index: 0, + text: "Third.", + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_commentary_2", phase: "commentary" }, + }, + { + type: "response.output_item.added", + item: { type: "message", id: "openai-text-0" }, + }, + { + type: "response.output_text.done", + item_id: "openai-text-0", + content_index: 0, + text: "Final.", + }, + { + type: "response.output_item.done", + item: { + type: "message", + id: "openai-text-0", + phase: "final_answer", + content: [ + { + type: "output_text", + text: "Final.", + annotations: [ + { + type: "url_citation", + url: "https://example.com", + title: "Example", + start_index: 0, + end_index: 6, + }, + ], + }, + ], + }, + }, + { + type: "response.output_item.added", + item: { type: "message", id: "msg_null", phase: null }, + }, + { + type: "response.output_text.done", + item_id: "msg_null", + content_index: 0, + text: "Nullable.", + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_null", phase: null }, + }, + { + type: "response.output_item.added", + item: { type: "message", id: "msg_unphased" }, + }, + { + type: "response.output_text.done", + item_id: "msg_unphased", + content_index: 0, + text: "Unphased.", + }, + { + type: "response.output_item.done", + item: { type: "message", id: "msg_unphased" }, + }, + { type: "response.completed", response: { id: "resp_1" } }, + ), + ), + ), + ) + + expect(response.message.content).toEqual([ + { + type: "text", + text: "First.", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text", + text: "Second.", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text", + text: "Third.", + providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, + }, + { + type: "text", + text: "Final.", + providerMetadata: { + openai: { + itemId: "openai-text-0", + phase: "final_answer", + annotations: [ + { + type: "url_citation", + url: "https://example.com", + title: "Example", + start_index: 0, + end_index: 6, + }, + ], + }, + }, + }, + { + type: "text", + text: "Nullable.", + providerMetadata: { openai: { itemId: "msg_null", phase: null } }, + }, + { + type: "text", + text: "Unphased.", + providerMetadata: { openai: { itemId: "msg_unphased" } }, + }, + ]) + + expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ + { + type: "text-start", + id: "msg_commentary", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { type: "text-delta", id: "msg_commentary", text: "First." }, + { + type: "text-end", + id: "msg_commentary", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text-start", + id: "openai-text-0", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { type: "text-delta", id: "openai-text-0", text: "Second." }, + { + type: "text-end", + id: "openai-text-0", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text-start", + id: "msg_commentary_2", + providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, + }, + { type: "text-delta", id: "msg_commentary_2", text: "Thi" }, + { type: "text-delta", id: "msg_commentary_2", text: "rd." }, + { + type: "text-end", + id: "msg_commentary_2", + providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, + }, + { + type: "text-start", + id: "openai-text-1", + providerMetadata: { openai: { itemId: "openai-text-0" } }, + }, + { type: "text-delta", id: "openai-text-1", text: "Final." }, + { + type: "text-end", + id: "openai-text-1", + providerMetadata: { + openai: { + itemId: "openai-text-0", + phase: "final_answer", + annotations: [ + { + type: "url_citation", + url: "https://example.com", + title: "Example", + start_index: 0, + end_index: 6, + }, + ], + }, + }, + }, + { + type: "text-start", + id: "msg_null", + providerMetadata: { openai: { itemId: "msg_null", phase: null } }, + }, + { type: "text-delta", id: "msg_null", text: "Nullable." }, + { + type: "text-end", + id: "msg_null", + providerMetadata: { openai: { itemId: "msg_null", phase: null } }, + }, + { + type: "text-start", + id: "msg_unphased", + providerMetadata: { openai: { itemId: "msg_unphased" } }, + }, + { type: "text-delta", id: "msg_unphased", text: "Unphased." }, + { + type: "text-end", + id: "msg_unphased", + providerMetadata: { openai: { itemId: "msg_unphased" } }, + }, + ]) + + const prepared = yield* LLMClient.prepare( + LLM.request({ model, messages: [response.message] }), + ) + expect(prepared.body.input).toEqual([ + { + type: "message", + id: "msg_commentary", + status: "completed", + role: "assistant", + phase: "commentary", + content: [ + { type: "output_text", text: "First.", annotations: [] }, + { type: "output_text", text: "Second.", annotations: [] }, + ], + }, + { + type: "message", + id: "msg_commentary_2", + status: "completed", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: "Third.", annotations: [] }], + }, + { + type: "message", + id: "openai-text-0", + status: "completed", + role: "assistant", + phase: "final_answer", + content: [ + { + type: "output_text", + text: "Final.", + annotations: [ + { + type: "url_citation", + url: "https://example.com", + title: "Example", + start_index: 0, + end_index: 6, + }, + ], + }, + ], + }, + { + type: "message", + id: "msg_null", + status: "completed", + role: "assistant", + phase: null, + content: [{ type: "output_text", text: "Nullable.", annotations: [] }], + }, + { + type: "message", + id: "msg_unphased", + status: "completed", + role: "assistant", + content: [{ type: "output_text", text: "Unphased.", annotations: [] }], + }, + ]) + }), + ) + it.effect("parses reasoning summary stream fixtures", () => Effect.gen(function* () { const body = sseEvents( @@ -947,7 +1336,7 @@ describe("OpenAI Responses route", () => { encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked the previous diff." }], }, - { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] }, + { role: "assistant", content: "The parser changed." }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, ], }) @@ -995,13 +1384,69 @@ describe("OpenAI Responses route", () => { ) expect(prepared.body.input).toEqual([ - { role: "assistant", content: [{ type: "output_text", text: "Before." }] }, + { role: "assistant", content: "Before." }, { type: "reasoning", encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked order." }], }, - { role: "assistant", content: [{ type: "output_text", text: "After." }] }, + { role: "assistant", content: "After." }, + ]) + }), + ) + + it.effect("round-trips assistant message phases", () => + Effect.gen(function* () { + const prepared = yield* LLMClient.prepare( + LLM.request({ + model, + messages: [ + Message.assistant([ + { + type: "text", + text: "Checking first.", + providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, + }, + { + type: "text", + text: "Still checking.", + providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, + }, + { + type: "text", + text: "Finished.", + providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, + }, + ]), + ], + }), + ) + + expect(prepared.body.input).toEqual([ + { + type: "message", + id: "msg_commentary", + status: "completed", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: "Checking first.", annotations: [] }], + }, + { + type: "message", + id: "msg_commentary_2", + status: "completed", + role: "assistant", + phase: "commentary", + content: [{ type: "output_text", text: "Still checking.", annotations: [] }], + }, + { + type: "message", + id: "msg_final", + status: "completed", + role: "assistant", + phase: "final_answer", + content: [{ type: "output_text", text: "Finished.", annotations: [] }], + }, ]) }), ) @@ -1120,7 +1565,13 @@ describe("OpenAI Responses route", () => { }, }, }, - { type: "text", text: "The parser changed." }, + { + type: "text", + text: "The parser changed.", + providerMetadata: { + openai: { itemId: "msg_1", phase: "final_answer", status: "completed" }, + }, + }, ]), Message.user("Summarize it."), ], @@ -1131,7 +1582,7 @@ describe("OpenAI Responses route", () => { expect(prepared.body).toMatchObject({ input: [ { role: "user", content: [{ type: "input_text", text: "What changed?" }] }, - { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] }, + { role: "assistant", content: "The parser changed.", phase: "final_answer" }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, ], store: false, diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index dd4d9cc17481..09b2ffbd160e 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -119,7 +119,7 @@ const storedSession = { const openAIResponses = { user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }), - assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }), + assistant: (text: string) => ({ role: "assistant", content: text }), openaiReasoning: (text: string, encryptedContent: string) => ({ type: "reasoning", encrypted_content: encryptedContent, From 53669cab2b19815f03c73518fbad0790da31b65b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Fri, 24 Jul 2026 21:07:01 +0000 Subject: [PATCH 065/333] chore: generate --- .../round-trips-commentary-into-a-final-answer.json | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json b/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json index bbb47c4cc0e8..c7abea9faf2d 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json +++ b/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json @@ -3,13 +3,7 @@ "metadata": { "name": "openai-responses-phase/round-trips-commentary-into-a-final-answer", "recordedAt": "2026-07-23T18:55:21.217Z", - "tags": [ - "prefix:openai-responses-phase", - "provider:openai", - "protocol:openai-responses", - "phase", - "tool" - ] + "tags": ["prefix:openai-responses-phase", "provider:openai", "protocol:openai-responses", "phase", "tool"] }, "interactions": [ { From 7840562d1b7ec46bc2beb02e2114ce14f7b9a384 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:31:38 -0500 Subject: [PATCH 066/333] fix(llm): revert response message phases (#38761) --- .../llm/src/protocols/openai-responses.ts | 270 +--------- packages/llm/src/protocols/utils/lifecycle.ts | 17 +- ...-trips-commentary-into-a-final-answer.json | 46 -- ...ponses-gpt-5-5-reasoning-continuation.json | 8 +- .../openai-responses-phase.recorded.test.ts | 89 ---- .../test/provider/openai-responses.test.ts | 467 +----------------- .../opencode/test/session/llm-native.test.ts | 2 +- 7 files changed, 25 insertions(+), 874 deletions(-) delete mode 100644 packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json delete mode 100644 packages/llm/test/provider/openai-responses-phase.recorded.test.ts diff --git a/packages/llm/src/protocols/openai-responses.ts b/packages/llm/src/protocols/openai-responses.ts index 8e2abc91085f..4936d31c921b 100644 --- a/packages/llm/src/protocols/openai-responses.ts +++ b/packages/llm/src/protocols/openai-responses.ts @@ -46,12 +46,8 @@ type OpenAIResponsesInputContent = Schema.Schema.Type - const OpenAIResponsesReasoningSummaryText = Schema.Struct({ type: Schema.tag("summary_text"), text: Schema.String, @@ -82,19 +78,7 @@ const OpenAIResponsesFunctionCallOutput = Schema.Union([ const OpenAIResponsesInputItem = Schema.Union([ Schema.Struct({ role: Schema.tag("system"), content: Schema.String }), Schema.Struct({ role: Schema.tag("user"), content: Schema.Array(OpenAIResponsesInputContent) }), - Schema.Struct({ - role: Schema.tag("assistant"), - content: Schema.String, - phase: optionalNull(OpenAIResponsesMessagePhase), - }), - Schema.Struct({ - type: Schema.tag("message"), - id: Schema.String, - status: Schema.Literals(["in_progress", "completed", "incomplete"]), - role: Schema.tag("assistant"), - content: Schema.Array(OpenAIResponsesOutputText), - phase: optionalNull(OpenAIResponsesMessagePhase), - }), + Schema.Struct({ role: Schema.tag("assistant"), content: Schema.Array(OpenAIResponsesOutputText) }), OpenAIResponsesReasoningItem, OpenAIResponsesItemReference, Schema.Struct({ @@ -210,9 +194,7 @@ const OpenAIResponsesStreamItem = Schema.Struct({ server_label: Schema.optional(Schema.String), output: Schema.optional(Schema.Unknown), error: Schema.optional(Schema.Unknown), - content: Schema.optional(Schema.Array(Schema.Unknown)), encrypted_content: optionalNull(Schema.String), - phase: optionalNull(OpenAIResponsesMessagePhase), }) type OpenAIResponsesStreamItem = Schema.Schema.Type @@ -230,9 +212,7 @@ const OpenAIResponsesErrorPayload = Schema.Struct({ const OpenAIResponsesEvent = Schema.Struct({ type: Schema.String, delta: Schema.optional(Schema.String), - text: Schema.optional(Schema.String), item_id: Schema.optional(Schema.String), - content_index: Schema.optional(Schema.Number), summary_index: Schema.optional(Schema.Number), item: Schema.optional(OpenAIResponsesStreamItem), response: Schema.optional( @@ -257,18 +237,10 @@ interface ParserState { readonly tools: ToolStream.State readonly hasFunctionCall: boolean readonly lifecycle: Lifecycle.State - readonly messageItems: Readonly> - readonly messageContentIDs: ReadonlySet - readonly nextMessageContentID: number readonly reasoningItems: Readonly> readonly store: boolean | undefined } -interface MessageStreamItem { - readonly providerMetadata?: ProviderMetadata - readonly content: Readonly> -} - type ReasoningSummaryStatus = "active" | "can-conclude" | "concluded" interface ReasoningStreamItem { @@ -326,26 +298,6 @@ const lowerReasoning = (part: ReasoningPart): OpenAIResponsesReasoningInput | un } } -const messagePhase = (part: TextPart): OpenAIResponsesMessagePhase | null | undefined => { - const phase = part.providerMetadata?.openai?.phase - return phase === "commentary" || phase === "final_answer" || phase === null ? phase : undefined -} - -const messageItemID = (part: TextPart) => { - const itemID = part.providerMetadata?.openai?.itemId - return typeof itemID === "string" && itemID.length > 0 ? itemID : undefined -} - -const messageStatus = (part: TextPart) => { - const status = part.providerMetadata?.openai?.status - return status === "in_progress" || status === "completed" || status === "incomplete" ? status : undefined -} - -const messageAnnotations = (part: TextPart) => { - const annotations = part.providerMetadata?.openai?.annotations - return Array.isArray(annotations) ? annotations : [] -} - const hostedToolItemID = (part: ToolResultPart) => { const openai = part.providerMetadata?.openai return ProviderShared.isRecord(openai) && typeof openai.itemId === "string" && openai.itemId.length > 0 @@ -416,49 +368,17 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ } if (message.role === "assistant") { - const inputStart = input.length const content: TextPart[] = [] - let phase: OpenAIResponsesMessagePhase | null | undefined - let itemID: string | undefined - let status: "in_progress" | "completed" | "incomplete" | undefined const reasoningItems: Record = {} const reasoningReferences = new Set() const hostedToolReferences = new Set() const flushText = () => { if (content.length === 0) return - input.push( - itemID - ? { - type: "message", - id: itemID, - status: status ?? "completed", - role: "assistant", - content: content.map((part) => ({ - type: "output_text", - text: part.text, - annotations: messageAnnotations(part), - })), - ...(phase !== undefined ? { phase } : {}), - } - : { - role: "assistant", - content: ProviderShared.joinText(content), - ...(phase !== undefined ? { phase } : {}), - }, - ) + input.push({ role: "assistant", content: content.map((part) => ({ type: "output_text", text: part.text })) }) content.splice(0, content.length) - phase = undefined - itemID = undefined - status = undefined } for (const part of message.content) { if (part.type === "text") { - const nextPhase = messagePhase(part) - const nextItemID = messageItemID(part) - if (content.length > 0 && (phase !== nextPhase || itemID !== nextItemID)) flushText() - phase = nextPhase - itemID = nextItemID - status = messageStatus(part) ?? status content.push(part) continue } @@ -509,20 +429,6 @@ const lowerMessages = Effect.fn("OpenAIResponses.lowerMessages")(function* (requ ]) } flushText() - if (store === false && Object.values(reasoningItems).some((item) => typeof item.encrypted_content !== "string")) - input.splice( - inputStart, - input.length - inputStart, - ...input.slice(inputStart).map((item) => - "type" in item && item.type === "message" - ? { - role: "assistant" as const, - content: ProviderShared.joinText(item.content), - ...(item.phase !== undefined ? { phase: item.phase } : {}), - } - : item, - ), - ) continue } @@ -706,134 +612,15 @@ const NO_EVENTS: StepResult["1"] = [] // the protocol's `terminal` predicate stay in sync. const TERMINAL_TYPES = new Set(["response.completed", "response.incomplete", "response.failed"]) -const messageMetadata = (item: OpenAIResponsesStreamItem, id: string, previous?: ProviderMetadata) => { - const openai = previous?.openai - const phase = item.phase !== undefined ? item.phase : openai?.phase - const status = - item.status === "in_progress" || item.status === "completed" || item.status === "incomplete" - ? item.status - : openai?.status - return openaiMetadata({ - itemId: id, - ...(phase === "commentary" || phase === "final_answer" || phase === null ? { phase } : {}), - ...(status === "in_progress" || status === "completed" || status === "incomplete" ? { status } : {}), - }) -} - -const messageContentMetadata = ( - providerMetadata: ProviderMetadata, - item: OpenAIResponsesStreamItem, - index: number, -): ProviderMetadata => { - const content = item.content?.[index] - if (!ProviderShared.isRecord(content) || content.type !== "output_text" || !Array.isArray(content.annotations)) - return providerMetadata - return openaiMetadata({ ...providerMetadata.openai, annotations: content.annotations }) -} - -const ensureMessageContent = (state: ParserState, event: OpenAIResponsesEvent) => { - const itemID = event.item_id ?? "text-0" - const index = event.content_index ?? 0 - const item = state.messageItems[itemID] ?? { content: {} } - const existing = item.content[index] - if (existing) return { state, itemID, index, item, content: existing } - const findID = (next: number): readonly [string, number] => { - const id = `openai-text-${next}` - return state.messageContentIDs.has(id) ? findID(next + 1) : [id, next + 1] - } - const [id, nextMessageContentID] = - index === 0 && !state.messageContentIDs.has(itemID) - ? ([itemID, state.nextMessageContentID] as const) - : findID(state.nextMessageContentID) - const content = { id, text: "" } - const nextItem = { ...item, content: { ...item.content, [index]: content } } - return { - state: { - ...state, - messageItems: { ...state.messageItems, [itemID]: nextItem }, - messageContentIDs: new Set([...state.messageContentIDs, id]), - nextMessageContentID, - }, - itemID, - index, - item: nextItem, - content, - } -} - -const updateMessageContent = ( - state: ParserState, - itemID: string, - index: number, - content: { readonly id: string; readonly text: string }, -): ParserState => ({ - ...state, - messageItems: { - ...state.messageItems, - [itemID]: { - ...state.messageItems[itemID], - content: { ...state.messageItems[itemID]?.content, [index]: content }, - }, - }, -}) - -const closeOtherMessageContent = (state: ParserState, events: LLMEvent[], item: MessageStreamItem, index: number) => - Object.entries(item.content).reduce( - (lifecycle, entry) => - Number(entry[0]) === index ? lifecycle : Lifecycle.textEnd(lifecycle, events, entry[1].id, item.providerMetadata), - state.lifecycle, - ) - -const appendOutputText = (state: ParserState, event: OpenAIResponsesEvent, text: string): StepResult => { - const ensured = ensureMessageContent(state, event) +const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { + if (!event.delta) return [state, NO_EVENTS] const events: LLMEvent[] = [] - const lifecycle = Lifecycle.textStart( - closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index), - events, - ensured.content.id, - ensured.item.providerMetadata, - ) return [ - { - ...updateMessageContent(ensured.state, ensured.itemID, ensured.index, { - ...ensured.content, - text: ensured.content.text + text, - }), - lifecycle: Lifecycle.textDelta(lifecycle, events, ensured.content.id, text), - }, + { ...state, lifecycle: Lifecycle.textDelta(state.lifecycle, events, event.item_id ?? "text-0", event.delta) }, events, ] } -const onOutputTextDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (!event.delta) return [state, NO_EVENTS] - return appendOutputText(state, event, event.delta) -} - -const onOutputTextDone = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { - if (event.text === undefined) return [state, NO_EVENTS] - const ensured = ensureMessageContent(state, event) - if (event.text === ensured.content.text) { - if (ensured.state.lifecycle.text.has(ensured.content.id)) return [ensured.state, NO_EVENTS] - const events: LLMEvent[] = [] - return [ - { - ...ensured.state, - lifecycle: Lifecycle.textStart( - closeOtherMessageContent(ensured.state, events, ensured.item, ensured.index), - events, - ensured.content.id, - ensured.item.providerMetadata, - ), - }, - events, - ] - } - if (event.text.startsWith(ensured.content.text)) - return appendOutputText(ensured.state, event, event.text.slice(ensured.content.text.length)) - return [ensured.state, NO_EVENTS] -} - const onReasoningDelta = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { if (!event.delta) return [state, NO_EVENTS] const events: LLMEvent[] = [] @@ -868,23 +655,6 @@ const reasoningMetadata = (item: OpenAIResponsesStreamItem & { id: string }) => // best-effort, not guaranteed. const onOutputItemAdded = (state: ParserState, event: OpenAIResponsesEvent): StepResult => { const item = event.item - if (item?.type === "message" && item.id) { - const existing = state.messageItems[item.id] - return [ - { - ...state, - messageItems: { - ...state.messageItems, - [item.id]: { - ...existing, - providerMetadata: messageMetadata(item, item.id, existing?.providerMetadata), - content: existing?.content ?? {}, - }, - }, - }, - NO_EVENTS, - ] - } if (item && isReasoningItem(item)) { const events: LLMEvent[] = [] return [ @@ -1042,32 +812,6 @@ const onOutputItemDone = Effect.fn("OpenAIResponses.onOutputItemDone")(function* const item = event.item if (!item) return [state, NO_EVENTS] satisfies StepResult - if (item.type === "message" && item.id) { - const events: LLMEvent[] = [] - const itemID = item.id - const messageItem = state.messageItems[itemID] - const { [itemID]: _finished, ...messageItems } = state.messageItems - const providerMetadata = messageMetadata(item, itemID, messageItem?.providerMetadata) - const lifecycle = Object.entries(messageItem?.content ?? {}).reduce( - (lifecycle, entry) => - Lifecycle.textEnd( - lifecycle, - events, - entry[1].id, - messageContentMetadata(providerMetadata, item, Number(entry[0])), - ), - state.lifecycle, - ) - return [ - { - ...state, - lifecycle, - messageItems, - }, - events, - ] satisfies StepResult - } - if (item.type === "function_call") { if (!item.id || !item.call_id || !item.name) return [state, NO_EVENTS] satisfies StepResult const tools = state.tools[item.id] @@ -1195,7 +939,6 @@ const step = (state: ParserState, event: OpenAIResponsesEvent) => { if (event.type === "response.reasoning_summary_part.done") return Effect.succeed(onReasoningSummaryPartDone(state, event)) if (event.type === "response.output_item.added") return Effect.succeed(onOutputItemAdded(state, event)) - if (event.type === "response.output_text.done") return Effect.succeed(onOutputTextDone(state, event)) if (event.type === "response.function_call_arguments.delta") return onFunctionCallArgumentsDelta(state, event) if (event.type === "response.output_item.done") return onOutputItemDone(state, event) if (event.type === "response.completed" || event.type === "response.incomplete") @@ -1225,9 +968,6 @@ export const protocol = Protocol.make({ hasFunctionCall: false, tools: ToolStream.empty(), lifecycle: Lifecycle.initial(), - messageItems: {}, - messageContentIDs: new Set(), - nextMessageContentID: 0, reasoningItems: {}, store: OpenAIOptions.store(request), }), diff --git a/packages/llm/src/protocols/utils/lifecycle.ts b/packages/llm/src/protocols/utils/lifecycle.ts index 8add02ce6775..eb6c95dfbdab 100644 --- a/packages/llm/src/protocols/utils/lifecycle.ts +++ b/packages/llm/src/protocols/utils/lifecycle.ts @@ -14,19 +14,16 @@ export const stepStart = (state: State, events: LLMEvent[]): State => { return { ...state, stepStarted: true } } -export const textStart = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { - if (state.text.has(id)) return state +export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { const stepped = stepStart(state, events) - events.push(LLMEvent.textStart({ id, ...(providerMetadata ? { providerMetadata } : {}) })) + if (stepped.text.has(id)) { + events.push(LLMEvent.textDelta({ id, text })) + return stepped + } + events.push(LLMEvent.textStart({ id }), LLMEvent.textDelta({ id, text })) return { ...stepped, text: new Set([...stepped.text, id]) } } -export const textDelta = (state: State, events: LLMEvent[], id: string, text: string): State => { - const started = textStart(state, events, id) - events.push(LLMEvent.textDelta({ id, text })) - return started -} - export const reasoningStart = ( state: State, events: LLMEvent[], @@ -68,7 +65,7 @@ export const reasoningEnd = ( export const textEnd = (state: State, events: LLMEvent[], id: string, providerMetadata?: ProviderMetadata): State => { if (!state.text.has(id)) return state const stepped = stepStart(state, events) - events.push(LLMEvent.textEnd({ id, ...(providerMetadata ? { providerMetadata } : {}) })) + events.push(LLMEvent.textEnd({ id, providerMetadata })) const text = new Set(stepped.text) text.delete(id) return { ...stepped, text } diff --git a/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json b/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json deleted file mode 100644 index c7abea9faf2d..000000000000 --- a/packages/llm/test/fixtures/recordings/openai-responses-phase/round-trips-commentary-into-a-final-answer.json +++ /dev/null @@ -1,46 +0,0 @@ -{ - "version": 1, - "metadata": { - "name": "openai-responses-phase/round-trips-commentary-into-a-final-answer", - "recordedAt": "2026-07-23T18:55:21.217Z", - "tags": ["prefix:openai-responses-phase", "provider:openai", "protocol:openai-responses", "phase", "tool"] - }, - "interactions": [ - { - "transport": "http", - "request": { - "method": "POST", - "url": "https://api.openai.com/v1/responses", - "headers": { - "content-type": "application/json" - }, - "body": "{\"model\":\"gpt-5.6-sol\",\"input\":[{\"role\":\"system\",\"content\":\"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":100,\"stream\":true}" - }, - "response": { - "status": 200, - "headers": { - "content-type": "text/event-stream; charset=utf-8" - }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_09a288a54615e317016a6263962f6081908c42f100432d7432\",\"object\":\"response\",\"created_at\":1784832918,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_09a288a54615e317016a6263962f6081908c42f100432d7432\",\"object\":\"response\",\"created_at\":1784832918,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"commentary\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"I\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"TrlggQ3HsX40yjX\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"’ll\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"OT4APBDnZPskz\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" check\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"y6OCrBSFwy\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" the\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"VgUHMFuGRsCe\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" current\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"NfRf0E2g\",\"output_index\":0,\"sequence_number\":8}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" weather\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"FDKK7SxE\",\"output_index\":0,\"sequence_number\":9}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" in\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"VpCRtSlIHUKRx\",\"output_index\":0,\"sequence_number\":10}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" Paris\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"7ksv7FLpVR\",\"output_index\":0,\"sequence_number\":11}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"obfuscation\":\"lo7fPMYb2XF2iIz\",\"output_index\":0,\"sequence_number\":12}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":13,\"text\":\"I’ll check the current weather in Paris.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"I’ll check the current weather in Paris.\"},\"sequence_number\":14}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"I’ll check the current weather in Paris.\"}],\"phase\":\"commentary\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":15}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"type\":\"function_call\",\"status\":\"in_progress\",\"arguments\":\"\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":16}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"{\\\"\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"j3afFo6Txi12z5\",\"output_index\":1,\"sequence_number\":17}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"city\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"k5BeAAvHaqNo\",\"output_index\":1,\"sequence_number\":18}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\":\\\"\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"p7WgYInwBJjJs\",\"output_index\":1,\"sequence_number\":19}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"Paris\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"Gm7YVJP56OJ\",\"output_index\":1,\"sequence_number\":20}\n\nevent: response.function_call_arguments.delta\ndata: {\"type\":\"response.function_call_arguments.delta\",\"delta\":\"\\\"}\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"obfuscation\":\"DP3YnNaUFF1AFQ\",\"output_index\":1,\"sequence_number\":21}\n\nevent: response.function_call_arguments.done\ndata: {\"type\":\"response.function_call_arguments.done\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"item_id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"output_index\":1,\"sequence_number\":22}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\"},\"output_index\":1,\"sequence_number\":23}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_09a288a54615e317016a6263962f6081908c42f100432d7432\",\"object\":\"response\",\"created_at\":1784832918,\"status\":\"completed\",\"background\":false,\"completed_at\":1784832920,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[{\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"I’ll check the current weather in Paris.\"}],\"phase\":\"commentary\",\"role\":\"assistant\"},{\"id\":\"fc_09a288a54615e317016a62639805948190a1aad87870887193\",\"type\":\"function_call\",\"status\":\"completed\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":86,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":34,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":120},\"user\":null,\"metadata\":{}},\"sequence_number\":24}\n\n" - } - }, - { - "transport": "http", - "request": { - "method": "POST", - "url": "https://api.openai.com/v1/responses", - "headers": { - "content-type": "application/json" - }, - "body": "{\"model\":\"gpt-5.6-sol\",\"input\":[{\"role\":\"system\",\"content\":\"Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"What is the weather in Paris?\"}]},{\"type\":\"message\",\"id\":\"msg_09a288a54615e317016a626397d4d48190b8e2dac34ada4601\",\"status\":\"completed\",\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"I’ll check the current weather in Paris.\",\"annotations\":[]}],\"phase\":\"commentary\"},{\"type\":\"function_call\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"name\":\"get_weather\",\"arguments\":\"{\\\"city\\\":\\\"Paris\\\"}\"},{\"type\":\"function_call_output\",\"call_id\":\"call_sp8Ji4cqPjnpRbTQ8Kss4epd\",\"output\":\"{\\\"temperature\\\":22,\\\"condition\\\":\\\"sunny\\\"}\"}],\"tools\":[{\"type\":\"function\",\"name\":\"get_weather\",\"description\":\"Get current weather for a city.\",\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"medium\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":100,\"stream\":true}" - }, - "response": { - "status": 200, - "headers": { - "content-type": "text/event-stream; charset=utf-8" - }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_09a288a54615e317016a626398499481908cda8e1ed0ef45bd\",\"object\":\"response\",\"created_at\":1784832920,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_09a288a54615e317016a626398499481908cda8e1ed0ef45bd\",\"object\":\"response\",\"created_at\":1784832920,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Paris\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"SQK4VKpPg6o\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" is\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"1kFOJRFyao7zE\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\" sunny\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"n4cE1OQJnX\",\"output_index\":0,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"obfuscation\":\"wd9OLc2GBwngpGN\",\"output_index\":0,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":8,\"text\":\"Paris is sunny.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_09a288a54615e317016a626398499481908cda8e1ed0ef45bd\",\"object\":\"response\",\"created_at\":1784832920,\"status\":\"completed\",\"background\":false,\"completed_at\":1784832921,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":100,\"max_tool_calls\":null,\"model\":\"gpt-5.6-sol\",\"moderation\":null,\"output\":[{\"id\":\"msg_09a288a54615e317016a626398f9f88190ba4b9ecc8b29fd03\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Paris is sunny.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"all_turns\",\"effort\":\"medium\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[{\"type\":\"function\",\"description\":\"Get current weather for a city.\",\"name\":\"get_weather\",\"output_schema\":null,\"parameters\":{\"type\":\"object\",\"properties\":{\"city\":{\"type\":\"string\"}},\"required\":[\"city\"],\"additionalProperties\":false},\"strict\":false}],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":140,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":8,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":148},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" - } - } - ] -} diff --git a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json index cb028796f318..47670c81272d 100644 --- a/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json +++ b/packages/llm/test/fixtures/recordings/openai-responses/openai-responses-gpt-5-5-reasoning-continuation.json @@ -2,7 +2,7 @@ "version": 1, "metadata": { "name": "openai-responses/openai-responses-gpt-5-5-reasoning-continuation", - "recordedAt": "2026-07-23T18:57:01.137Z", + "recordedAt": "2026-05-23T23:19:06.776Z", "provider": "openai", "route": "openai-responses", "transport": "http", @@ -33,7 +33,7 @@ "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_05f4a9bec03f8148016a6263faf3bc81959b717b99f194a37c\",\"object\":\"response\",\"created_at\":1784833018,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_05f4a9bec03f8148016a6263faf3bc81959b717b99f194a37c\",\"object\":\"response\",\"created_at\":1784833018,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_05f4a9bec03f8148016a6263fb83748195b13a0e6b77c0dd88\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqYmP7gx69qnBLNDRIOVvsxN78GS99z9MYWR_FTPUwnVyq8J470WTXQWQEqp9PF8tLyswAhxg2PkxL7ijhN9190IV6QR1waQUzNd6NLYDc5-GG8mJ0LdKYPABZqFYRWV6MEJhDW2CrR5XiGErIzIlMCWEBkE79DDmLOdyOEVMTtetK_CUuxbBGnQ2_8_16FP8AXCqL6xKBCzFxTsQDa9oKTuMoS7jczBlC71fWBiw_cEfILOIUe7_5K4ze7MJG079Ty580gZCAQ0vteMnKpPKSfugMhKlVB_9Wn3wofeL-Xf8s1QojpIAUgHE_fh9fMNGeMkEsUFKhz_vOQrU7FH3NpGTYs0qKQVJaX9FHrR5EooMMimg2TFqc3GZkHJt9VJe1dhfNzcnybIdyz5s0Y6l-1FSD90_8mHR3ZUdfgOBYrsDfVvXpCUdyn4lQGJMkJYgBqAZpXZHvRw16t9_6stCvsScdb5nGrat54qbSYx1mfXD9S2RPw57vzcnClSpFMzen9oMzPWnBxjQujMzjauz_Ps4UJ_H59XUcvuL8fzWv00qhVvbL59NCMpQFdY0b4TtDuIKT0tgEHRKiHMN4zl3zoVJ9bp8qsxVTBBkIF3Q-Wsf4655dkD3yV0H66Yp3XAO5hvRO-0y8FMMpWcc0N5liLOMKiCKj9XMeGi2p5nF66vkPs3fm880W3S2fyOVsxbTWF_TVDJ1SDaHn_xPzzYQCg047sGOQ9S6jvAfblBqIO9iOV3zOF-Gu5Zv9gbJEywTIPSx_u8sKNAqvlxpZ6zJLOyjipByUvFb27MYJLdPw9fIV9w5D2XVwowB8Bg13joWKgm5ne75-VudmIlm-AoWC78zT2_WIKpIN0jYhJDOgxIgpFeI4LEGKh6BlVSQ0-CRrOGw1FnsxKfGN0FL9ChLs-rz4yA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_05f4a9bec03f8148016a6263fb83748195b13a0e6b77c0dd88\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqYmP8aJHmIOttZSqCfZrRqPkxHEGRKL8agRHsomAtpHOM91zNm-YwW-Tupi5CFHy3SFlIatT1_VVZLSR50Mf1jZezXHUkIdB003BGPUwaeHlhlq3B63dukCmAGJso8xJvDw0YqYppy2FXNIoPoys3wI4n8LSTiUOm6yXEyUkaBt4sXLeWsg_NLqYo-ilmv60ppKV1vPQNZkHAX2N0PCZebSmzto5a896xV7jkKVsEMhanPnKP8N3G_ag_-B4ZPr01IAma4xJ2jXysRX_P-zN7AqGDRxBuuoIzylLizo68wNH9cXmnvQcCEkjKxOTpmQGCl0LxSnK_UnaLwErtj9FUMXDaDqbfugCZrCy5pUq0RgBSoAnZvOqH6pWpXPvclQI9Wsg-Ra3bv8sYtf2qYBMebaNIbI_BVVKN8Lrpj7UWODiRZImgfUWHuflaf98H1oD09f7e55gg7GlQ0r6EVZwbuX3GaEwsHbDe7KdsPmwV_RV_ZQpxm14aBypGtapXEAd8SMJxygZtuQKCTJuOwsgZug_I1cGw4EF5AIg5EWzVhvJmlzPrBqas-VdL0_VIfohJAm9181eYKX5ITeUZkdrNpwk7yMCLG4yHCOiP_x33Gyz3xOOUzNSVMy56F7f_K1Bd-eqHK4zMsYrS4-c2cbC5HPPNUUFh8-CaMaJ-34P2TvTHrxR9QY6PXMyfiEMhNC8Mpblhh3ShCvNWTAsrujWHmtDRp47cSNsiNxTSRMU75OsDviGcnuNCZDHBA-92N9g-nPqBvqJfFJXsHbpNzLRCgkv3O8ZOvMPOLLM4OZu1fELgAdKqWNupZgys6n1pUVsRGctECeVZ4NOafuvHvm6wUHRXuLLUN6ATJJ4RdSD_DAtnGD1QFstXwEeBHgBwMMppaIgBvZGbz5skrDm5NBv7AEdCl6UzZ-0zWP6AFxowTzTNuHUyIO-Buwvq_hmLn2hemhr0wOBBgTqFcW51cnybHt7sQD6AhGYlLjNnGu5XH7qcdTY=\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"logprobs\":[],\"obfuscation\":\"l9gChAJHAvW\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"logprobs\":[],\"obfuscation\":\"MgDcWbMrxjIR4h6\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_05f4a9bec03f8148016a6263faf3bc81959b717b99f194a37c\",\"object\":\"response\",\"created_at\":1784833018,\"status\":\"completed\",\"background\":false,\"completed_at\":1784833020,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_05f4a9bec03f8148016a6263fb83748195b13a0e6b77c0dd88\",\"type\":\"reasoning\",\"content\":[],\"encrypted_content\":\"gAAAAABqYmP8gH5Qc4oBXBDbJyFBgvZVodKHctAOFeZp3khLa37IbJljUkbx7ERw0pgedvhUBaQsRKB31czAty8Ruv9PJVbyoSUpVamjmVM0-FD9lHpwNDnLPjhjPzcAxQosXeVGuWp6jxwmKjzIowwLFp7ImRG2syV-2-QqZ6Wef4VuPzmQ6RVVQUeqBz5Pvhf0FKkqzgb9T3vv0bV-V8qFsdbNKGtQvKKciR5fL6YaUlmY0gsru8UDabWjNjL8oyjZI0HezNcvYNLfmmzptyYpfw208LFiTeD8ntf6xClGgbBztiMQDQhOjQoeaZrrOCaB3iqxbe-_x8xAMRdUpS9NmhJ8oHH1w4JaPQ-Gjvzrrjq0kqpVYXHmdZ-cmwPpRG_0mh4yHkIUYXb0cvoaMyKWLXGcVf3OWLmkEZCa0GuOvwKz8QaPoOAer0L-GrV4p1GadjEUG07EvwsSRyG3fPckMJsSLxW70NNipaGmJys-ihR5YU5qqz74EnpJpYFJnh7Anl5ETl92-ZA4tqAevrW2dwoCNIfGkuX7GMrpPLxTs5sZ1UQWppPyfji-f0YY7198_Ypf8XZfWhB46HYipjSwqErvUyHpE6UpkqWQwwX5wa29clrUrjxTnn1qiw0G2PgvKziwBpImhfWDRGafirQ9G4lh1I8GS5K7vDPVtOPnzxFccYFX4oB5NgSdADMtm5cFKgAqVNM4l04E2FMYJvPr5qn79EWf9AwfOFIuwwNE_sWSkN8VU1-iob0_03iMJ_U2lbT9BT65aJhhSmFUfaqyliC9rUwnFXYEgoFC7jAZ6_LhNJ4ONM26KPUhGu0Uv5xq3aAyk_EKQ_quvYF7euqijnIAzjk8rsV7w7_Qj0Ujnoz7M7QSJx5VaGUCXR8O_pKBcBVLayBe957WZ154qfTYIK96FyOcqM5_RGHlvvfVRi3pnbrQR_odU1e2pAL5hdCyvKtZ3GtN0kaGU1wJOersW5QCUyTIJ9H9ax-YJn3_4Xe4PKKEr5Q=\",\"summary\":[]},{\"id\":\"msg_05f4a9bec03f8148016a6263fc25a08195b4a994aeeed62684\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":21,\"output_tokens_details\":{\"reasoning_tokens\":13},\"total_tokens\":52},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXnglldg7hhpTBATVqj7sThK5ATieOVR8sZGYPDW2zYopwpKxA3RyRccK_FPjRvvlzrvL-FitOxmdMGBaKa5jncrT9hHo5IMhsFsCEHkQ1x5tlrKPqtfwJ_LFexR0h_IpPogu8wlVAkHRoWQoq61o9vBxjMOEsq6dtXu09959gXnAvJA3jN_mqNkRZ7Yp6LaJJtLDAAtt_dhX8veoEFXZ412lCY4zcaMvC5o0yq6MPvLIN4NhHmfPKkVAy-j8wGlgA42KR4wd5-VeFXUdeSn32dlNLZZxBFa9w6iTgCQ9aF-3C7RB4OXeSY782QUD1dRyFybd7vJtjlptwXBntSHZ9wugoKSDEj0KnvQKG_WiCWuJvkGiOVno4MAs5QnCmKBnpak5OV1wOhPwX2ez6OmAYT4mMKIogdfivVvUxMrmdVJzgE85WoZEAU2ZporxVXkI7_8p0L6dxxwk_IKiKSCz-bZgsCtOP5Jsr5GeI831nVv272kZ3DugV-hcjGHAE5T9KhebzpFjsdxnJcfxuGY8SyRaLlUAHM_37H4veHsOzyhCoaG8mMaT3gIb4tAvM7ezd1xzLsFae89P5xCv_fNeoV7qmf2IWDWUi1vitIib5w9jsclWRqYaLVZR0GK6dYyNJ1DXDOOcWRdH7UJakv1m2koUbcYWBuxao7sc-af_9ySKAloWhb6QjiVElJHYtwraJBtX-CLBVHEYqAXmZgMUWVbz8NNRA6JS1TrOys7_LiQtXXubLWas_66LyaqmB-628LCUitUISYYc2wmq1uUm7gjPA53Wm4F7VU6g-PO7bt1O0Nd-jasisPXINTX3Z4hgC1APPEq29iEHwmEPnicO_Nu6U3JLfq4DD6r1oLK-RnIp3Ratw0P-Gwog86RBLGUWIEIKdFu6m9d1TI8rIBbAVaBA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Hello\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"3nRhhCWA1H8\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"!\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"obfuscation\":\"60NqChSEyXHKsoy\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Hello!\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e74e148195beb46e1925d20292\",\"object\":\"response\",\"created_at\":1779578343,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578344,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":120,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235e7ce3881958a5eca32a36a14c5\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXoMO9Ci_Q06HKQ0YDBarUvbp9ulkR9W2RXWPbx7XKokNCrKUZX-pPPGpUg6r-vTXe8iEX-oED6TmjxZV_nyo838x6pmQJlDqz5JECs2axIrUbCjv9xBt3ob8eAyOizhKFjp3dJNu4i01c38MPZ5QYpD24uCKf69jzjUfydKIEjbo0VhP3K6SDG0V9ZUtua-e6WMqzIg-W5Zs3u64DxGw974ntmvNsx8lsuLR-bk9S5ZZ7zPlCG2Emwfph8UE5HJmIfmMxYlrY5qmXSWKDhse9hovQj-TrvbllP-0vLNQWEPLc3aUfVrWWR9i3NZZ-nxJZiIJPCF3xxIIyKaLh9a6Lh9J6Z-brsvVfbVJWXIGZhsu-uKk6Gwoqo56KqHdNaPF7lkPo5GAWfMrweCnJZ4o_j-oWm8BwTkXxrLib4XYKDO2JNqrNdbmy8rZ7UGgW_DVTiNyZi6LoRfSuvK45MWV2uzB_OJ9LBcqgscY4HyPvKrhGG4Peh4iXuBUCyQQ2IudM5GbeeMOAF3dnEzZff68SwE1H56CO6PtKhVQ6cFJMf7LwI5LFFio0qJnEDx-MejvU7PxmYW7R3MEbgjbsuEFU5KnRVYsgug3_Bq1vXdmP2qhebufFZwz26SwaFqyn3xjwCP8-GR7lWCZ2EvUvWtfxJ5_zgkZg06UsF4Eo_CWKFdp0ao43nemNJxOlMzFa6tPuCgplmD0oYoQ316f-bWK02-eJk56S7G4bZSk8cQfExfIZMjW2f-qrxvfxEpFiXsZF80BQwgRUOeKjsqidg2ihdldRkXGn3vX8p15mf1UgstU8y3DNd2_qJe1f_pEl6rWNXoxFdSRCTG7wTAqbCCmuDgCKGhNQY9tfJNsFqgWBIGkqKy88DN_HiWywJjJ-5u9aoe68yDK-E0TMDqs7ZrTely1wvmkl2yF0XQttaB30taxkIcRR-n0PRO-CRNA_9nJkw9ZsBb8oBjyqWH_mwSijT5g==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235e8d64c81959a41f8db3ea7b66c\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Hello!\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":31,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":51},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" } }, { @@ -44,14 +44,14 @@ "headers": { "content-type": "application/json" }, - "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqYmP8aJHmIOttZSqCfZrRqPkxHEGRKL8agRHsomAtpHOM91zNm-YwW-Tupi5CFHy3SFlIatT1_VVZLSR50Mf1jZezXHUkIdB003BGPUwaeHlhlq3B63dukCmAGJso8xJvDw0YqYppy2FXNIoPoys3wI4n8LSTiUOm6yXEyUkaBt4sXLeWsg_NLqYo-ilmv60ppKV1vPQNZkHAX2N0PCZebSmzto5a896xV7jkKVsEMhanPnKP8N3G_ag_-B4ZPr01IAma4xJ2jXysRX_P-zN7AqGDRxBuuoIzylLizo68wNH9cXmnvQcCEkjKxOTpmQGCl0LxSnK_UnaLwErtj9FUMXDaDqbfugCZrCy5pUq0RgBSoAnZvOqH6pWpXPvclQI9Wsg-Ra3bv8sYtf2qYBMebaNIbI_BVVKN8Lrpj7UWODiRZImgfUWHuflaf98H1oD09f7e55gg7GlQ0r6EVZwbuX3GaEwsHbDe7KdsPmwV_RV_ZQpxm14aBypGtapXEAd8SMJxygZtuQKCTJuOwsgZug_I1cGw4EF5AIg5EWzVhvJmlzPrBqas-VdL0_VIfohJAm9181eYKX5ITeUZkdrNpwk7yMCLG4yHCOiP_x33Gyz3xOOUzNSVMy56F7f_K1Bd-eqHK4zMsYrS4-c2cbC5HPPNUUFh8-CaMaJ-34P2TvTHrxR9QY6PXMyfiEMhNC8Mpblhh3ShCvNWTAsrujWHmtDRp47cSNsiNxTSRMU75OsDviGcnuNCZDHBA-92N9g-nPqBvqJfFJXsHbpNzLRCgkv3O8ZOvMPOLLM4OZu1fELgAdKqWNupZgys6n1pUVsRGctECeVZ4NOafuvHvm6wUHRXuLLUN6ATJJ4RdSD_DAtnGD1QFstXwEeBHgBwMMppaIgBvZGbz5skrDm5NBv7AEdCl6UzZ-0zWP6AFxowTzTNuHUyIO-Buwvq_hmLn2hemhr0wOBBgTqFcW51cnybHt7sQD6AhGYlLjNnGu5XH7qcdTY=\"},{\"role\":\"assistant\",\"content\":\"Hello!\"},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" + "body": "{\"model\":\"gpt-5.5\",\"input\":[{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Think briefly, then reply exactly with: Hello!\"}]},{\"type\":\"reasoning\",\"summary\":[],\"encrypted_content\":\"gAAAAABqEjXoGMCw3WDXpoD9151PEr2Lt8raW7KBKefQhZJGWx5f8jy152bApO6oE-Mr1BhUtfZNq3OPBVfSL4ioQ9bHREfujIBXgk9LUDBAz2Sle7KjOr9HaUV16A4HBiaFIRFjsHPS9G8yEySp1m6F1CD_WR6apyUGgugRh_y39EcOJmxPOzmiac5DVM6fraA1VpcGbqrZ1x2ANHFDOfnYTycPtPNTgzE7LjkYjDDWbT03uN1YxfP4pqjDVRzY14pA8bSZ8ys-pDv5kUFCAsw-OlU4jYKUXp-M8_6KTaRQP71LPwppt__zG_NJPfy-qUil4pOU8_NoxtxerHgLLXbfExZdzfpoGinoEjn7nj7BJDEtl-LNeNEb5c-1ZymNfVMp-Cs3fLEPkAV8rtHFtZ0MhE_07GKbGo7hTrOmkM4DydxmHsdWGNbXAG35cprslEA5P7p3GHFKnRs5hGs2eq-XcZ3yki64ZBOU_Tv6UR7nUH09gF1rdrJo3dpre6M00COwwdZ02zUP5KxCuI8FKu2jsZu9zgMVXDALsdtM5orTCVLXsn4rddWd111zE-vMjNmMMmktW2cHMjH7j1ooA-9P083koNVYiLi4UhMA64gTqgyl8MxkZekl7eFSMa7qk295NaHOKtFxzYYcZ9jdioCwSPSZ0ZZWLoNgrK7SWfRh0uaTHNcMZ3wq8ae6CguktIeVTCPTQAqJLQqd7AU0oOCKCJ7BWnC-L8UC6m7Pm9ZS958uUVeWBhgKHzMAGq9UeQB7IEeAcbMn3EDgOSfd8qCb8iwU9iG9dcu9axQwWU7pd7kd-T-He61W7z5wWgpx1KehWCxrN6kuKSo6p-uUfwVnJukreOn8BJNAzADQgz68bhmN9VGih7YcKVnLgwDwKditrjSd6-tfE0Baarj3jWENvT6ohY17R9FDrKS-2v8IIX6tGjoKJw8SRhaWLNv4vWlmxRgR0gdac3qumd0GKqsWSveNz01naA==\"},{\"role\":\"assistant\",\"content\":[{\"type\":\"output_text\",\"text\":\"Hello!\"}]},{\"role\":\"user\",\"content\":[{\"type\":\"input_text\",\"text\":\"Now reply exactly with: Done.\"}]}],\"store\":false,\"include\":[\"reasoning.encrypted_content\"],\"reasoning\":{\"effort\":\"low\",\"summary\":\"auto\"},\"text\":{\"verbosity\":\"low\"},\"max_output_tokens\":40,\"stream\":true}" }, "response": { "status": 200, "headers": { "content-type": "text/event-stream; charset=utf-8" }, - "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_06418d4b810c5ed5016a6263fc610081909e978e5ca1a9edd0\",\"object\":\"response\",\"created_at\":1784833020,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_06418d4b810c5ed5016a6263fc610081909e978e5ca1a9edd0\",\"object\":\"response\",\"created_at\":1784833020,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":3}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"logprobs\":[],\"obfuscation\":\"M3ODGG2egQCH\",\"output_index\":0,\"sequence_number\":4}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"logprobs\":[],\"obfuscation\":\"fm15a3vpfYAW4br\",\"output_index\":0,\"sequence_number\":5}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"logprobs\":[],\"output_index\":0,\"sequence_number\":6,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"output_index\":0,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":7}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":0,\"sequence_number\":8}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_06418d4b810c5ed5016a6263fc610081909e978e5ca1a9edd0\",\"object\":\"response\",\"created_at\":1784833020,\"status\":\"completed\",\"background\":false,\"completed_at\":1784833021,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"msg_06418d4b810c5ed5016a6263fd13888190829edf0774b5b6e1\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"mode\":\"standard\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tool_usage\":{\"image_gen\":{\"input_tokens\":0,\"input_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"output_tokens\":0,\"output_tokens_details\":{\"image_tokens\":0,\"text_tokens\":0},\"total_tokens\":0},\"web_search\":{\"num_requests\":0}},\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cache_write_tokens\":0,\"cached_tokens\":0},\"output_tokens\":6,\"output_tokens_details\":{\"reasoning_tokens\":0},\"total_tokens\":41},\"user\":null,\"metadata\":{}},\"sequence_number\":9}\n\n" + "body": "event: response.created\ndata: {\"type\":\"response.created\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":0}\n\nevent: response.in_progress\ndata: {\"type\":\"response.in_progress\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"in_progress\",\"background\":false,\"completed_at\":null,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"auto\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":null,\"user\":null,\"metadata\":{}},\"sequence_number\":1}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqB-kOX_0QAeoEksgNjwSbtGmVEQuMj5ODcFV6b7Kp3E8RoHRRmSXtRH0rtNbZRbhKz5jM48DUpDI1WTeO2HqCd_A3fsSgFxp5ACGVFjPWjfvP2JMdDkpoOo5gu2zy7WsWY0fseocQQ5q_jfG6SWw0fyaeeqfdQ9HkcHyg6gVEl5skb4L8_2lD5nClmLlNVVh5JCuXRH9eYysrfO19NOZ29A2MVUX-XgB6mmK5uSb1jE43GhrEPPYrMbB5JyzM6B-yeB8rE4H2wx530hQqtxwSZREa8G03rzTJ49_KAPWl0djGDDtufUX-t4EpBHo6loA3PMuiZ3VsJTkkPpEqkm6QQyAVkQ_8AdRu12CqHbFdu73I-BnArzr33yW6reNUjnZjFV5bWDyxIMh6ljy3O_2nGk-qdTLt6bGJbEjTdPj1hi7icYZTVPqofPU4pjlo9BnIBheo-4u9pA26V9G9vDAtM4myDdMEe4pnieUztBUYPUOVMaG2U9gqtNs6iPehKo3BeKy4lhYPorL2OPmf1lVUQOCW1MBbwT5xt1kjOVw7LggnyjrBsXVvDBWg0AFcvm14r3ZQezPgLetQfSx56mVEJpui9BuVSUg2Xvqb5tCCip6TipUVvzZJKKkxN43o8N6UVXLIn6wgstAn9727JgBEsjMxzvuOWaaI-qM3dWMcFzFSGvKb6gTiF37AhSOzosf31hnsGx8AnGmLmbuW3IMhZXZZMHgVUHx4p8pNRPeoCE-Bv833KZbRfVxe6tbmkLadBjXYaCQqPXDHBR7qbpfu4_M9UAy0kpic-joepxQKsCT2t6vsNaThRYaN3PtIgjs5xxAfa9yKeIYak8yiP96CUlME9Y7zaOIydPWYBhWLf3phQsWMax9eKdLDh19f0Y0iAJPpk--1xd2OVTuDxKIMpA==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":2}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXqW5MInCBPKulZ1lizyFtOaKUpKgHldAXVjTTs4XFE45gtxC1NbJoOi2tHoQhpfq-JGxtjSQEDTHnMCiLLhyqvQ4GlWVF4n51xcFVC_WgymkZqDxG6xPw8ITAsRI5vb8HiPO6EmmKt6xGIVXOjrrxRNAY3xtrByeYSvnCa6FDUHEkMeXmwllBalCeQPNDPl0Ub2ehuchNG0loMVLJoOjT-2KgDrXlOa6rCn3nUf4U1W5JA_kHytlgrD0IPbs7nY8wemdynJRXBoNSOT_U3nQSB6j-i4KIJAdLiUs9LVWMYleqmFQNs8S4dC3i5DfpHXWUMZ5Ai1d3gbvMP8bH7fsUyfIhyiDUvlgr6PZ9rfh8JqkjOpiQ7NFtSDuHQGdx__W3qi23WPDp3iQjKxVl1oUXfbMzsPE4bmNN9dnJ9qTQ43kvw8GyrGrSqRS8jCKuk9bxqeR_ibj4KoDdxvVbUeGMg3WKANfCRsNXlxwYtMpu3I4HxKm5EuMNKDg_e9RFH2wFEDm9wCKMZrC_5LShgKhSfhsk3yJ47Mit0zYdX27kyHGlZvpzLPYKdtHW1O15KNT4gFKBrIguCtjXz2Lb42ENM6Jo8BTY7BZbf0hXZ4A5mFNl_gyLVHWEHpR1GcYiJbs9RtQ-7qX_PTeZ11iFY3a7_jM7TK3WEN2IuG0OKbZVHvOkVcvyBEgIbzSzCzhtC-j584knI4WmYiqnltuwRcR2N3sxYY3vMcYGA2_AU5kYlZcJcztapTTW-aKbyGxPcw5D_dqb5mGpDqJgquye-qOufDt4Fd7cSc-g8awqR8QPsLz-ZDPLMB9JKQ3VqLQlNCKDUoDodGOAL3h-7EQG66osALfhpdsWcNmuVqlb0lNAXklrsZJtRKBU4pJ1UGCyVDwde7nv6I9PW19VumaRrJhc2cC52qUoyihvUo8xJsElaFp7-EHn5ymS4znZhRfyA_4UDL4rwj-3DqbMwNeDJrgBc3w==\",\"summary\":[]},\"output_index\":0,\"sequence_number\":3}\n\nevent: response.output_item.added\ndata: {\"type\":\"response.output_item.added\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"in_progress\",\"content\":[],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":4}\n\nevent: response.content_part.added\ndata: {\"type\":\"response.content_part.added\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"\"},\"sequence_number\":5}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\"Done\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"7tyE7hMvNOTM\",\"output_index\":1,\"sequence_number\":6}\n\nevent: response.output_text.delta\ndata: {\"type\":\"response.output_text.delta\",\"content_index\":0,\"delta\":\".\",\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"obfuscation\":\"RGXvuTTSJS3AT5E\",\"output_index\":1,\"sequence_number\":7}\n\nevent: response.output_text.done\ndata: {\"type\":\"response.output_text.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"logprobs\":[],\"output_index\":1,\"sequence_number\":8,\"text\":\"Done.\"}\n\nevent: response.content_part.done\ndata: {\"type\":\"response.content_part.done\",\"content_index\":0,\"item_id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"output_index\":1,\"part\":{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"},\"sequence_number\":9}\n\nevent: response.output_item.done\ndata: {\"type\":\"response.output_item.done\",\"item\":{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"},\"output_index\":1,\"sequence_number\":10}\n\nevent: response.completed\ndata: {\"type\":\"response.completed\",\"response\":{\"id\":\"resp_0a0794dab3b8ec7d016a1235e991c88195a4d2f9766babd985\",\"object\":\"response\",\"created_at\":1779578345,\"status\":\"completed\",\"background\":false,\"completed_at\":1779578346,\"error\":null,\"frequency_penalty\":0.0,\"incomplete_details\":null,\"instructions\":null,\"max_output_tokens\":40,\"max_tool_calls\":null,\"model\":\"gpt-5.5-2026-04-23\",\"moderation\":null,\"output\":[{\"id\":\"rs_0a0794dab3b8ec7d016a1235ea4dd88195a32179255ed6c532\",\"type\":\"reasoning\",\"encrypted_content\":\"gAAAAABqEjXq8oliF2VeqiOUi-jUdi49emjffD6wtbmxlwQWbJ6tSxXIjyXvCeclOqKx83G83GyDOJqvR4L_D8V_ebJtgG87ahWB8Rr9LEQDoLT24n4Vz279xtHMxEGgv7f0NmaXu2dGFeFY_s2RhH-DqNE7V4nEkS7odJOTkhxTKgEcxtz3dDlEnGU7IgN2sD1lh9y90BD3ysvARegy4Cs0DhUjLOvkx11G9lk5dQ3yo1ek8JhTHpnVSYrLDYIudCh6pfu1yP1tx8xbxDHUcwlNclU9Hp_9ils5FhZNWC_tiLDscXXvRPBgMF77jdOicCV6cyUV0Snsu1_KSRbm4rLtgXLXVMqFyYpxdyicsD577e4yZ0VVXT4Oo_af0eDh3I3ZPIWui38EmYuoRhvQuYZkqjhGd_xOkvjQF4_Tp6cyNO0XdAMGMoYG-5npHC0gcPpv56qYGX8ffj0P8ZyR9shn3H7kcQqE2YXXBa42VKK0poPbC996xSqFNW7ygePel41h493XlJ70wnP50vFY5s0raNFf9eLP3YYmLxiPks9gshayGwUQXNNwrSimoQv3OeJzRzihbzZNWTfhR4xKs53nlXMjwnnXwHRH5D07vJg_1zU7BQzJ-QRLZnsnhIOq3psHt1yuoCtsSTKBN6HPiR81F-snIttJiUAiYsgv_ajwPxxnKP0FnFXQfBuaUAtAOD5G_3MC1yECjzq-YI4MDOXj4dsIGnHkdzXo-DV2lXMl2WnPqytoUkugp14SWbJso-eDsN5QivqspnYc1VsdNAaOOgjBiHmi-bACI1CykrkuiYJm1nOHAH4L4IQjpd0pcNm-Dk7z9LGIE5lwKI07hLXp_ByhVXRT8xWuugl43pzoM1jgYD4LjTjScC3ymauqqvKjjoHfnt0Zma0eVDeQrnVT6W9RQ9wDt5KVebrrwJTqlaNV0HywZJo3gwFy-Qq5MfwAwwC-GdjMsER1TgXO_E5kFZZD4sNVgw==\",\"summary\":[]},{\"id\":\"msg_0a0794dab3b8ec7d016a1235eaae648195ab7ad5385b641107\",\"type\":\"message\",\"status\":\"completed\",\"content\":[{\"type\":\"output_text\",\"annotations\":[],\"logprobs\":[],\"text\":\"Done.\"}],\"phase\":\"final_answer\",\"role\":\"assistant\"}],\"parallel_tool_calls\":true,\"presence_penalty\":0.0,\"previous_response_id\":null,\"prompt_cache_key\":null,\"prompt_cache_retention\":\"24h\",\"reasoning\":{\"context\":\"current_turn\",\"effort\":\"low\",\"summary\":\"detailed\"},\"safety_identifier\":null,\"service_tier\":\"default\",\"store\":false,\"temperature\":1.0,\"text\":{\"format\":{\"type\":\"text\"},\"verbosity\":\"low\"},\"tool_choice\":\"auto\",\"tools\":[],\"top_logprobs\":0,\"top_p\":0.98,\"truncation\":\"disabled\",\"usage\":{\"input_tokens\":35,\"input_tokens_details\":{\"cached_tokens\":0},\"output_tokens\":20,\"output_tokens_details\":{\"reasoning_tokens\":12},\"total_tokens\":55},\"user\":null,\"metadata\":{}},\"sequence_number\":11}\n\n" } } ] diff --git a/packages/llm/test/provider/openai-responses-phase.recorded.test.ts b/packages/llm/test/provider/openai-responses-phase.recorded.test.ts deleted file mode 100644 index f8ea26054108..000000000000 --- a/packages/llm/test/provider/openai-responses-phase.recorded.test.ts +++ /dev/null @@ -1,89 +0,0 @@ -import { describe, expect } from "bun:test" -import { Effect } from "effect" -import { LLM, Message } from "../../src" -import * as OpenAI from "../../src/providers/openai" -import { OpenAIResponses } from "../../src/protocols/openai-responses" -import { LLMClient } from "../../src/route" -import { weatherTool } from "../recorded-scenarios" -import { recordedTests } from "../recorded-test" - -const model = OpenAI.configure({ - apiKey: process.env.OPENAI_API_KEY ?? "fixture", -}).responses("gpt-5.6-sol") - -const recorded = recordedTests({ - prefix: "openai-responses-phase", - provider: "openai", - protocol: "openai-responses", - requires: ["OPENAI_API_KEY"], -}) - -describe("OpenAI Responses phase recorded", () => { - recorded.effect.with("round-trips commentary into a final answer", { tags: ["phase", "tool"] }, () => - Effect.gen(function* () { - const user = Message.user("What is the weather in Paris?") - const first = yield* LLMClient.generate( - LLM.request({ - model, - system: - "Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. Do not provide the final answer until its result is available.", - messages: [user], - tools: [weatherTool], - generation: { maxTokens: 100 }, - }), - ) - const call = first.toolCalls[0] - if (!call) throw new Error("OpenAI Responses did not return the expected weather tool call") - - expect(call).toMatchObject({ name: "get_weather", input: { city: "Paris" } }) - const commentary = first.message.content.find( - (part) => part.type === "text" && part.providerMetadata?.openai?.phase === "commentary", - ) - if (!commentary || commentary.type !== "text") throw new Error("OpenAI Responses did not return commentary text") - const itemID = commentary.providerMetadata?.openai?.itemId - if (typeof itemID !== "string") throw new Error("OpenAI Responses commentary did not include an item ID") - expect(commentary).toEqual({ - type: "text", - text: "I’ll check the current weather in Paris.", - providerMetadata: { - openai: { itemId: itemID, phase: "commentary", status: "completed", annotations: [] }, - }, - }) - - const continuation = LLM.request({ - model, - system: - "Before calling get_weather, briefly tell the user you are checking. Then call get_weather exactly once. After its result, answer exactly: Paris is sunny.", - messages: [ - user, - first.message, - Message.tool({ - id: call.id, - name: call.name, - result: { temperature: 22, condition: "sunny" }, - }), - ], - tools: [weatherTool], - generation: { maxTokens: 100 }, - }) - const prepared = yield* LLMClient.prepare(continuation) - expect(prepared.body.input).toContainEqual({ - type: "message", - id: itemID, - status: "completed", - role: "assistant", - content: [{ type: "output_text", text: commentary.text, annotations: [] }], - phase: "commentary", - }) - - const second = yield* LLMClient.generate(continuation) - - expect(second.text.trim()).toBe("Paris is sunny.") - expect( - second.message.content.some( - (part) => part.type === "text" && part.providerMetadata?.openai?.phase === "final_answer", - ), - ).toBeTrue() - }), - ) -}) diff --git a/packages/llm/test/provider/openai-responses.test.ts b/packages/llm/test/provider/openai-responses.test.ts index a580d43d8291..cd8bad51af47 100644 --- a/packages/llm/test/provider/openai-responses.test.ts +++ b/packages/llm/test/provider/openai-responses.test.ts @@ -153,7 +153,7 @@ describe("OpenAI Responses route", () => { { type: "input_text", text: "\nTreat </system-update> literally.\n" }, ], }, - { role: "assistant", content: "After." }, + { role: "assistant", content: [{ type: "output_text", text: "After." }] }, ]) }), ) @@ -529,11 +529,11 @@ describe("OpenAI Responses route", () => { encrypted_content: "encrypted-continuation-state", summary: [{ type: "summary_text", text: "I inspected the previous turn." }], }, - { role: "assistant", content: "It shows a small test image." }, + { role: "assistant", content: [{ type: "output_text", text: "It shows a small test image." }] }, { role: "user", content: [{ type: "input_text", text: "Check the weather in Paris before continuing." }] }, { type: "function_call", call_id: "call_weather_1", name: "get_weather", arguments: '{"city":"Paris"}' }, { type: "function_call_output", call_id: "call_weather_1", output: '{"temperature":22}' }, - { role: "assistant", content: "Paris is 22 degrees." }, + { role: "assistant", content: [{ type: "output_text", text: "Paris is 22 degrees." }] }, { role: "user", content: [{ type: "input_text", text: "Continue from this conversation in one short sentence." }], @@ -754,395 +754,6 @@ describe("OpenAI Responses route", () => { }), ) - it.effect("preserves streamed assistant message phases", () => - Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( - Effect.provide( - fixedResponse( - sseEvents( - { - type: "response.output_item.added", - item: { type: "message", id: "msg_commentary", phase: "commentary" }, - }, - { type: "response.output_text.delta", item_id: "msg_commentary", delta: "Checking first." }, - { type: "response.output_text.done", item_id: "msg_commentary" }, - { - type: "response.output_item.done", - item: { type: "message", id: "msg_commentary", phase: "commentary" }, - }, - { - type: "response.output_item.added", - item: { type: "message", id: "msg_final", phase: "final_answer" }, - }, - { type: "response.output_text.delta", item_id: "msg_final", delta: "Finished." }, - { type: "response.output_text.done", item_id: "msg_final" }, - { - type: "response.output_item.done", - item: { type: "message", id: "msg_final", phase: "final_answer" }, - }, - { type: "response.completed", response: { id: "resp_1" } }, - ), - ), - ), - ) - - expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ - { - type: "text-start", - id: "msg_commentary", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { type: "text-delta", id: "msg_commentary", text: "Checking first." }, - { - type: "text-end", - id: "msg_commentary", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text-start", - id: "msg_final", - providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, - }, - { type: "text-delta", id: "msg_final", text: "Finished." }, - { - type: "text-end", - id: "msg_final", - providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, - }, - ]) - expect(response.message.content).toEqual([ - { - type: "text", - text: "Checking first.", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text", - text: "Finished.", - providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, - }, - ]) - }), - ) - - it.effect("preserves phased message and content boundaries", () => - Effect.gen(function* () { - const response = yield* LLMClient.generate(request).pipe( - Effect.provide( - fixedResponse( - sseEvents( - { - type: "response.output_item.added", - item: { type: "message", id: "msg_commentary", phase: "commentary" }, - }, - { - type: "response.output_text.delta", - item_id: "msg_commentary", - content_index: 0, - delta: "First.", - }, - { - type: "response.output_item.added", - item: { type: "message", id: "msg_commentary" }, - }, - { - type: "response.output_text.done", - item_id: "msg_commentary", - content_index: 0, - text: "First.", - }, - { - type: "response.output_text.done", - item_id: "msg_commentary", - content_index: 1, - text: "Second.", - }, - { - type: "response.output_item.done", - item: { type: "message", id: "msg_commentary" }, - }, - { - type: "response.output_item.added", - item: { type: "message", id: "msg_commentary_2", phase: "commentary" }, - }, - { - type: "response.output_text.delta", - item_id: "msg_commentary_2", - content_index: 0, - delta: "Thi", - }, - { - type: "response.output_text.done", - item_id: "msg_commentary_2", - content_index: 0, - text: "Third.", - }, - { - type: "response.output_item.done", - item: { type: "message", id: "msg_commentary_2", phase: "commentary" }, - }, - { - type: "response.output_item.added", - item: { type: "message", id: "openai-text-0" }, - }, - { - type: "response.output_text.done", - item_id: "openai-text-0", - content_index: 0, - text: "Final.", - }, - { - type: "response.output_item.done", - item: { - type: "message", - id: "openai-text-0", - phase: "final_answer", - content: [ - { - type: "output_text", - text: "Final.", - annotations: [ - { - type: "url_citation", - url: "https://example.com", - title: "Example", - start_index: 0, - end_index: 6, - }, - ], - }, - ], - }, - }, - { - type: "response.output_item.added", - item: { type: "message", id: "msg_null", phase: null }, - }, - { - type: "response.output_text.done", - item_id: "msg_null", - content_index: 0, - text: "Nullable.", - }, - { - type: "response.output_item.done", - item: { type: "message", id: "msg_null", phase: null }, - }, - { - type: "response.output_item.added", - item: { type: "message", id: "msg_unphased" }, - }, - { - type: "response.output_text.done", - item_id: "msg_unphased", - content_index: 0, - text: "Unphased.", - }, - { - type: "response.output_item.done", - item: { type: "message", id: "msg_unphased" }, - }, - { type: "response.completed", response: { id: "resp_1" } }, - ), - ), - ), - ) - - expect(response.message.content).toEqual([ - { - type: "text", - text: "First.", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text", - text: "Second.", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text", - text: "Third.", - providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, - }, - { - type: "text", - text: "Final.", - providerMetadata: { - openai: { - itemId: "openai-text-0", - phase: "final_answer", - annotations: [ - { - type: "url_citation", - url: "https://example.com", - title: "Example", - start_index: 0, - end_index: 6, - }, - ], - }, - }, - }, - { - type: "text", - text: "Nullable.", - providerMetadata: { openai: { itemId: "msg_null", phase: null } }, - }, - { - type: "text", - text: "Unphased.", - providerMetadata: { openai: { itemId: "msg_unphased" } }, - }, - ]) - - expect(response.events.filter((event) => event.type.startsWith("text-"))).toEqual([ - { - type: "text-start", - id: "msg_commentary", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { type: "text-delta", id: "msg_commentary", text: "First." }, - { - type: "text-end", - id: "msg_commentary", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text-start", - id: "openai-text-0", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { type: "text-delta", id: "openai-text-0", text: "Second." }, - { - type: "text-end", - id: "openai-text-0", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text-start", - id: "msg_commentary_2", - providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, - }, - { type: "text-delta", id: "msg_commentary_2", text: "Thi" }, - { type: "text-delta", id: "msg_commentary_2", text: "rd." }, - { - type: "text-end", - id: "msg_commentary_2", - providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, - }, - { - type: "text-start", - id: "openai-text-1", - providerMetadata: { openai: { itemId: "openai-text-0" } }, - }, - { type: "text-delta", id: "openai-text-1", text: "Final." }, - { - type: "text-end", - id: "openai-text-1", - providerMetadata: { - openai: { - itemId: "openai-text-0", - phase: "final_answer", - annotations: [ - { - type: "url_citation", - url: "https://example.com", - title: "Example", - start_index: 0, - end_index: 6, - }, - ], - }, - }, - }, - { - type: "text-start", - id: "msg_null", - providerMetadata: { openai: { itemId: "msg_null", phase: null } }, - }, - { type: "text-delta", id: "msg_null", text: "Nullable." }, - { - type: "text-end", - id: "msg_null", - providerMetadata: { openai: { itemId: "msg_null", phase: null } }, - }, - { - type: "text-start", - id: "msg_unphased", - providerMetadata: { openai: { itemId: "msg_unphased" } }, - }, - { type: "text-delta", id: "msg_unphased", text: "Unphased." }, - { - type: "text-end", - id: "msg_unphased", - providerMetadata: { openai: { itemId: "msg_unphased" } }, - }, - ]) - - const prepared = yield* LLMClient.prepare( - LLM.request({ model, messages: [response.message] }), - ) - expect(prepared.body.input).toEqual([ - { - type: "message", - id: "msg_commentary", - status: "completed", - role: "assistant", - phase: "commentary", - content: [ - { type: "output_text", text: "First.", annotations: [] }, - { type: "output_text", text: "Second.", annotations: [] }, - ], - }, - { - type: "message", - id: "msg_commentary_2", - status: "completed", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Third.", annotations: [] }], - }, - { - type: "message", - id: "openai-text-0", - status: "completed", - role: "assistant", - phase: "final_answer", - content: [ - { - type: "output_text", - text: "Final.", - annotations: [ - { - type: "url_citation", - url: "https://example.com", - title: "Example", - start_index: 0, - end_index: 6, - }, - ], - }, - ], - }, - { - type: "message", - id: "msg_null", - status: "completed", - role: "assistant", - phase: null, - content: [{ type: "output_text", text: "Nullable.", annotations: [] }], - }, - { - type: "message", - id: "msg_unphased", - status: "completed", - role: "assistant", - content: [{ type: "output_text", text: "Unphased.", annotations: [] }], - }, - ]) - }), - ) - it.effect("parses reasoning summary stream fixtures", () => Effect.gen(function* () { const body = sseEvents( @@ -1336,7 +947,7 @@ describe("OpenAI Responses route", () => { encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked the previous diff." }], }, - { role: "assistant", content: "The parser changed." }, + { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, ], }) @@ -1384,69 +995,13 @@ describe("OpenAI Responses route", () => { ) expect(prepared.body.input).toEqual([ - { role: "assistant", content: "Before." }, + { role: "assistant", content: [{ type: "output_text", text: "Before." }] }, { type: "reasoning", encrypted_content: "encrypted-state", summary: [{ type: "summary_text", text: "Checked order." }], }, - { role: "assistant", content: "After." }, - ]) - }), - ) - - it.effect("round-trips assistant message phases", () => - Effect.gen(function* () { - const prepared = yield* LLMClient.prepare( - LLM.request({ - model, - messages: [ - Message.assistant([ - { - type: "text", - text: "Checking first.", - providerMetadata: { openai: { itemId: "msg_commentary", phase: "commentary" } }, - }, - { - type: "text", - text: "Still checking.", - providerMetadata: { openai: { itemId: "msg_commentary_2", phase: "commentary" } }, - }, - { - type: "text", - text: "Finished.", - providerMetadata: { openai: { itemId: "msg_final", phase: "final_answer" } }, - }, - ]), - ], - }), - ) - - expect(prepared.body.input).toEqual([ - { - type: "message", - id: "msg_commentary", - status: "completed", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Checking first.", annotations: [] }], - }, - { - type: "message", - id: "msg_commentary_2", - status: "completed", - role: "assistant", - phase: "commentary", - content: [{ type: "output_text", text: "Still checking.", annotations: [] }], - }, - { - type: "message", - id: "msg_final", - status: "completed", - role: "assistant", - phase: "final_answer", - content: [{ type: "output_text", text: "Finished.", annotations: [] }], - }, + { role: "assistant", content: [{ type: "output_text", text: "After." }] }, ]) }), ) @@ -1565,13 +1120,7 @@ describe("OpenAI Responses route", () => { }, }, }, - { - type: "text", - text: "The parser changed.", - providerMetadata: { - openai: { itemId: "msg_1", phase: "final_answer", status: "completed" }, - }, - }, + { type: "text", text: "The parser changed." }, ]), Message.user("Summarize it."), ], @@ -1582,7 +1131,7 @@ describe("OpenAI Responses route", () => { expect(prepared.body).toMatchObject({ input: [ { role: "user", content: [{ type: "input_text", text: "What changed?" }] }, - { role: "assistant", content: "The parser changed.", phase: "final_answer" }, + { role: "assistant", content: [{ type: "output_text", text: "The parser changed." }] }, { role: "user", content: [{ type: "input_text", text: "Summarize it." }] }, ], store: false, diff --git a/packages/opencode/test/session/llm-native.test.ts b/packages/opencode/test/session/llm-native.test.ts index 09b2ffbd160e..dd4d9cc17481 100644 --- a/packages/opencode/test/session/llm-native.test.ts +++ b/packages/opencode/test/session/llm-native.test.ts @@ -119,7 +119,7 @@ const storedSession = { const openAIResponses = { user: (text: string) => ({ role: "user", content: [{ type: "input_text", text }] }), - assistant: (text: string) => ({ role: "assistant", content: text }), + assistant: (text: string) => ({ role: "assistant", content: [{ type: "output_text", text }] }), openaiReasoning: (text: string, encryptedContent: string) => ({ type: "reasoning", encrypted_content: encryptedContent, From 2b2aacc93975330f9fd045d4306f698b0c6a8f8f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Fri, 24 Jul 2026 16:44:00 -0500 Subject: [PATCH 067/333] fix(provider): generalize Claude adaptive thinking (#38757) --- packages/opencode/src/provider/transform.ts | 53 ++++++--- .../opencode/test/provider/transform.test.ts | 112 +++++++++++++++++- 2 files changed, 145 insertions(+), 20 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 81759160bfeb..705af3d5c1b5 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -635,24 +635,23 @@ function openaiCompatibleReasoningEfforts(id: string) { return gpt5CodexReasoningEfforts(apiId) ?? versionedGpt5ReasoningEfforts(apiId) ?? OPENAI_EFFORTS } -function anthropicOpus47OrLater(apiId: string) { - // Matches "opus-4.7" (Anthropic/Bedrock/Vertex) and "claude-4.7-opus" (SAP AI Core inverted). - // Greedy \d+ correctly extends to multi-digit majors (e.g. "claude-10.0-opus") for forward compatibility. - const version = /opus-(\d+)[.-](\d+)(?:[.@-]|$)|claude-(\d+)[.-](\d+)-opus(?:[.@-]|$)/i.exec(apiId) - if (!version) return false - const major = Number(version[1] ?? version[3]) - const minor = Number(version[2] ?? version[4]) +function anthropicUsesModernAdaptiveThinking(apiId: string) { + if (!apiId.toLowerCase().includes("claude-")) return false + // Covers family-first IDs such as claude-opus-4.7 and version-first IDs such as claude-4.7-opus. + // Limit minors to two digits so release dates in IDs such as claude-opus-4-20250514 are not versions. + const version = /claude-(?:[a-z]+-)?(\d+)(?:[.-](\d{1,2}))?(?:[.@-]|$)/i.exec(apiId) + if (!version) return true + const major = Number(version[1]) + const minor = Number(version[2] ?? 0) return major > 4 || (major === 4 && minor >= 7) } -function anthropicSonnet5OrLater(apiId: string) { - const version = /sonnet-(\d+)(?:[.@-]|$)|claude-(\d+)-sonnet(?:[.@-]|$)/i.exec(apiId) - if (!version) return false - return Number(version[1] ?? version[2]) >= 5 +function anthropicOpus45(apiId: string) { + return ["opus-4-5", "opus-4.5"].some((value) => apiId.includes(value)) } function anthropicAdaptiveEfforts(apiId: string): string[] | null { - if (anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5")) { + if (anthropicUsesModernAdaptiveThinking(apiId)) { return ["low", "medium", "high", "xhigh", "max"] } if ( @@ -666,7 +665,7 @@ function anthropicAdaptiveEfforts(apiId: string): string[] | null { } function anthropicOmitsThinking(apiId: string) { - return anthropicOpus47OrLater(apiId) || anthropicSonnet5OrLater(apiId) || apiId.includes("fable-5") + return anthropicUsesModernAdaptiveThinking(apiId) } function googleThinkingLevelEfforts(apiId: string) { @@ -990,8 +989,10 @@ export function variants(model: Provider.Model): Record model.api.id.includes(v))) { - return Object.fromEntries(WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, { effort }])) + if (anthropicOpus45(model.api.id)) { + return Object.fromEntries( + WIDELY_SUPPORTED_EFFORTS.map((effort) => [effort, anthropicOpus45Effort(model, effort)]), + ) } return { @@ -1103,7 +1104,7 @@ export function variants(model: Provider.Model): Record [ @@ -1711,6 +1712,14 @@ function reasoningEffort(model: Provider.Model, effort: string) { ...(anthropicOmitsThinking(model.api.id) ? { display: "summarized" } : {}), }, } + if (anthropicOpus45(model.api.id)) + return { + reasoningConfig: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + maxReasoningEffort: effort, + }, + } if (model.api.id.includes("anthropic")) return return { reasoningConfig: { type: "enabled", maxReasoningEffort: effort } } case "@ai-sdk/gateway": @@ -1751,7 +1760,7 @@ function reasoningEffort(model: Provider.Model, effort: string) { } function anthropicEffort(model: Provider.Model, effort: string) { - if (["opus-4-5", "opus-4.5"].some((value) => model.api.id.includes(value))) return { effort } + if (anthropicOpus45(model.api.id)) return anthropicOpus45Effort(model, effort) // Kimi defaults to omitting adaptive thinking text unless summarized display is requested. if (isKimiFamily(model)) return { thinking: { type: "adaptive", display: "summarized" }, effort } if (!anthropicAdaptiveEfforts(model.api.id)) return @@ -1764,6 +1773,16 @@ function anthropicEffort(model: Provider.Model, effort: string) { } } +function anthropicOpus45Effort(model: Provider.Model, effort: string) { + return { + thinking: { + type: "enabled", + budgetTokens: Math.min(16_000, Math.floor(model.limit.output / 2 - 1)), + }, + effort, + } +} + function reasoningBudget(model: Provider.Model, budget: number) { switch (model.api.npm) { case "@openrouter/ai-sdk-provider": diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index ef2b275035e1..93e166c4a8c9 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3213,6 +3213,7 @@ describe("ProviderTransform.reasoningVariants", () => { { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, "claude-opus-4-7", ], + ["@ai-sdk/anthropic", { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, "claude-opus-5"], ["@ai-sdk/google", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], ["@ai-sdk/google-vertex", { thinkingConfig: { includeThoughts: true, thinkingLevel: "high" } }], [ @@ -3264,13 +3265,18 @@ describe("ProviderTransform.reasoningVariants", () => { ) }) - test("uses bare effort for Claude Opus 4.5", () => { + test("combines effort with extended thinking for Claude Opus 4.5", () => { expect( ProviderTransform.reasoningVariants( model([{ type: "effort", values: ["high"] }]), target("@ai-sdk/anthropic", "claude-opus-4-5"), ), - ).toEqual({ high: { effort: "high" } }) + ).toEqual({ + high: { + thinking: { type: "enabled", budgetTokens: 16_000 }, + effort: "high", + }, + }) }) test("uses explicit effort metadata for Anthropic-compatible models", () => { @@ -3319,6 +3325,38 @@ describe("ProviderTransform.reasoningVariants", () => { }) }) + test("uses adaptive reasoning config for Claude Opus 5 on Bedrock", () => { + const result = ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["low", "medium", "high", "xhigh", "max"] }]), + target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-5"), + ) + expect(Object.keys(result ?? {})).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result?.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + + test("combines effort with extended thinking for Claude Opus 4.5 on Bedrock", () => { + expect( + ProviderTransform.reasoningVariants( + model([{ type: "effort", values: ["high"] }]), + target("@ai-sdk/amazon-bedrock", "us.anthropic.claude-opus-4-5-20251101-v1:0"), + ), + ).toEqual({ + high: { + reasoningConfig: { + type: "enabled", + budgetTokens: 16_000, + maxReasoningEffort: "high", + }, + }, + }) + }) + test("does not replace unsupported Anthropic Bedrock effort options with token budgets", () => { expect( ProviderTransform.reasoningVariants( @@ -4617,11 +4655,17 @@ describe("ProviderTransform.variants", () => { describe("@ai-sdk/anthropic", () => { for (const testCase of [ + { + name: "opus 4 dated", + apiIds: ["claude-opus-4-20250514"], + efforts: ["high", "max"], + expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 } }, + }, { name: "opus 4.5", apiIds: ["claude-opus-4-5-20251101", "claude-opus-4.5-20251101"], efforts: ["low", "medium", "high"], - expectedHigh: { effort: "high" }, + expectedHigh: { thinking: { type: "enabled", budgetTokens: 16000 }, effort: "high" }, }, { name: "sonnet 4.6", @@ -4653,6 +4697,18 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, }, + { + name: "opus 5", + apiIds: ["claude-opus-5", "claude-opus-5-20260724"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, + { + name: "unversioned future model", + apiIds: ["claude-future"], + efforts: ["low", "medium", "high", "xhigh", "max"], + expectedHigh: { thinking: { type: "adaptive", display: "summarized" }, effort: "high" }, + }, { name: "fable 5", apiIds: ["claude-fable-5"], @@ -4772,6 +4828,28 @@ describe("ProviderTransform.variants", () => { effort: "high", }) }) + + test("opus 5 uses adaptive reasoning for Vertex model IDs", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "google-vertex-anthropic/claude-opus-5@default", + providerID: "google-vertex-anthropic", + api: { + id: "claude-opus-5@default", + url: "https://us-central1-aiplatform.googleapis.com", + npm: "@ai-sdk/google-vertex/anthropic", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + thinking: { + type: "adaptive", + display: "summarized", + }, + effort: "high", + }) + }) }) describe("@ai-sdk/amazon-bedrock", () => { @@ -4867,6 +4945,28 @@ describe("ProviderTransform.variants", () => { }) }) + test("anthropic opus 5 returns adaptive reasoning options with xhigh", () => { + const result = ProviderTransform.variants( + createMockModel({ + id: "bedrock/anthropic-claude-opus-5", + providerID: "bedrock", + api: { + id: "us.anthropic.claude-opus-5-v1:0", + url: "https://bedrock.amazonaws.com", + npm: "@ai-sdk/amazon-bedrock", + }, + }), + ) + expect(Object.keys(result)).toEqual(["low", "medium", "high", "xhigh", "max"]) + expect(result.high).toEqual({ + reasoningConfig: { + type: "adaptive", + maxReasoningEffort: "high", + display: "summarized", + }, + }) + }) + test("returns WIDELY_SUPPORTED_EFFORTS with reasoningConfig", () => { const model = createMockModel({ id: "bedrock/llama-4", @@ -5055,6 +5155,12 @@ describe("ProviderTransform.variants", () => { efforts: ["low", "medium", "high", "xhigh", "max"], thinking: { type: "adaptive", display: "summarized" }, }, + { + name: "opus 5", + apiIds: ["anthropic--claude-opus-5", "anthropic--claude-5-opus"], + efforts: ["low", "medium", "high", "xhigh", "max"], + thinking: { type: "adaptive", display: "summarized" }, + }, ]) { for (const apiId of testCase.apiIds) { test(`${testCase.name} ${apiId} returns adaptive thinking variants under modelParams`, () => { From a85d8d23aa297b3051e642c28e3fc79b457fc4bc Mon Sep 17 00:00:00 2001 From: opencode Date: Fri, 24 Jul 2026 22:18:13 +0000 Subject: [PATCH 068/333] sync release versions for v1.18.5 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index ccca966458c8..426438cfbba6 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.4", + "version": "1.18.5", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.4", + "version": "1.18.5", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.4", + "version": "1.18.5", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", @@ -841,7 +841,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +854,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +888,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +907,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +949,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1027,7 +1027,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index f6a1bf9d2ea7..23a830fcd57e 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.4", + "version": "1.18.5", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 58b1429ad590..26a621028110 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 80c8f5679d5b..15f3910e27e1 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.4", + "version": "1.18.5", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 6c6f14e20a45..22e440d0d50a 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 6a04328e7916..be70f690c402 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index a7df87572aae..5909638d05d5 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.4", + "version": "1.18.5", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index ac5932cb07b8..ca8f1dada9d5 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.4", + "version": "1.18.5", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 0da6c16520d4..3fb6d2fdf278 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 761bee109a9d..7ecdd9263225 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.4", + "version": "1.18.5", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 78e070c4c01a..9820c107cdce 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 7b3df31b8dac..d29305faaed9 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.4", + "version": "1.18.5", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 093b913a6362..e56d3ba4f2c2 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.4", + "version": "1.18.5", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 1e0c5e6e0c56..761f1c758978 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index be1eba336f27..535ee6faa63d 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.4", + "version": "1.18.5", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 9bcbf3839a36..a39ac136422c 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.4", + "version": "1.18.5", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index dc1eda59e481..6afa905bb463 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.4", + "version": "1.18.5", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 0876f4badb1c..7960a72dfe2d 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.4", + "version": "1.18.5", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2b7bc946396e..fdeab9b82b3a 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 261d9a54d72d..093439705af3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 578217a70335..d011dbb4735a 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 3cf74b043b67..158cb21e0ab8 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 3dd5746db5f2..e4fc453450b1 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 84ff0a214d77..b14e49e2d883 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index e828912704b3..f621709185b9 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 7daf57998b2f..e6795405832c 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 3fd469378035..d450262d85f9 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.4", + "version": "1.18.5", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index b8e3ec7172f9..971c3c89d0f1 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.4", + "version": "1.18.5", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 36ce8f81f9b7..2918b28f3985 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.4", + "version": "1.18.5", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index a5c257f7129b..4e89e1f08063 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.4", + "version": "1.18.5", "publisher": "sst-dev", "repository": { "type": "git", From 065dc274ec46a9995c04d8908293d3502bcff67e Mon Sep 17 00:00:00 2001 From: Kit Langton Date: Fri, 24 Jul 2026 21:23:44 -0400 Subject: [PATCH 069/333] fix(core): branch-keyed repository cache with gated reference readiness (#38759) --- packages/core/src/reference.ts | 6 +- packages/core/src/repository-cache.ts | 78 +++++++++---------- packages/core/src/repository.ts | 10 ++- packages/core/test/reference.test.ts | 2 +- packages/core/test/repository-cache.test.ts | 37 ++++++++- packages/core/test/repository.test.ts | 6 ++ .../test/server/httpapi-reference.test.ts | 2 +- 7 files changed, 89 insertions(+), 52 deletions(-) diff --git a/packages/core/src/reference.ts b/packages/core/src/reference.ts index 5303dbd955c1..1e1ab9d20f75 100644 --- a/packages/core/src/reference.ts +++ b/packages/core/src/reference.ts @@ -58,7 +58,6 @@ const layer = Layer.effect( finalize: (draft) => Effect.gen(function* () { materialized.clear() - const seen = new Map() for (const [name, source] of draft.list()) { if (source.type === "local") { materialized.set( @@ -82,14 +81,11 @@ const layer = Layer.effect( continue } } - const target = Repository.cachePath(global.repos, repository) - if (seen.has(target) && seen.get(target) !== source.branch) continue - seen.set(target, source.branch) materialized.set( name, new Info({ name, - path: AbsolutePath.make(target), + path: AbsolutePath.make(Repository.cachePath(global.repos, repository, source.branch)), ...(source.description === undefined ? {} : { description: source.description }), ...(source.hidden === undefined ? {} : { hidden: source.hidden }), source, diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index 988236743b39..f166219125fd 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -1,3 +1,10 @@ +/** + * Local tracking checkouts for remote Git references, one per remote and + * branch. Each checkout permanently tracks a single ref: the requested branch + * when the cache key has one, otherwise the remote default branch. Content + * follows "newest wins": refresh fetches and hard-resets, so readers may + * observe the checkout move underneath them. + */ import path from "path" import { Context, Effect, Layer, Schema } from "effect" import { FSUtil } from "./fs-util" @@ -135,7 +142,7 @@ const layer: Layer.Layer new FetchFailedError({ repository, message: error.message }))) if (input.branch) { - const requestedBranch = input.branch yield* git.sync - .fetchBranch(existing, { branch: requestedBranch }) + .fetchBranch(existing, { branch: input.branch }) .pipe(Effect.mapError((error) => new FetchFailedError({ repository, message: error.message }))) + } - yield* git.sync.checkoutRemoteBranch(existing, { branch: requestedBranch }).pipe( - Effect.mapError( - (error) => - new CheckoutFailedError({ - repository, - branch: requestedBranch, - message: error.message, - }), - ), - ) + // Checking out the tracked ref before resetting keeps the + // checkout self-healing even if it was left on another + // branch. + const branch = input.branch ?? (yield* git.history.defaultRemoteBranch(existing)) + if (branch) { + yield* git.sync + .checkoutRemoteBranch(existing, { branch }) + .pipe( + Effect.mapError( + (error) => new CheckoutFailedError({ repository, branch, message: error.message }), + ), + ) } + const target = branch ?? (yield* git.history.branch(existing)) yield* git.sync - .resetHard(existing, yield* resetTarget(git, existing, input.branch)) + .resetHard(existing, target ? `origin/${target}` : "HEAD") .pipe(Effect.mapError((error) => new ResetFailedError({ repository, message: error.message }))) } @@ -229,12 +242,6 @@ export const node = makeGlobalNode({ deps: [EffectFlock.node, FSUtil.node, Git.node, Global.node], }) -function statusForRepository(input: { reuse: boolean; refresh?: boolean; branchMatches?: boolean }) { - if (!input.reuse) return "cloned" as const - if (input.branchMatches === false || input.refresh) return "refreshed" as const - return "cached" as const -} - function errorMessage(error: unknown) { return error instanceof globalThis.Error ? error.message : String(error) } @@ -245,17 +252,4 @@ function cacheOperation(effect: Effect.Effect, operation: stri ) } -const resetTarget = Effect.fnUntraced(function* ( - git: Git.Interface, - repository: Git.Repository, - requestedBranch?: string, -) { - if (requestedBranch) return `origin/${requestedBranch}` - const remoteHead = yield* git.history.defaultRemoteBranch(repository) - if (remoteHead) return `origin/${remoteHead}` - const currentBranch = yield* git.history.branch(repository) - if (currentBranch) return `origin/${currentBranch}` - return "HEAD" -}) - export * as RepositoryCache from "./repository-cache" diff --git a/packages/core/src/repository.ts b/packages/core/src/repository.ts index dbc6a8fbcae6..8ee5be600e3a 100644 --- a/packages/core/src/repository.ts +++ b/packages/core/src/repository.ts @@ -118,8 +118,14 @@ export function isRemote(reference: Reference): reference is RemoteReference { return !isFile(reference) } -export function cachePath(root: string, reference: Reference): string { - return path.join(root, ...reference.host.split(":"), ...reference.segments) +/** + * Checkouts are keyed by remote and branch: a branch-specific reference gets + * its own directory so branchless refreshes can never move it. The branch is + * percent-encoded because valid branch names may contain `/`. + */ +export function cachePath(root: string, reference: Reference, branch?: string): string { + const base = path.join(root, ...reference.host.split(":"), ...reference.segments) + return branch ? `${base}@${encodeURIComponent(branch)}` : base } export function cacheIdentity(reference: Reference): string { diff --git a/packages/core/test/reference.test.ts b/packages/core/test/reference.test.ts index db61232310ed..5ab215230acf 100644 --- a/packages/core/test/reference.test.ts +++ b/packages/core/test/reference.test.ts @@ -47,7 +47,7 @@ describe("Reference", () => { expect(yield* references.list()).toEqual([ new Reference.Info({ name: "sdk", - path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository)), + path: AbsolutePath.make(Repository.cachePath(Global.Path.repos, repository, "main")), source, }), ]) diff --git a/packages/core/test/repository-cache.test.ts b/packages/core/test/repository-cache.test.ts index 2dd0ce250288..4433583bf787 100644 --- a/packages/core/test/repository-cache.test.ts +++ b/packages/core/test/repository-cache.test.ts @@ -8,7 +8,7 @@ import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Global } from "@opencode-ai/core/global" import { Repository } from "@opencode-ai/core/repository" import { RepositoryCache } from "@opencode-ai/core/repository-cache" -import { git, gitRemote } from "./fixture/git" +import { branch, git, gitRemote } from "./fixture/git" import { tmpdir } from "./fixture/tmpdir" import { testEffect } from "./lib/effect" @@ -66,6 +66,41 @@ describe("RepositoryCache", () => { ), ) + it.live("keeps branch checkouts isolated from branchless refreshes", () => + withRemote((fixture) => + Effect.gen(function* () { + yield* Effect.promise(() => branch(fixture.source, "feature", "two\n")) + const cache = yield* RepositoryCache.Service + + const featured = yield* cache.ensure({ reference: fixture.reference, branch: "feature" }) + expect(featured.branch).toBe("feature") + expect(featured.localPath.endsWith("repo@feature")).toBe(true) + expect(yield* read(path.join(featured.localPath, "README.md"))).toBe("two\n") + + const refreshed = yield* cache.ensure({ reference: fixture.reference, refresh: true }) + expect(refreshed.localPath).not.toBe(featured.localPath) + expect(yield* read(path.join(refreshed.localPath, "README.md"))).toBe("one\n") + + const cached = yield* cache.ensure({ reference: fixture.reference, branch: "feature" }) + expect(cached.status).toBe("cached") + expect(yield* read(path.join(cached.localPath, "README.md"))).toBe("two\n") + }).pipe(Effect.provide(cacheLayer(fixture.root))), + ), + ) + + it.live("does not mistake an enclosing repository for the cache checkout", () => + withRemote((fixture) => + Effect.gen(function* () { + yield* Effect.promise(() => git(fixture.root, "clone", fixture.remote, path.join(fixture.root, "repos"))) + + const result = yield* (yield* RepositoryCache.Service).ensure({ reference: fixture.reference }) + + expect(result.status).toBe("cloned") + expect(yield* read(path.join(result.localPath, "README.md"))).toBe("one\n") + }).pipe(Effect.provide(cacheLayer(fixture.root))), + ), + ) + it.live("returns typed validation and clone failures", () => withRemote((fixture) => Effect.gen(function* () { diff --git a/packages/core/test/repository.test.ts b/packages/core/test/repository.test.ts index 5b18f8b1d693..1a6af675d12b 100644 --- a/packages/core/test/repository.test.ts +++ b/packages/core/test/repository.test.ts @@ -17,6 +17,12 @@ describe("Repository", () => { label: "owner/repo", }) expect(Repository.cachePath("/cache", reference)).toBe(path.join("/cache", "github.com", "owner", "repo")) + expect(Repository.cachePath("/cache", reference, "main")).toBe( + path.join("/cache", "github.com", "owner", "repo@main"), + ) + expect(Repository.cachePath("/cache", reference, "feature/x")).toBe( + path.join("/cache", "github.com", "owner", "repo@feature%2Fx"), + ) expect(Repository.cacheIdentity(reference)).toBe("github.com/owner/repo") }) diff --git a/packages/opencode/test/server/httpapi-reference.test.ts b/packages/opencode/test/server/httpapi-reference.test.ts index 9354d527da09..b187bd4eea2e 100644 --- a/packages/opencode/test/server/httpapi-reference.test.ts +++ b/packages/opencode/test/server/httpapi-reference.test.ts @@ -51,7 +51,7 @@ describe("reference HttpApi", () => { }, { name: "effect", - path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect"), + path: path.join(Global.Path.repos, "github.com", "Effect-TS", "effect@main"), source: { type: "git", repository: "Effect-TS/effect", From 5e2a6257b22c0141a20c281f4c2a641311afe5a5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 25 Jul 2026 01:25:04 +0000 Subject: [PATCH 070/333] chore: generate --- packages/core/src/repository-cache.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/packages/core/src/repository-cache.ts b/packages/core/src/repository-cache.ts index f166219125fd..cab2f631ffb8 100644 --- a/packages/core/src/repository-cache.ts +++ b/packages/core/src/repository-cache.ts @@ -167,7 +167,11 @@ const layer: Layer.Layer Date: Sat, 25 Jul 2026 14:58:05 +0800 Subject: [PATCH 071/333] fix(app): refresh V1 providers after auth (#38786) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Co-authored-by: Brendan Allan --- .../components/dialog-connect-provider.tsx | 6 +-- .../app/src/components/settings-providers.tsx | 2 +- .../settings-v2/dialog-settings-v2.tsx | 20 +++++++- .../src/components/settings-v2/providers.tsx | 11 ++-- packages/app/src/context/models.tsx | 2 +- packages/app/src/hooks/use-providers.ts | 10 ++-- packages/app/src/pages/layout.tsx | 2 +- .../composer/session-composer-controls.ts | 50 ++++++++++--------- packages/app/src/utils/server-compat.test.ts | 37 ++++++++++++++ packages/app/src/utils/server-compat.ts | 6 +++ 10 files changed, 107 insertions(+), 39 deletions(-) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 9ad389317baf..744bc7922788 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -159,7 +159,7 @@ function ProviderPicker(props: { const settings = useSettings() if (settings.general.newLayoutDesigns()) return - const providers = useProviders(props.directory) + const providers = useProviders(() => props.directory?.()) const language = useLanguage() const popularGroup = () => language.t("dialog.provider.group.popular") const otherGroup = () => language.t("dialog.provider.group.other") @@ -231,7 +231,7 @@ function ProviderPickerV2(props: { onSelect: (provider: string) => void onPrepare?: () => void }) { - const providers = useProviders(props.directory) + const providers = useProviders(() => props.directory?.()) const language = useLanguage() const [store, setStore] = createStore({ filter: "", @@ -391,7 +391,7 @@ function ProviderConnection(props: { const language = useLanguage() const settings = useSettings() const newLayout = settings.general.newLayoutDesigns - const providers = useProviders(props.directory) + const providers = useProviders(() => props.directory?.()) const directory = () => props.directory?.() ?? decode64(params.dir) const location = () => { const value = directory() diff --git a/packages/app/src/components/settings-providers.tsx b/packages/app/src/components/settings-providers.tsx index 7a15d82eaf61..080e6c517bfe 100644 --- a/packages/app/src/components/settings-providers.tsx +++ b/packages/app/src/components/settings-providers.tsx @@ -41,7 +41,7 @@ const SettingsProvidersContent: Component<{ onBack?: () => void }> = (props) => const serverSDK = useServerSDK() const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders() + const providers = useProviders(() => undefined) const providerConnect = useProviderConnectController({ onBack: props.onBack }) const connect = (provider?: string) => { diff --git a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx index af24a47274fa..4116f4a6208c 100644 --- a/packages/app/src/components/settings-v2/dialog-settings-v2.tsx +++ b/packages/app/src/components/settings-v2/dialog-settings-v2.tsx @@ -1,4 +1,4 @@ -import { Component, createSignal, startTransition } from "solid-js" +import { Component, createMemo, createSignal, startTransition } from "solid-js" import { Dialog } from "@opencode-ai/ui/v2/dialog-v2" import { TabsV2 } from "@opencode-ai/ui/v2/tabs-v2" import { Icon } from "@opencode-ai/ui/icon" @@ -11,6 +11,9 @@ import { SettingsModelsV2 } from "./models" import "./settings-v2.css" import { SettingsServersV2 } from "./servers" import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useLayout } from "@/context/layout" +import { useTabs } from "@/context/tabs" +import { useServerSync } from "@/context/server-sync" export const DialogSettings: Component<{ sessionID?: string @@ -19,7 +22,20 @@ export const DialogSettings: Component<{ const language = useLanguage() const platform = usePlatform() const dialog = useDialog() + const layout = useLayout() + const tabs = useTabs() + const serverSync = useServerSync() const [tab, setTab] = createSignal(props.defaultValue ?? "general") + const directory = createMemo(() => { + const route = layout.route() + if (route.type === "dir-new-sesssion") return route.dir + if (route.type === "draft") { + const draft = tabs.store.find((item) => item.type === "draft" && item.draftID === route.draftID) + return draft?.type === "draft" ? draft.directory : undefined + } + if (route.type === "session") return serverSync().session.get(route.sessionId)?.directory + return undefined + }) const showProviders = () => { void dialog.show(() => ) @@ -87,7 +103,7 @@ export const DialogSettings: Component<{ - + diff --git a/packages/app/src/components/settings-v2/providers.tsx b/packages/app/src/components/settings-v2/providers.tsx index 29192114f0fb..acd73ddbe544 100644 --- a/packages/app/src/components/settings-v2/providers.tsx +++ b/packages/app/src/components/settings-v2/providers.tsx @@ -4,7 +4,7 @@ import { useDialog } from "@opencode-ai/ui/context/dialog" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { showToast } from "@/utils/toast" import { popularProviders, useProviders } from "@/hooks/use-providers" -import { createMemo, type Component, For, Show } from "solid-js" +import { createMemo, type Accessor, type Component, For, Show } from "solid-js" import { useLanguage } from "@/context/language" import { useServerProtocol, useServerSDK } from "@/context/server-sdk" import { useServerSync } from "@/context/server-sync" @@ -29,18 +29,21 @@ const PROVIDER_NOTES = [ const PROVIDER_ICON_SIZE = 16 -export const SettingsProvidersV2: Component<{ onBack?: () => void }> = (props) => { +export const SettingsProvidersV2: Component<{ + directory: Accessor + onBack?: () => void +}> = (props) => { const dialog = useDialog() const language = useLanguage() const serverSdk = useServerSDK() const protocol = useServerProtocol() const serverSync = useServerSync() - const providers = useProviders() + const providers = useProviders(props.directory) const providerConnect = useProviderConnectController({ onBack: props.onBack }) const connect = (provider?: string) => { providerConnect.select(provider) - void dialog.show(() => ) + void dialog.show(() => ) } const connected = createMemo(() => { diff --git a/packages/app/src/context/models.tsx b/packages/app/src/context/models.tsx index 736dc3a7c52b..a80cf2e5804d 100644 --- a/packages/app/src/context/models.tsx +++ b/packages/app/src/context/models.tsx @@ -26,7 +26,7 @@ export const { use: useModels, provider: ModelsProvider } = createSimpleContext( name: "Models", gate: false, init: (props: { directory?: Accessor } = {}) => { - const providers = useProviders(props.directory) + const providers = useProviders(() => props.directory?.()) const [store, setStore, _, ready] = persisted( Persist.global("model", ["model.v1"]), diff --git a/packages/app/src/hooks/use-providers.ts b/packages/app/src/hooks/use-providers.ts index 982c26ca9712..60ad01c86743 100644 --- a/packages/app/src/hooks/use-providers.ts +++ b/packages/app/src/hooks/use-providers.ts @@ -2,7 +2,7 @@ import { useServerSync } from "@/context/server-sync" import { decode64 } from "@/utils/base64" import { useParams } from "@solidjs/router" import { Iterable, pipe } from "effect" -import type { Accessor } from "solid-js" +import { createEffect, createMemo, type Accessor } from "solid-js" import { selectProviderCatalog } from "./provider-catalog" export const popularProviders = [ @@ -17,14 +17,14 @@ export const popularProviders = [ ] const popularProviderSet = new Set(popularProviders) -export function useProviders(directory?: Accessor) { +export function useProviders(directory: Accessor) { const serverSync = useServerSync() const params = useParams() const dir = () => (directory ? directory() : decode64(params.dir)) const providers = () => { const value = dir() const projectStore = value ? serverSync().child(value)[0] : undefined - if (directory) + if (value) return selectProviderCatalog({ explicit: true, directory: value, @@ -37,6 +37,7 @@ export function useProviders(directory?: Accessor) { global: serverSync().data.provider, }) } + return { all: () => providers().all, default: () => providers().default, @@ -58,7 +59,7 @@ export function useProviders(directory?: Accessor) { }, paid: () => { const connected = new Set(providers().connected) - return [ + const paid = [ ...Iterable.filter( providers().all, ([id]) => @@ -66,6 +67,7 @@ export function useProviders(directory?: Accessor) { (id !== "opencode" || Object.values(providers().all.get(id)?.models ?? {}).some((m) => m.cost?.input)), ), ] + return paid }, } } diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 59474423184a..96ed022373a2 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -119,7 +119,7 @@ export default function LegacyLayout(props: ParentProps) { const permission = usePermission() const navigate = useNavigate() setNavigate(navigate) - const providers = useProviders() + const providers = useProviders(() => undefined) const dialog = useDialog() const command = useCommand() const theme = useTheme() 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 4ae7827e210a..a9b0070bc025 100644 --- a/packages/app/src/pages/session/composer/session-composer-controls.ts +++ b/packages/app/src/pages/session/composer/session-composer-controls.ts @@ -25,35 +25,39 @@ export function createPromptInputController(input: { }) { const layout = useLayout() const local = useLocal() - const providers = useProviders() - const sync = useSync() const sdk = useSDK() + const sync = useSync() + const providers = useProviders(() => sdk().directory) const view = layout.view(input.sessionKey) const agentsQuery = createQuery(() => input.queryOptions.agents(pathKey(sdk().directory))) const globalProvidersQuery = createQuery(() => input.queryOptions.providers(null)) const providersQuery = createQuery(() => input.queryOptions.providers(pathKey(sdk().directory))) - return createMemo(() => ({ - agents: { - available: sync().data.agent, - options: local.agent.list().map((agent) => agent.name), - current: local.agent.current()?.name ?? "", - loading: agentsQuery.isLoading, - visible: local.agent.visible(), - select: local.agent.set, - }, - model: { - selection: input.model ?? local.model, - paid: providers.paid().length > 0, - loading: - (local.agent.visible() && agentsQuery.isLoading) || providersQuery.isLoading || globalProvidersQuery.isLoading, - }, - session: { - id: input.sessionID(), - tabs: layout.tabs(input.sessionKey), - reviewPanel: view.reviewPanel, - }, - })) + return createMemo(() => { + return { + agents: { + available: sync().data.agent, + options: local.agent.list().map((agent) => agent.name), + current: local.agent.current()?.name ?? "", + loading: agentsQuery.isLoading, + visible: local.agent.visible(), + select: local.agent.set, + }, + model: { + selection: input.model ?? local.model, + paid: providers.paid().length > 0, + loading: + (local.agent.visible() && agentsQuery.isLoading) || + providersQuery.isLoading || + globalProvidersQuery.isLoading, + }, + session: { + id: input.sessionID(), + tabs: layout.tabs(input.sessionKey), + reviewPanel: view.reviewPanel, + }, + } + }) } export function createPromptProjectControls() { diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 3f4b8f2205cd..605fdaace84f 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -190,4 +190,41 @@ describe("createCompatibleApi", () => { 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") }) + + test("disposes the V1 instance after connecting a provider", async () => { + const { api, requests } = setup("v1") + + await api.integration.connect.key({ + integrationID: "openrouter", + key: "secret", + location: { directory: "/repo" }, + }) + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/auth/openrouter", + "/instance/dispose", + "/instance/dispose", + ]) + expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() + }) + + test("disposes the V1 instance after completing provider OAuth", async () => { + const { api, requests } = setup("v1") + + await api.integration.oauth.complete({ + integrationID: "openrouter", + attemptID: "openrouter:0", + code: "code", + location: { directory: "/repo" }, + }) + + expect(requests.map((request) => new URL(request.url).pathname)).toEqual([ + "/provider/openrouter/oauth/callback", + "/instance/dispose", + "/instance/dispose", + ]) + expect(requests[1]!.headers.get("x-opencode-directory")).toBe("%2Frepo") + expect(requests[2]!.headers.get("x-opencode-directory")).toBeNull() + }) }) diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 72e74438edca..94374aa9f91f 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -401,6 +401,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi { providerID: value.integrationID, auth: { type: "api", key: value.key }, }) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() }, }, oauth: { @@ -429,6 +431,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi { { providerID: value.integrationID, method, code: value.code }, { throwOnError: true }, ) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() }, status: async (value: Parameters[0]) => { const method = Number(value.attemptID.split(":").at(-1)) @@ -436,6 +440,8 @@ function createV1Api(input: CompatibleInput): CompatibleApi { { providerID: value.integrationID, method }, { throwOnError: true }, ) + await legacy(value.location).instance.dispose() + await input.legacy().instance.dispose() return located( { status: "complete" as const, time: { created: Date.now(), expires: Date.now() } }, value.location, From 2b2b69d668ed05836ea6d3fa7f42d416bdb61806 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 25 Jul 2026 15:01:18 +0800 Subject: [PATCH 072/333] fix(app): refresh V1 MCP state (#38816) --- .../src/context/global-sync/bootstrap.test.ts | 16 +++++++++++++--- .../app/src/context/global-sync/bootstrap.ts | 11 +++++++++-- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/packages/app/src/context/global-sync/bootstrap.test.ts b/packages/app/src/context/global-sync/bootstrap.test.ts index de2baa704c0c..58cc65a2e0bb 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -73,14 +73,14 @@ function directoryState() { } describe("bootstrapDirectory", () => { - test("marks a loading directory partial during bootstrap and complete after success", async () => { + test("uses legacy MCP endpoints while refreshing a v1 directory", async () => { const mcpReads: string[] = [] const [store, setStore] = directoryState() await bootstrapDirectory({ directory: "/project", scope: ServerScope.local, - mcp: false, + mcp: true, global: { config: {} satisfies Config, path: { state: "", config: "", worktree: "/project", directory: "/project", home: "/home" }, @@ -90,6 +90,7 @@ 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 () => { @@ -106,6 +107,14 @@ describe("bootstrapDirectory", () => { return { data: {} } }, }, + experimental: { + resource: { + list: async () => { + mcpReads.push("resource") + return { data: {} } + }, + }, + }, provider: { list: async () => ({ data: { all: [], connected: [], default: {} } }) }, } as unknown as OpencodeClient, api, @@ -115,6 +124,7 @@ describe("bootstrapDirectory", () => { loadSessions() {}, translate: (key) => key, queryClient: new QueryClient(), + protocol: Promise.resolve("v1"), }) expect(store.status).toBe("partial") @@ -122,7 +132,7 @@ describe("bootstrapDirectory", () => { await new Promise((resolve) => setTimeout(resolve, 80)) expect(store.status).toBe("complete") - expect(mcpReads).toEqual([]) + expect(mcpReads.sort()).toEqual(["command", "resource", "status"]) }) }) diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index 4f7b949e61a7..cf030c8af092 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -503,9 +503,16 @@ export async function bootstrapDirectory(input: { }), ), () => Promise.resolve(input.loadSessions(input.directory)), - 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( + loadMcpQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + )), + input.mcp && + (() => + input.queryClient.fetchQuery( + loadMcpResourcesQuery(input.scope, input.directory, input.api.mcp, input.sdk, input.protocol), + )), () => input.queryClient .fetchQuery(loadProvidersQuery(input.scope, input.directory, input.api, input.sdk, input.protocol)) From 0a6637e17aa79789a86608121f4dee8fad442d4f Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Sat, 25 Jul 2026 16:07:13 +0800 Subject: [PATCH 073/333] chore(app): vendor v2 client snapshot (#38818) --- bun.lock | 8 +-- .../review-state-persistence.spec.ts | 16 ++--- .../review-terminal-stacked.spec.ts | 46 ++++++------ .../regression/terminal-tab-switch.spec.ts | 2 +- packages/app/package.json | 2 +- .../components/dialog-select-directory-v2.tsx | 9 ++- .../components/dialog-select-directory.tsx | 9 ++- packages/app/src/components/edit-project.ts | 23 ++++-- packages/app/src/components/prompt-input.tsx | 2 +- .../app/src/components/settings-general.tsx | 3 +- .../src/components/settings-v2/general.tsx | 3 +- packages/app/src/components/terminal.tsx | 22 +++--- packages/app/src/context/directory-sync.ts | 3 +- .../src/context/global-sync/bootstrap.test.ts | 8 ++- .../app/src/context/global-sync/bootstrap.ts | 47 +++++++------ .../src/context/global-sync/event-reducer.ts | 26 +++---- packages/app/src/context/layout.tsx | 26 ++++--- .../context/server-session-v2-reducer.test.ts | 2 +- .../src/context/server-session-v2-reducer.ts | 21 ++++-- .../app/src/context/server-session.test.ts | 5 +- packages/app/src/context/server-session.ts | 13 ++-- packages/app/src/context/server-sync.test.ts | 4 +- packages/app/src/context/server-sync.tsx | 5 +- .../pages/home/home-sessions-controller.tsx | 8 ++- packages/app/src/pages/layout.tsx | 38 +++++++--- .../session/timeline/message-timeline.tsx | 3 +- packages/app/src/utils/server-compat.test.ts | 24 ++++--- packages/app/src/utils/server-compat.ts | 66 +++++++++--------- .../app/src/utils/session-message.test.ts | 4 +- packages/app/src/utils/session-message.ts | 9 ++- .../vendor/opencode-ai-client-1.17.13-v2.tgz | Bin 0 -> 67724 bytes .../app/vendor/opencode-ai-client-1.17.13.tgz | Bin 75585 -> 0 bytes packages/session-ui/package.json | 2 +- 33 files changed, 268 insertions(+), 191 deletions(-) create mode 100644 packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz delete mode 100644 packages/app/vendor/opencode-ai-client-1.17.13.tgz diff --git a/bun.lock b/bun.lock index 426438cfbba6..839f28839445 100644 --- a/bun.lock +++ b/bun.lock @@ -37,7 +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/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", @@ -799,7 +799,7 @@ "version": "1.18.5", "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", @@ -6030,7 +6030,7 @@ "@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/app/@opencode-ai/client": ["@opencode-ai/client@vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], "@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=="], @@ -6050,7 +6050,7 @@ "@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/@opencode-ai/client": ["@opencode-ai/client@../app/vendor/opencode-ai-client-1.17.13-v2.tgz", {}, "sha512-332kgNifvpQOF9e3UA+pIa5xPrMhLaQkUiNiO+meS0Ba9HjSE6hfsWnEojMkD0DPSLqPP6rCF1dDoF7U0Y0OCQ=="], "@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=="], diff --git a/packages/app/e2e/regression/review-state-persistence.spec.ts b/packages/app/e2e/regression/review-state-persistence.spec.ts index 4f67756d53c2..0d6756201e7e 100644 --- a/packages/app/e2e/regression/review-state-persistence.spec.ts +++ b/packages/app/e2e/regression/review-state-persistence.spec.ts @@ -65,7 +65,7 @@ async function switchSession(page: Page, title: string) { async function setup(page: Page) { await mockOpenCodeServer(page, { - protocol: "v2", + protocol: "v1", directory, project: { id: projectID, @@ -93,20 +93,18 @@ async function setup(page: Page) { route.fulfill({ status: 200, contentType: "application/json", - body: JSON.stringify({ location: { directory }, data: { branch: "feature", defaultBranch: "dev" } }), + body: JSON.stringify({ branch: "feature", default_branch: "dev" }), }), ) await page.route("**/vcs/diff**", (route) => route.fulfill({ status: 200, contentType: "application/json", - 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")], - }), + 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")], + ), }), ) 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 7cc8723a8cf7..79b564820e20 100644 --- a/packages/app/e2e/regression/review-terminal-stacked.spec.ts +++ b/packages/app/e2e/regression/review-terminal-stacked.spec.ts @@ -25,7 +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", + protocol: "v1", directory, project: { id: projectID, @@ -67,33 +67,31 @@ test("keeps the review tree and terminal sized when both panels are open", async status: 200, contentType: "application/json", body: JSON.stringify({ - location: { directory }, - data: { branch: "review-pane-performance", defaultBranch: "dev" }, + branch: "review-pane-performance", + default_branch: "dev", }), }), ) - await page.route("**/api/vcs/diff**", (route) => { + await page.route("**/vcs/diff**", (route) => { const url = new URL(route.request().url()) - const scope = url.searchParams.get("location[directory]")?.replaceAll("\\", "/") + const scope = url.searchParams.get("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({ - 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)), - }), + 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)), + ), }) }) - await page.route("**/api/pty*", (route) => + await page.route("**/pty*", (route) => route.fulfill({ status: 200, contentType: "application/json", @@ -111,7 +109,7 @@ test("keeps the review tree and terminal sized when both panels are open", async }), }), ) - await page.route("**/api/pty/pty_review_terminal*", (route) => + await page.route("**/pty/pty_review_terminal*", (route) => route.fulfill({ status: 200, contentType: "application/json", @@ -129,7 +127,7 @@ test("keeps the review tree and terminal sized when both panels are open", async }), }), ) - await page.route("**/api/pty/pty_review_terminal/connect-token*", (route) => + await page.route("**/pty/pty_review_terminal/connect-token*", (route) => route.fulfill({ status: 200, contentType: "application/json", @@ -139,7 +137,7 @@ test("keeps the review tree and terminal sized when both panels are open", async }), }), ) - await page.routeWebSocket("**/api/pty/pty_review_terminal/connect", () => undefined) + await page.routeWebSocket("**/pty/pty_review_terminal/connect", () => undefined) await page.addInitScript(() => { localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) localStorage.setItem( @@ -177,8 +175,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 === "/api/vcs/diff" && - url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) await lastFile.click() @@ -192,8 +190,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 === "/api/vcs/diff" && - url.searchParams.get("location[directory]")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true + url.pathname === "/vcs/diff" && + url.searchParams.get("directory")?.replaceAll("\\", "/").endsWith("/src/branch/d00027") === true ) }) sessionStatus[sessionID] = { type: "idle" } diff --git a/packages/app/e2e/regression/terminal-tab-switch.spec.ts b/packages/app/e2e/regression/terminal-tab-switch.spec.ts index 0076c5a2ca2f..165920753cb2 100644 --- a/packages/app/e2e/regression/terminal-tab-switch.spec.ts +++ b/packages/app/e2e/regression/terminal-tab-switch.spec.ts @@ -32,7 +32,7 @@ test("keeps the terminal session alive when switching session tabs in a workspac 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") + expect(connection.searchParams.get("ticket")).toBeNull() await writeProbe(page) await switchTab(page, titleB) diff --git a/packages/app/package.json b/packages/app/package.json index 23a830fcd57e..75b2b8c48809 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -53,7 +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/client": "file:vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/schema": "workspace:*", "@opencode-ai/sdk": "workspace:*", diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index e0909d849bd4..847239d098b5 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -8,6 +8,7 @@ import { createEffect, createMemo, createResource, createSignal, For, onCleanup, import { useGlobal } from "@/context/global" import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" +import type { Path } from "@opencode-ai/sdk/v2/client" import { absoluteTreePath, activeTreeNavigation, @@ -68,7 +69,13 @@ 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), + async (): Promise => { + if ((await sdk.protocol) !== "v1") return + return sdk.client.path + .get() + .then((result) => result.data) + .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 5cc19fd92056..4b24828e3ee9 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -9,6 +9,7 @@ import { useLanguage } from "@/context/language" import { ServerConnection } from "@/context/server" import { useGlobal } from "@/context/global" import { cleanPickerInput, createDirectorySearch, displayPickerPath } from "./directory-picker-domain" +import type { Path } from "@opencode-ai/sdk/v2/client" interface DialogSelectDirectoryProps { title?: string @@ -59,8 +60,12 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) const [fallbackPath] = createResource( () => (missingBase() ? true : undefined), - async () => { - return sdk.api.path.get().catch(() => undefined) + async (): Promise => { + if ((await sdk.protocol) !== "v1") return + return sdk.client.path + .get() + .then((result) => result.data) + .catch(() => undefined) }, { initialValue: undefined }, ) diff --git a/packages/app/src/components/edit-project.ts b/packages/app/src/components/edit-project.ts index 42053f6eff81..0c5d576e9347 100644 --- a/packages/app/src/components/edit-project.ts +++ b/packages/app/src/components/edit-project.ts @@ -71,12 +71,23 @@ export function createEditProjectModel(props: { project: LocalProject; server: S const start = store.startup.trim() if (props.project.id && props.project.id !== "global") { - const project = await serverCtx().sdk.api.project.update({ - projectID: props.project.id, - name, - icon: { color: store.color || "", override: store.iconOverride || "" }, - commands: { start }, - }) + if ((await serverCtx().sdk.protocol) !== "v1") return + const project = await serverCtx() + .sdk.client.project.update({ + projectID: props.project.id, + directory: props.project.worktree, + name, + icon: { color: store.color || "", override: store.iconOverride || "" }, + commands: { start }, + }) + .then((result) => result.data) + if (!project) return + // const project = await serverCtx().sdk.api.project.update({ + // projectID: props.project.id, + // 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)), ) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 3842b087914e..f70cb3992896 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -709,7 +709,7 @@ export const PromptInput: Component = (props) => { title: cmd.name, description: cmd.description, type: "custom" as const, - source: cmd.source, + // source: cmd.source, })) return [...custom, ...builtin] diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index 3ab265d72975..a20e66de3ee3 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -132,7 +132,8 @@ export const SettingsGeneral: Component = () => { if ((await sdk.protocol) === "v1") { return (await sdk.client.pty.shells()).data ?? [] } - return (await sdk.api.pty.shells()).data + // return (await sdk.api.pty.shells()).data + return [] as ShellOption[] }, { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 5a4cb186a65d..613ac96cbae8 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -127,7 +127,8 @@ export const SettingsGeneralV2: Component<{ if ((await sdk.protocol) === "v1") { return (await sdk.client.pty.shells()).data ?? [] } - return (await sdk.api.pty.shells()).data + // return (await sdk.api.pty.shells()).data + return [] as ShellOption[] }, { initialValue: [] as ShellOption[] }, ) diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index b2e827f73a71..65035034760e 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -573,19 +573,13 @@ 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({ - ptyID: id, - location: { directory }, - "x-opencode-ticket": "1", - }) - .then((result) => result.data.ticket) - .catch((err: unknown) => { - 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 - }) + // return sdk() + // .api.pty.connectToken({ + // ptyID: id, + // location: { directory }, + // "x-opencode-ticket": "1", + // }) + // .then((result) => result.data.ticket) } const retry = (err: unknown) => { @@ -616,7 +610,7 @@ export const Terminal = (props: TerminalProps) => { return undefined }) const protocol = await sdk().protocol - if (protocol === "v2" && !ticket) return + // if (protocol === "v2" && !ticket) return if (once.value) return if (disposed) return diff --git a/packages/app/src/context/directory-sync.ts b/packages/app/src/context/directory-sync.ts index ca6df85a53ad..befd5b61e095 100644 --- a/packages/app/src/context/directory-sync.ts +++ b/packages/app/src/context/directory-sync.ts @@ -134,7 +134,8 @@ export const createDirSyncContext = ( }, more: createMemo(() => current()[0].session.length >= current()[0].limit), archive: async (sessionID: string) => { - await serverSDK.api.session.archive({ sessionID, directory }) + if ((await serverSDK.protocol) !== "v1") return + await serverSDK.client.session.update({ sessionID, directory, time: { archived: Date.now() } }) 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 58cc65a2e0bb..71986dd3c40d 100644 --- a/packages/app/src/context/global-sync/bootstrap.test.ts +++ b/packages/app/src/context/global-sync/bootstrap.test.ts @@ -2,7 +2,7 @@ import { describe, expect, test } from "bun:test" import { createStore } from "solid-js/store" import { QueryClient } from "@tanstack/solid-query" 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 { AgentApi, CatalogApi, CommandApi, ReferenceApi } from "@opencode-ai/client/promise" import type { NormalizedProviderListResponse } from "@opencode-ai/session-ui/context" import { bootstrapDirectory, @@ -17,6 +17,8 @@ import type { State, VcsCache } from "./types" import { ServerScope } from "@/utils/server-scope" import type { ServerApi } from "@/utils/server" +type ProjectApi = ServerApi["project"] + const provider = { all: new Map(), connected: [], default: {} } satisfies NormalizedProviderListResponse const api = { agent: { list: async () => ({ location: {}, data: [] }) }, @@ -200,7 +202,7 @@ describe("query keys", () => { calls.push(input) return { location: {}, - data: [{ name: "review", template: "Review files", source: "command" as const }], + data: [{ name: "review", template: "Review files" /* source: "command" as const */ }], } }, } as unknown as CommandApi @@ -208,7 +210,7 @@ describe("query keys", () => { const result = await loadCommands("/repo", api) expect(calls).toEqual([{ location: { directory: "/repo" } }]) - expect(result).toEqual([{ name: "review", template: "Review files", source: "command" }]) + expect(result).toEqual([{ name: "review", template: "Review files" /* source: "command" */ }]) }) test("loads projects from the current endpoint", async () => { diff --git a/packages/app/src/context/global-sync/bootstrap.ts b/packages/app/src/context/global-sync/bootstrap.ts index cf030c8af092..39221d551fa6 100644 --- a/packages/app/src/context/global-sync/bootstrap.ts +++ b/packages/app/src/context/global-sync/bootstrap.ts @@ -16,18 +16,12 @@ import type { 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" @@ -50,6 +44,7 @@ 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" +import type { ServerApi } from "@/utils/server" type GlobalStore = { ready: boolean @@ -121,9 +116,10 @@ type ProjectApi = { readonly current: (input?: ProjectCurrentInput) => Promise } -type PathApi = { - readonly get: (input?: PathGetInput) => Promise -} +type McpApi = ServerApi["mcp"] +type PermissionApi = ServerApi["permission"] +type QuestionApi = ServerApi["question"] +type VcsApi = ServerApi["vcs"] export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => queryOptions({ @@ -143,7 +139,7 @@ export const loadProjectsQuery = (scope: ServerScope, api: ProjectApi) => export async function bootstrapGlobal(input: { serverSDK: OpencodeClient - serverAPI: CatalogApi & { readonly path: PathApi; readonly project: ProjectApi } + serverAPI: CatalogApi & { readonly project: ProjectApi } protocol?: Promise scope: ServerScope requestFailedTitle: string @@ -158,7 +154,7 @@ export async function bootstrapGlobal(input: { 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(loadPathQuery(input.scope, null, input.serverSDK, input.protocol)), () => input.queryClient .fetchQuery(loadProjectsQuery(input.scope, input.serverAPI.project)) @@ -289,17 +285,26 @@ export const loadCommands = ( agent: command.agent, model: providerID && id ? { providerID, id } : undefined, subtask: command.subtask, - source: command.source === "skill" ? undefined : command.source, + // 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) => +export const loadPathQuery = ( + scope: ServerScope, + directory: string | null, + sdk: OpencodeClient, + protocol?: Promise, +) => queryOptions({ queryKey: [scope, directory, "path"], - queryFn: () => retry(() => api.get(directory ? { location: { directory } } : undefined)), + queryFn: async () => { + if ((await protocol) !== "v1") + return { state: "", config: "", worktree: "", directory: directory ?? "", home: "" } + return retry(() => sdk.path.get({ directory: directory ?? undefined }).then((result) => result.data!)) + }, }) export const loadReferencesQuery = ( @@ -328,7 +333,6 @@ export async function bootstrapDirectory(input: { readonly agent: AgentListApi readonly command: CommandListApi readonly mcp: McpApi - readonly path: PathApi readonly permission: PermissionApi readonly project: ProjectApi readonly question: QuestionApi @@ -408,19 +412,20 @@ export async function bootstrapDirectory(input: { !seededPath && (() => input.queryClient - .ensureQueryData(loadPathQuery(input.scope, input.directory, input.api.path)) + .ensureQueryData(loadPathQuery(input.scope, input.directory, input.sdk, input.protocol)) .then((data) => { const next = projectID(data.directory ?? input.directory, input.global.project) if (next) input.setStore("project", next) })), () => - retry(() => - input.api.vcs.get({ location: { directory: input.directory } }).then((result) => { - const next = { branch: result.data.branch, default_branch: result.data.defaultBranch } + retry(async () => { + if ((await input.protocol) !== "v1") return + return input.sdk.vcs.get().then((result) => { + const next = { branch: result.data?.branch, default_branch: result.data?.default_branch } input.setStore("vcs", next) if (next) input.vcsCache.setStore("value", next) - }), - ), + }) + }), input.mcp && (() => loadCommands(input.directory, input.api.command, input.sdk, input.protocol).then((commands) => diff --git a/packages/app/src/context/global-sync/event-reducer.ts b/packages/app/src/context/global-sync/event-reducer.ts index 39ba22c59d51..8f203715a6de 100644 --- a/packages/app/src/context/global-sync/event-reducer.ts +++ b/packages/app/src/context/global-sync/event-reducer.ts @@ -211,19 +211,19 @@ export function applyDirectoryEvent(input: { })) 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.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 diff --git a/packages/app/src/context/layout.tsx b/packages/app/src/context/layout.tsx index d086582035ae..6235f35c45ae 100644 --- a/packages/app/src/context/layout.tsx +++ b/packages/app/src/context/layout.tsx @@ -570,16 +570,22 @@ export const { use: useLayout, provider: LayoutProvider } = createSimpleContext( continue } - void serverSdk() - .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) - }) + const projectID = project.id + void (async () => { + const sdk = serverSdk() + if ((await sdk.protocol) !== "v1") return + return sdk.client.project + .update({ projectID, directory: worktree, icon: { color } }) + .then((response) => response.data) + .then((result) => { + if (!result) return + 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/server-session-v2-reducer.test.ts b/packages/app/src/context/server-session-v2-reducer.test.ts index a1db63b1212b..00cc37cf5240 100644 --- a/packages/app/src/context/server-session-v2-reducer.test.ts +++ b/packages/app/src/context/server-session-v2-reducer.test.ts @@ -114,7 +114,7 @@ describe("v2 session reducer", () => { sessionID: "ses_1", assistantMessageID: "msg_assistant", callID: "call_1", - structured: {}, + metadata: {}, content: [{ type: "text", text: "done" }], executed: true, }, diff --git a/packages/app/src/context/server-session-v2-reducer.ts b/packages/app/src/context/server-session-v2-reducer.ts index 3b9719c09875..b34ab3985ec1 100644 --- a/packages/app/src/context/server-session-v2-reducer.ts +++ b/packages/app/src/context/server-session-v2-reducer.ts @@ -269,13 +269,18 @@ export function createV2SessionReducer() { ...tool, executed: event.data.executed, providerState: event.data.state, - state: { status: "running", input: event.data.input, structured: {}, content: [] }, + // structured: {}, content: [] + state: { status: "running", input: event.data.input, metadata: {} }, 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, + // state: { ...tool.state, structured: event.data.structured, content: event.data.content }, + state: { ...tool.state, metadata: event.data.metadata }, + } : tool, ) case "session.tool.success": @@ -288,9 +293,10 @@ export function createV2SessionReducer() { state: { status: "completed", input: tool.state.input, - structured: event.data.structured, + // structured: event.data.structured, + metadata: event.data.metadata, content: event.data.content, - result: event.data.result, + // result: event.data.result, }, time: { ...tool.time, completed: event.created }, } @@ -305,10 +311,11 @@ export function createV2SessionReducer() { 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 : [], + // structured: tool.state.status === "running" ? tool.state.structured : {}, + metadata: event.data.metadata ?? (tool.state.status === "running" ? tool.state.metadata : {}), + content: event.data.content, error: event.data.error, - result: event.data.result, + // result: event.data.result, }, time: { ...tool.time, completed: event.created }, } diff --git a/packages/app/src/context/server-session.test.ts b/packages/app/src/context/server-session.test.ts index 1e1046fb76fd..e9595e137607 100644 --- a/packages/app/src/context/server-session.test.ts +++ b/packages/app/src/context/server-session.test.ts @@ -1,8 +1,11 @@ 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 { OpenCodeEvent, SessionApi } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, Part, Session } from "@opencode-ai/sdk/v2/client" import { createServerSession } from "./server-session" +import type { ServerApi } from "@/utils/server" + +type MessageApi = ServerApi["message"] const session = (id: string, parentID?: string): Session => ({ id, diff --git a/packages/app/src/context/server-session.ts b/packages/app/src/context/server-session.ts index e8f91cda3f6f..69bec61ba2fc 100644 --- a/packages/app/src/context/server-session.ts +++ b/packages/app/src/context/server-session.ts @@ -1,6 +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 { OpenCodeEvent, SessionApi, SessionMessageInfo } from "@opencode-ai/client/promise" import type { Message, OpencodeClient, @@ -21,6 +21,9 @@ 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" +import type { ServerApi } from "@/utils/server" + +type MessageApi = ServerApi["message"] 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) @@ -954,10 +957,10 @@ export function createServerSession( }) 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.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" || diff --git a/packages/app/src/context/server-sync.test.ts b/packages/app/src/context/server-sync.test.ts index 9f625c94324a..7821a09e207f 100644 --- a/packages/app/src/context/server-sync.test.ts +++ b/packages/app/src/context/server-sync.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import type { OpencodeClient } from "@opencode-ai/sdk/v2/client" import type { - McpApi, McpListInput, McpResourceCatalogInput, SessionApi, @@ -14,6 +13,9 @@ import { estimateRootSessionTotal, loadRootSessions } from "./global-sync/sessio import { loadActiveSessionsQuery, loadMcpQuery, loadMcpResourcesQuery, seedActiveSessionStatuses } from "./server-sync" import { ServerScope } from "@/utils/server-scope" import { createServerSession } from "./server-session" +import type { ServerApi } from "@/utils/server" + +type McpApi = ServerApi["mcp"] describe("MCP queries", () => { test("loads current servers for the requested location", async () => { diff --git a/packages/app/src/context/server-sync.tsx b/packages/app/src/context/server-sync.tsx index 109a7bf7d65c..6fe6d4bd099f 100644 --- a/packages/app/src/context/server-sync.tsx +++ b/packages/app/src/context/server-sync.tsx @@ -188,7 +188,8 @@ function makeQueryOptionsApi( projects: () => loadProjectsQuery(scope, serverAPI.project), providers: (directory: PathKey | null) => loadProvidersQuery(scope, directory, serverAPI, directory ? sdkFor(directory) : serverSDK(), protocol), - path: (directory: PathKey | null) => loadPathQuery(scope, directory, serverAPI.path), + path: (directory: PathKey | null) => + loadPathQuery(scope, directory, directory ? sdkFor(directory) : serverSDK(), protocol), agents: (directory: PathKey) => loadAgentsQuery(scope, directory, serverAPI.agent, sdkFor(directory), protocol), references: (directory: PathKey) => loadReferencesQuery(scope, directory, serverAPI.reference, sdkFor(directory), protocol), @@ -581,7 +582,7 @@ export function createServerSyncContextInner(serverSDK: ServerSDK) { children.mark(key) if ( event.current?.type === "session.moved" || - event.current?.type === "session.archived" || + // event.current?.type === "session.archived" || event.current?.type === "session.forked" || eventType === "command.updated" || eventType === "config.updated" || diff --git a/packages/app/src/pages/home/home-sessions-controller.tsx b/packages/app/src/pages/home/home-sessions-controller.tsx index 06d86c30c9ed..a12e9800e9fe 100644 --- a/packages/app/src/pages/home/home-sessions-controller.tsx +++ b/packages/app/src/pages/home/home-sessions-controller.tsx @@ -211,10 +211,16 @@ export function createHomeSessionsController(home: HomeController) { const ctx = home.server.focusedContext() if (!conn || !ctx) return const [, setStore] = ctx.sync.child(session.directory) + if ((await ctx.sdk.protocol) !== "v1") return await archiveHomeSession({ server: ServerConnection.key(conn), session, - archive: (sessionID) => ctx.sdk.api.session.archive({ sessionID, directory: session.directory }), + archive: (sessionID) => + ctx.sdk.client.session.update({ + sessionID, + directory: session.directory, + time: { archived: Date.now() }, + }), remove: () => setStore( produce((draft) => { diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index 96ed022373a2..b348b44d8a87 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -872,12 +872,17 @@ export default function LegacyLayout(props: ParentProps) { } async function archiveSession(session: Session) { + if ((await serverSDK().protocol) !== "v1") return const [store, setStore] = serverSync().child(session.directory) const sessions = store.session ?? [] const index = sessions.findIndex((s) => s.id === session.id) const nextSession = sessions[index + 1] ?? sessions[index - 1] - await serverSDK().api.session.archive({ sessionID: session.id, directory: session.directory }) + await serverSDK().client.session.update({ + sessionID: session.id, + directory: session.directory, + time: { archived: Date.now() }, + }) setStore( produce((draft) => { const match = Binary.search(draft.session, session.id, (s) => s.id) @@ -1296,7 +1301,13 @@ export default function LegacyLayout(props: ParentProps) { const name = next === getFilename(project.worktree) ? "" : next if (project.id && project.id !== "global") { - const result = await serverSDK().api.project.update({ projectID: project.id, name }) + const sdk = serverSDK() + if ((await sdk.protocol) !== "v1") return + const result = await sdk.client.project + .update({ projectID: project.id, directory: project.worktree, name }) + .then((response) => response.data) + if (!result) return + // 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)), ) @@ -1479,15 +1490,20 @@ export default function LegacyLayout(props: ParentProps) { return } - await Promise.all( - sessions - .filter((session) => session.time.archived === undefined) - .map((session) => - serverSDK() - .api.session.archive({ sessionID: session.id, directory: session.directory }) - .catch(() => undefined), - ), - ) + if ((await serverSDK().protocol) === "v1") + await Promise.all( + sessions + .filter((session) => session.time.archived === undefined) + .map((session) => + serverSDK() + .client.session.update({ + sessionID: session.id, + directory: session.directory, + time: { archived: Date.now() }, + }) + .catch(() => undefined), + ), + ) setBusy(directory, false) dismiss() diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 48b432667e17..dc9153536cf2 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -812,13 +812,14 @@ export function MessageTimeline(props: { const archiveSession = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return + if ((await sdk().protocol) !== "v1") return const sessions = sync().data.session ?? [] const index = sessions.findIndex((s) => s.id === sessionID) const nextSession = index === -1 ? undefined : (sessions[index + 1] ?? sessions[index - 1]) await sdk() - .api.session.archive({ sessionID }) + .client.session.update({ sessionID, directory: sdk().directory, time: { archived: Date.now() } }) .then(() => { sync().set( produce((draft) => { diff --git a/packages/app/src/utils/server-compat.test.ts b/packages/app/src/utils/server-compat.test.ts index 605fdaace84f..52e5ec6e3bee 100644 --- a/packages/app/src/utils/server-compat.test.ts +++ b/packages/app/src/utils/server-compat.test.ts @@ -53,6 +53,7 @@ function setup( } 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" }) @@ -63,6 +64,7 @@ describe("createCompatibleApi", () => { 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") @@ -127,14 +129,6 @@ describe("createCompatibleApi", () => { ]) }) - 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("resolves protocol detection once across implementation methods", async () => { let detections = 0 const resolved = Promise.resolve<"v1" | "v2">("v2") @@ -147,12 +141,22 @@ describe("createCompatibleApi", () => { }) const { api } = setup(protocol) - await api.session.archive({ sessionID: "ses_1" }) + await api.session.list() await api.session.list() expect(detections).toBe(1) }) + /* + 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 }) @@ -160,6 +164,7 @@ 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" } }) @@ -167,6 +172,7 @@ describe("createCompatibleApi", () => { data: { branch: "feature", defaultBranch: "dev" }, }) }) + */ test("translates current file searches to the V1 dirs parameter", async () => { const { api, requests } = setup("v1") diff --git a/packages/app/src/utils/server-compat.ts b/packages/app/src/utils/server-compat.ts index 94374aa9f91f..1df1338b71e5 100644 --- a/packages/app/src/utils/server-compat.ts +++ b/packages/app/src/utils/server-compat.ts @@ -27,7 +27,7 @@ type CompatibleSessionApi = Omit< shell: (input: SessionShellInput & LegacyPrompt) => Promise compact: (input: SessionCompactInput & { model?: LegacyPrompt["model"] }) => Promise rename: (input: Parameters[0] & LegacyLocation) => ReturnType - archive: (input: Parameters[0] & LegacyLocation) => ReturnType + // archive: (input: Parameters[0] & LegacyLocation) => ReturnType remove: (input: Parameters[0] & LegacyLocation) => ReturnType } type CompatiblePermissionApi = Omit & { @@ -183,9 +183,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi { async rename(value: Parameters[0] & LegacyLocation) { await legacy(value).session.update({ sessionID: value.sessionID, title: value.title }) }, - async archive(value: Parameters[0] & LegacyLocation) { - await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) - }, + // async archive(value: Parameters[0] & LegacyLocation) { + // await legacy(value).session.update({ sessionID: value.sessionID, time: { archived: Date.now() } }) + // }, async remove(value: Parameters[0] & LegacyLocation) { await legacy(value).session.delete(value) }, @@ -308,34 +308,34 @@ function createV1Api(input: CompatibleInput): CompatibleApi { 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]) { - 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 update(value: Parameters[0]) { + // 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]) { const result = await legacy(value.location).worktree.list() return (result.data ?? []).map((item) => ({ directory: item })) }, }, - path: { - ...input.current.path, - async get(value?: Parameters[0]) { - const result = await legacy(value?.location).path.get() - if (!result.data) throw new Error("Path unavailable") - return result.data - }, - }, + // path: { + // ...input.current.path, + // async get(value?: Parameters[0]) { + // 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]) { - const result = await legacy(value?.location).vcs.get() - return located({ branch: result.data?.branch, defaultBranch: result.data?.default_branch }, value?.location) - }, + // async get(value?: Parameters[0]) { + // const result = await legacy(value?.location).vcs.get() + // 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() return located(result.data ?? [], value?.location) @@ -451,9 +451,9 @@ function createV1Api(input: CompatibleInput): CompatibleApi { }, pty: { ...input.current.pty, - async shells(value?: Parameters[0]) { - return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) - }, + // async shells(value?: Parameters[0]) { + // return located((await legacy(value?.location).pty.shells()).data ?? [], value?.location) + // }, async list(value?: Parameters[0]) { return located((await legacy(value?.location).pty.list()).data ?? [], value?.location) }, @@ -485,11 +485,11 @@ function createV1Api(input: CompatibleInput): CompatibleApi { async remove(value: Parameters[0]) { await legacy(value.location).pty.remove({ ptyID: value.ptyID }) }, - async connectToken(value: Parameters[0]) { - 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) - }, + // async connectToken(value: Parameters[0]) { + // 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, diff --git a/packages/app/src/utils/session-message.test.ts b/packages/app/src/utils/session-message.test.ts index d998c0c0459e..a69c414e1686 100644 --- a/packages/app/src/utils/session-message.test.ts +++ b/packages/app/src/utils/session-message.test.ts @@ -49,7 +49,7 @@ describe("normalizeSessionMessages", () => { state: { status: "completed", input: { filePath: "note.txt" }, - structured: { title: "note.txt" }, + metadata: { title: "note.txt" }, content: [{ type: "text", text: "hello" }], }, time: { created: 5, ran: 6, completed: 7 }, @@ -170,7 +170,7 @@ describe("normalizeSessionMessages", () => { status: "completed", input: { path: "/repo/README.md", oldString: "old", newString: "new" }, content: [{ type: "text", text: "Edited file successfully" }], - structured: { + metadata: { files: [ { file: "README.md", diff --git a/packages/app/src/utils/session-message.ts b/packages/app/src/utils/session-message.ts index c67c6c717c2a..93d86a66bb25 100644 --- a/packages/app/src/utils/session-message.ts +++ b/packages/app/src/utils/session-message.ts @@ -304,7 +304,8 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi return { status: "running" as const, input: normalizeToolInput(tool.name, tool.state.input), - metadata: normalizeToolMetadata(tool.name, tool.state.structured), + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), time: { start }, } } @@ -313,7 +314,8 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi status: "error" as const, input: normalizeToolInput(tool.name, tool.state.input), error: tool.state.error.message, - metadata: normalizeToolMetadata(tool.name, tool.state.structured), + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), time: { start, end: tool.time.completed ?? start }, } } @@ -337,7 +339,8 @@ function toolPart(sessionID: string, messageID: string, tool: SessionMessageAssi 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), + // metadata: normalizeToolMetadata(tool.name, tool.state.structured), + metadata: normalizeToolMetadata(tool.name, tool.state.metadata ?? {}), time: { start, end: tool.time.completed ?? start }, attachments: attachments.length ? attachments : undefined, } diff --git a/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz b/packages/app/vendor/opencode-ai-client-1.17.13-v2.tgz new file mode 100644 index 0000000000000000000000000000000000000000..bc1b664f82dcc55a95fc45b4c3b2c5b0cafaec55 GIT binary patch literal 67724 zcmYJaQl@s%1CNf6+tu< z(Ekh&z)kOu`?h!zCAYwN{lVf~Cqw6a8hAnq?IunAF{gVI&*b~2)ay?2ak3yODG>-T z0+6(l`AdKQ6$I2#u|O3LkeJkIZY*VEe|1+?7k~QR%~bb41KOwjul{n!=kz=P9xg7f zJUPiL>Mxr=KBvi>p$lUkK0fE%;o+bun7<>!PJax-Z&Rp$HR}_KrZi%9jqP3F_t!dR z@#3Dr@@8rOzgKy2?{uv;Sq1$bLyv}h0N3o%$bNisGqaj283VADSX`zDSQE2i{oom=E^bB0Y4(0J!&p{P*GH+9nW<$({7%=2v>pEBUR( zG_2n^>e35s=`@bnq?;QmWLWvm4uF5Ex<@4Dy|Z*!3U$?A0WHAeQ{W6k>e?1ROJ@>F0=1+j8xPb9pRx8#jMl zV{GT$hql8hIt%lx#MJ1S@w?p_?D#%lep)=&54>R#?DzkieZ74}e*igKUI09p`Tj2W zuR9_{{MsMy_s4>jd_^@I$USZ%2CI032Am{ zbw@#h-=S|CnNWlEE$QRJUf}oL$R%;4+RRTf{~HW@0D1PvGVR@A#a_UtUBEavuQtd5 z!QX&3FqpJ>7s*DVKO%BUs)yO&Sq z*dg_Upy%ds(=t|TUYhvZCdFj!f?lGCkIQ!F>j<_lh>iIB>w^7XAj;3ZQ^QZF`>Fp& z{bFYNrvRDJX{X}U>My~bF*L}}#a%T+)E!|aIwr?#y-*jTCJ^TRv@RB0v{-&j6{ZQF z232nU%4?Q??^{sfJ(|Pb{ufM{sqnG+(OQd)9~z_OL;8K6C;Jq@O&NFDYi$Mq`rG!# zApmF6hRIHy^Nks={(D^XlBMm`>1F-6ALx9+e$pT5e3J0p9_!C#4I}^FyJgOdk?30EDlQ)s)aG){FCP=#?S)k0Pt4;Jf?s30FH|vQtkwH z{Xg4Rzw$x*R%&dof8+JaCjo~XU4ZR(4PMd>9n^?{HwSAGc~Jfn?RYRhDu!47;h0vx z*HujL%L6??dlU2mq<}_zPq4c!mcc+p{r)g0iLrLlvM|*Q8s0aax}|PX@)fmyzksir zeVbu4AJzH{P69|r`4`{|&>`*DKR!>hg@8ebpr>q0IFSyKu$ zc2j2D{W`z-%Zu4B1QHZn)zsp1@e-U)zpH+~!m$e2CDpV#`bb%Tdlevmv^Krt`2vif1nV5QGF3#`CPj(gz!zbkN5_Qhg;$t~E z7Kz`DR^$hNUVuC~*0>$pK3>j^zm~3ni)HG@y+SxBf=a)eAO!|KN|}?7%sbcvRBy_F zXIjApf#v;pu6P#p5H�UTUBOqcJxiUa;W*CiDq1fKUtz+=S04NCKZWLZ^(UDdKMn zYV{;S1nf86{~{O|4f3o7yVn3S_n0| zP_PfA+Li+EA%4rCzRN%V%e6enR)_*_aPYK+QdmTyhl}KjpR)bt#!@I{q_@pXJK)pUi6ST2zz9wo2r;EAL!a=EjhT9HeE8_Oo?v zsr;g<~%B>h6Y zGPZhtRS-JBa3HSqdFO{hy~J@07pD|UP%Y|IG>WyS(*>o0d}Fuj_)KSO@hVKY!q|^* z{JL4{qcFCe{7s2tp$H0fz`mthnv$K;V1K>=hZ~g5#oS1j(~~ zXbt--Co9O(2r*lNxsZZ`;+!1+ZOv*(?lmBb7Ld6G3bFHEw_=!?6<}5cDwE4Gz%jsv zO}wN!L9iy5+K~GSvVA1G@`m8jqG9EZ>a9W3I@T-a9 zOb0hC=A)^8QP8&%@=69=G5$8NDm^kzPSDE+Y$*!R=@EfaA_^Y z#tWxKX>Hhb4i8Rjb;eYQ$TZYt^=1BHG!S%xWKxseHYVwV!4r%{WM84~{anmXlc+?8 zb^btpv3*LYY89uPz9yClA>@ekUD9b7w+Qact;bj+U#HpGXWuz`CMHF%9{s9^Z2h`s z%TzUJ*ChmcS`eVfxeFC)B1k!szH{j_iMK7Ym+aKsgN%J^zK7sH%XB3t;X1pEfLKyZ zxWfD28+(z|olD;t-E-oz`TJsoRgx2vKnum?I)Th#? zhNFyJut2TO5d-`X$U%iHkUS)gpx32!WkVwTK&DF{wiG#2bpoApD zfzGBDU@37#>>yWrK@wB#1-KG%Udl@A2pYcm0*_<)nmW8!gf_@=Q&@*RH!>eIOGjMTJa|gKY;h6KlrN9}9lY?AskQU;mN&nEy$v>eC#aJM=onScn?&Ebet#7067W*4{$7 zY668e%3H0emYz)P^f8e`Wxs}UPPESk<^jTxvMDvIsvW6%tPn>-*Vq<~yAo6sxf4m! z@!&~Cj{?VV+rpqi|C(q;rA}(}RG!--mArD5RiJ%IiPNmhinUH$GSHL~L)#*a!+{ox z_2)-Lm)}cI5#Ac)DQb<<8YdpgYQoTXh#B!N>h%Ssw9^k?l0OA8=|{C&*a=w7UVD~2 z=d&wVO^ZotPeLwHJxAlz(LGTN|0a}_GFDlXTh+wy4^J-LBx20Sz^N+~fG`k{qX7*JV3?E-J0Qkwh=9n_!64 zA;wc`RAoE#_zj)n8lV&;sVUw&dOx(dpqN2NuP1%l_%T_%e);XgA~=Q1PVsD=B98|G zqMBWtZEczEWQY~Ki4gMa+vkgs6ibvN=3m5#k%YQ;EvEkFTj#=D zBj>_aWKWze(r1pA$ulDGp<$G|gaLs=pNjeO$BM~SfhSGg4lIl(+5%);My3$Boy3)oT&wo4HVb}^BWCag3gN82Q zqGt6)7v9MM7>Gy`h)AwS<8n?mySAu?(^k{T84Li?w#?UEpUA#!9I;CV@9X(5?&)8d zWSmcBcahYplk|T43bx6q2s|l!y_`20oTCD6sYwz~y0z~JcXE$P3JeCe9z(urY(8gX zt)?ONF}&mCC-lx!SjNYB?K}sYAjk1E-op)Qp?qVWSyJE8b@G`*O~iW>aA!_&7RW<> z){m&T(i(Cm?KHjgi#9~?zN{36ZrT_bgKn2L$u;N zMRc+~*{>-c$P1o%4By0*PD`5pzxMpu;QlW(*32SEnog!uK_=O$jfjw=0^Q zcj&O!NJ+;B-95YhAMGA+xFn1YHA4eZ?raU2qsYspYWx-YL-hX{f)nvYeC zDuXu)xPcowxB+%#5Qx@YX({bn(|NcQs(#ftw7oU$e6Q%isSLV4{#$rDW#ruk7G1n| zk?T0$LYIk-wmUDBIgx1>NTY0NmkF3Y9!+X3Bp(Vd9BOKrmYStLqL(n;pr|j?%?1V!ONhZMXd!s&hxHE! zQrygo2ls87sV+S=v;>{S3JtcXekB(l_hLU48GWtpjKCl-Pzb04E$`yA*U~XPZb2d> z$?4`qablwR$)2gZeRFqN=h7fg&1{WP7v2X5 zI~e)tuRGf5ytV_bd+6~7M%=NyLkfR5a6;I<0i;oZvnm8Pq-{a9jUj#sr=k46fgBg` zvk{DpWch)bTrr%y{S<>^A#R}nmm-kMm9Vk02kOj^e`k}ta&f@y!HEMbt>CjQB)vAb zQleZ>;`fj25Kaqdz-5(NVz?O$V?sS-MXg72n>5vJqi6Y0M@I;*j_eJ2oP`A0m_krUg(Cq!U^=Zy>(&+5bC-^pFl~mHYM!0IN zOi>%1;=j+jw={ZS`aCyVJp#|;+Z{>wQ{(~<7@AF&#Cv3QS9(W9`(#UeL||$fpNfe^ zG0TWknqCrJ?DYsg&{BnVPR;*RLs$MUN~$pkuM#3!yx z(@B?)l3=g3SGV=tD~(swv2wx)S<|8KGomW6DRPE{0Wr3Y{x*M@3HOt*pza^<7n{bh z0$)s4S$tyr!eHH+>Dw?XKw~yZ9~Y3=m1E~(L+2lx$f71uSbWF)^W=(tt>N zp_Ku>o)VmdNJtChM*McjyMmn9u5?_!h31g4q;6L787V4A;sGt+X;cA2D0A5L3FkMEm(7b%!Ioe`g{GFm$z3j1c5~ss z+aboASzbl)gjP?Uddu)q_rnF@7AKFcv7aeuq^&YKS$O^o)w^{^>NE!tmal0o0>upl z#Z3*>^&^O6#FZsYus--L+ES9G@TLpT*6v<0Ny`WCk(udT7Qu0*`ZAVNw4d>EYkMV^ zx-EJkQYNl2cUg}&WzFGL{Y->EKT~y|)sBi@a(4EvDj>YsqQ`-67_Z4^9i}`f`6;8u z{L#foa{#|>F@a)C()HwaRn2kd^`hEQNtR{Kokt}M&!|=jTxQ7@;1&;6#=+F(i>ed* zuyQ6BznhN{M9zIebrdV0*85O?!O`dD&+WUra?vWl=5vb~L}VP(REiB=23ioH9Naz# z1j@IE_K=camG`LftfJq|(>rAG_qi$`yYFh0(xE~GcyU{PsecISLw(e9aR1^feX~>z z%YzdLLRwXFH9R?x*eXhqO_r@HY<5Kt9ieMIU-WihRA;532r`ng#p;%<1U1%7Nhj1x zWN<|xvP?K@;Fy09qAa+{#BpuhhK56-^dgzXn_(xs&nCmNstw0s#)S@ye6rG zDAaW3Dg?$g#)-#R#_0w;=7UpE?U>r2L=v-goHQHhD2ig}T-~QMaHIyFjimU;r!$Bc zi9LRL+GMcxd91=b&_t(Yps`Aua9?2=kpr+65keS+$tjT)ax&V3Rf5Ez1Z3v};iEqm zDI<6jjK3m2d7qo!R?s#j#IPsRj6v`<2Q?8<@)SpZ&6|&|mLk`{+nMjeX3?$2rKl-2 zvdj4q{^R1BHkWiTgNX*_JFo4d&7sIGA06$_J)R|U#8QD+se2IV!*~02aOCXD(5xFU z`TTOGSG==RXKQD-PR-%&O)%>&>t^i)t1F6Nr;!@GJ$imnSEE;N<%TFrO8z#$7?g{u zy9v~a^%^wG+FCj(7IiU~)oRf%H0wk1@Fnt$&hz`R$X3MAqHI#T4E>)o?qKW@qqWDr zkT9+*8P!?Gbz5dp#wIgwK)p5<*EgJG~ z(kT&1jew7I4VUv;w`q;5MI&mbtx$Fe18Ky}e~Q%DI@QSbU5M@z_srMb?op^y+oO2U z&zcu}%}oS~nfN`aY7*#tzfp+ABMdPdmxOY&2{DxN4}sfBFu_CRayuMnGd`FA)7nEi zW+cKXx?&_6mndi9p90f~OBCmarYELIt)7xPJ#toNV=qj$!~2inyQbuAL(;t2QbuoQ zK?`l7gDebmiIx;Utx8hrd^c5%JxzZTgC1r=2s@@{WJR^xJ~6Bzf^;Iqau*MprHVp2 z9RJqgWsX6fU{og>9zpnH%wGb5NzSR8;W7xG%*|XIwSM%QowP1ponq<~t2Wg-RY1n$ zi;Qque5_;{Bv=>;6I$j*8RGFc!D7LtXt@O*TGmEuwDl*U2@(;qWHT8u(P(=+7h!gDYF(gm2y_oTH1-UGM8O!L-GoXd~u``{nl<_-_olY!ss6!$oP z4RB9`NRSH8$-g>Q3e0UE9NrZhz)O8go~<((kg93yd?$N>z7v=u@x3eXSpby+A%UpL0?(pkDrroeQeojWTFQo&KzQQa|NYkA1WP%@p;?Q!5$ILh$llm!J@>YCE`jD|>aeg;`)M zBu9z8x8VKBITNg+(guIDt}ElVs%R%AG<>|3l7X{lhk)=`W?sXHH#I#d5ss>wc`y(J z`V|J?@Aa2*bc3M0fJ9lCG5E}->3d8E`~ZIMtpuF^D^vJCbqZ(wxH0A7ka5l)9*lqR zIFpBmFAc;Yw>bmQCnBL~OT*>X*xv#U`_FivFj?qX`QDOAArh?wWFP|praJ-s-UqUH zK>+q&@aP=Bc>Y=XpHPl2oLq_rAGv(oL z+y0ndR-avuFU>C;0$!My{3+}5>Vyw>YT7q^)>i&KYGxDmK5bxz%;odqAI$xA$AvyZ3YhzOSP@8b5F;;_coq`}vQTw|!TNfBws7 zKLQ!J3qSi;Kl5tJ^HDKG^Ci!QB8&l6YyG~BAjpA^$C&?cW6yIsz;Nu!mvrOx$V^_D~H z;^y58`)Z@VvqtbPBT&s6wk|#X>WL(sl4-h6(|_Y44DL5vdLih=bR{Nd`@fTYM#b5{ zDavTZwnbo9tyX81y39M-p2_9wlnu4j6bK60T5Kv}IZ<#_)`2Bn8p6SztGnuYl(uE2 z-jX&;>+JWW6Rr}~T5DD%GNPLK&TdcY=(~_l;^;%kmAXS*g_bmGeUxHDQjb6~YYab3IvNOrlHrvj}5u5w*w2 zV*X+gN@KNP@--T`?4s6j)lGI$oL%Q_)#Jc1Xe6mA$f?_=J@qigu~}}aCOT-hy@-6( zmU9TPcl&rNQ9bP9?wo+lDEnc$Njkb4EryM4#dX4xJ&>?0oRcd!zOg(0 zwf{Ctm&yA2fqDC0GM#6Lech?}(!SrD176*GYW&GVH@)029Um9x!SGmJ3m4>EO&ih) zmW2whWKDDUMIfe*V2Lh`k>rFVAHTre&{(s2R#(S{7|rQB-cqPB6I4KMesnYL3cJkX z7%`1Vj3uP21T7%}lH4O6)8s&3#n7hn8G-T?fu3_k=2+kK9+5o9BFbHczHCsoreR5} zsVC;4$T5TUH>Ki!YQsA0%zm~ZIjoIs)D?$B~Yni=6{bo#h-c>iM zKh}F!n$*z1mbj=sb23f?P~XPcYWdV_-XOG^Pr`UKPkPFJ_VSZ{3bZB_=}eZF;7lYa zJU}bco8j#YPPADTXHKvt2R()1y96QwsX_N2>UBp79eNjTvn7$5U}NRZv4Ed;H49ks zo6><+#tkv@DZb6&NY2?%O6LK!O-@yXy~c(%yh{Why&?R`2@Kv9L#Z$MQ~OSi)3eJST;S`9m;{`HiyuiSm#AXUJOFQCI17MNyw+Bg}AllxDQ zN~w6SGAm@6sL7M>wMw_dnQ>)CrC|{inW%ud|6>E8QJU?9wEn2ZwTV+|6g(e5b3oV| z)M)hi^NpKYZNJ&8F8?bCHKJ_|hnouuE7-NGa0J0RqoL%3dh7kvc_XV!643?GVx1b2 z)Q7$W8cPbvc;^RH&=aY-1oexCYVU6ogL0gwI8ox(POrqG4PuCKP-lkAvaU#WeY>b z8mdcc856|i3Ra1J+6Un`;TV+;@uKlx*%rjFZt7^W3z=tL8{*?&zMD%!L~qUwk>V*m zXF#kAIC}B%PCC$O1xG;xR=y%c5{XFy?h9B_F}|RaY0Ndl*TK9elQ+DHhm}tggSRmp zLpO6+x_FF`;HE*A^6T;MKvCK4_?RNs9rS*@_rROUQ~9J&JXC!Nsq(qzD9>p@c{4hK zsaHqbq~(Ag-8J8gFp6;ZYZL3Y*B_Sj%5? z7kpjG^Lj>uHF+o1x(_8PIPbXPim)Yay7p_U&an(8_Win~AFe2%x;4LXy*D2Az55ZL z{sbnW=2K|D>Et?{JS=<9zrl-EJwd!@f*&jj<`XU_r>7$_9KHnyB#RuCFdskk{?;j} z_V5f^zvcnh-HxGYxqnU2;PgmRxiRxfq6H^3-<|Z9R8)~UJyYo~sknb~iYk+wY_#DE zQBHdh(;7rEL?^`AJD^OWQ@dYj3?L1_QI5%R@bbm_n=r0vKUTEiP+Gec>30S&=Q6+l-oTz8Omjk|*F}`NlasR@>p( zmrD0#*$qU~pb+QDciH_IaXDY6=cz|`v5B>5d9qs1RY>K*TyaQwwM%hG!QC;U_$*V5 zl9gS8QN=L!bqY7<3|2U6o}8N9^!dOEYQxx|Iem$R}vxL=IeLIkR zI*}wgd~JqKjp0vi+Ww38Z%TX1bpRZeK!?uf_}0KqkcqUs1nF+SwfBo$u@+2;kBNRCBL;%;#Q;8$ns^V;Q6?#MLZ4Kuz@puBkA zQCwqnN3eSj{UaY*BmtTX`(vk@eir@asP~faDz+a9vzSAX^$FKCz9#KDlR=%ZtP5fc zbpjq%7CM*k6vV)3j}-E*R`cr(M@6PR=@qT0K_mZkd>yiHVJg@_9alhUW$>w8|3a<$ z@rz(hu6eEEiyopZ+cN#$Q+tz2sMb^}&qo^v&s~QWYPmizckIJO3WUEgce8ntNnQp) z^O4-m6rRWSIPay$Oc=Wk=DmS(iCN(OCM38R$0L(ttgozYPrQ`LBz4stmXhmuSe48t zNrYE0BM*6q=izuOcV%1*^HW&HdACi()sr3&>pFs^vh-~Mt6W|20xQwM4K~U7hFv}x z(1RZq&`AX`xIHYl#_K9)y13}xRe zYW%FS+C-A|3I8E>w?c=;+=Fx`1*aL+#n?$rqn&&d zjOn-K@&5SVa7U<#k@u+8tM9kvBI5(U-(%N3HU`yp{D>dWTp(GZ3q@6+Sy<3}f7-Ke zut+g|d$0AhWJEt548$LAAd0)tdLyv@3ibUfP$0=pg)wvPK5(OBGH)bw`2Of7x$16< z;$DJGs+$&C>m0xY4aBd4R{p!d5m~+Og}_@7rl3xf2Szx92k;=;6}msBkp7^dzTkAj zlLB(|K6w1CKs0ytLaUOhW51T^gTHud(hTynsWun2x*}4+u*kE5_9~b+a27}QhfD>T zHvY}JBV49o@wjh?`$xQM$vojYa8g{!Y31*WZIVZOy|t#_C+VDr+{IS;%wm^5zjE06 z5MIf5h<-{+iYIRpPu6J_W(XaRbrI3mYBWa_P?+A2d|bK6VPiKzjdj(a_i&{cGpE3+XQmZ_3$5C zT>s^bw9o*B1DsPogbN`{LS{2H7Th-uP9w9j91>$TVY z3xY)&^Iw>t$n{Y#*b_K2#m{N*>CBq7TUjW7le|IEs9>Bw!XN32@`WZO0^3lg*IDH! z_HS)vo_S+Iyc=Atnqhxb>^6n-gIuAnPrSC@4{S$XJ45~Z6Tyv7$Nw$g3O9BCq<^K& zsmf%nAUMz29e8DjjxWbyrrEKg9I>6>Yo%m{+@OP$(q(pZALg?Z~J=hG4Fe?!@XT9XvrkEQ)Ry)&QaD z6(=D>C#Cu@wgCn+)YtT^D)3OHUKrSzujN3(Nv-Z1~sOb&l-=ij#9f z2OarhgLH|sm(>RRuyzVhcf8OlTg=2K_5DDM$z4;vH5*)L=O~49*7?8-~2yIjx7B@rF@v9)T3Dhglw8|7i1k1Mm79K zd0?p^Pa-t7Q_udMab3)O4nR6oo^Y!q+p!>GAG@vO*u`a4u3eKuGZU=d&aL32kb$y> z$kqYp$WR?{{JV{3yiwS^*{ldm#s037=zHSr9G*WWYo0#PW=hN|(P5C0JGl)POYkKV z{>N>J9WEZI6OW9bmV{a9>SJMr8>IRec=~T0_z?nb{&Fe%#n$)z{-q-WGH87lYB}d# zt$)g6=b!po2!)sOt{rYiVlxVQZ_$l}K!~-c-VP3qBD**vG(;f8W&!nUTVi62&g&Rm zF7~L*4c_=?41a||)`U)3x?}%+gTiRR8hPa>kU0-GqVPU@*%0ADAsYp?N1>Lhg`3sh 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

    7Y+QD}T8_DVKLjGZ(7vq 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 literal 0 HcmV?d00001 diff --git a/packages/app/vendor/opencode-ai-client-1.17.13.tgz b/packages/app/vendor/opencode-ai-client-1.17.13.tgz deleted file mode 100644 index 5939f2cb39c27da205f1f37e9971009a669b83c2..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 75585 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 z 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

    $=Nes$vsW~pOCtUnEt6JQ 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 diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 158cb21e0ab8..1fba11705367 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -39,7 +39,7 @@ }, "dependencies": { "@kobalte/core": "catalog:", - "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13.tgz", + "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", "@opencode-ai/core": "workspace:*", "@opencode-ai/sdk": "workspace:*", "@opencode-ai/ui": "workspace:*", From 7534d23551f665e65080809975b4ca5c7d63807b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Sat, 25 Jul 2026 08:21:12 +0000 Subject: [PATCH 074/333] 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 407d7812fb22..db0259a67e75 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-0kcwV34P2C3yKg2eG9W2nW+OedrSBb+1TdpuUeYtauY=", - "aarch64-linux": "sha256-yHVygApQchAB34wrtFR4GU0CkmZOlLsl3wsp15u0xzs=", - "aarch64-darwin": "sha256-DyalcwyK2Wn5R6249keFcNVECbgtjYNjscOFqTi88FI=", - "x86_64-darwin": "sha256-BkGw0GWN9W9q+/g4FYR0MqxUuFP80BPoERO+ypz/arQ=" + "x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=", + "aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=", + "aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=", + "x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus=" } } From 7d8195e8fe32f750f4437b314c8f9abb34918f45 Mon Sep 17 00:00:00 2001 From: Rahul A Mistry <149420892+ProdigyRahul@users.noreply.github.com> Date: Mon, 27 Jul 2026 08:16:59 +0530 Subject: [PATCH 075/333] fix(ui): keep mutable selects open (#39027) Co-authored-by: Brendan Allan --- packages/ui/src/v2/components/select-v2.tsx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/ui/src/v2/components/select-v2.tsx b/packages/ui/src/v2/components/select-v2.tsx index 7f9a2b5d3761..57348ed39209 100644 --- a/packages/ui/src/v2/components/select-v2.tsx +++ b/packages/ui/src/v2/components/select-v2.tsx @@ -121,6 +121,7 @@ export function SelectV2(props: SelectV2Props) { {...others} multiple={false} + allowDuplicateSelectionEvents={false} disabled={local.disabled} data-component="select-v2-root" placement={local.placement ?? (inline() ? "bottom-end" : "bottom-start")} From 1ad63cfc6cd1bf19a47b155cd2d9b173014174c5 Mon Sep 17 00:00:00 2001 From: opencode Date: Mon, 27 Jul 2026 02:47:42 +0000 Subject: [PATCH 076/333] sync release versions for v1.18.6 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 839f28839445..4f969c2f1e9f 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.5", + "version": "1.18.6", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.5", + "version": "1.18.6", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.5", + "version": "1.18.6", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -841,7 +841,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +854,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +888,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +907,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +949,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1027,7 +1027,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 75b2b8c48809..d2219740b12d 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.5", + "version": "1.18.6", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 26a621028110..d8886470a091 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 15f3910e27e1..a57dad613028 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.5", + "version": "1.18.6", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 22e440d0d50a..0ccae42dbfc8 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index be70f690c402..4e7f7658510c 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 5909638d05d5..edb98fa95eae 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.5", + "version": "1.18.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index ca8f1dada9d5..26b7745a9ab9 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.5", + "version": "1.18.6", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 3fb6d2fdf278..ee4bd72911ee 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 7ecdd9263225..b297ac569aaf 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.5", + "version": "1.18.6", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 9820c107cdce..fc7d278bedfd 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index d29305faaed9..84c6cda13131 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.5", + "version": "1.18.6", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index e56d3ba4f2c2..fd915ebd3baf 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.5", + "version": "1.18.6", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 761f1c758978..f338cde8fc9b 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 535ee6faa63d..117632f768e6 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.5", + "version": "1.18.6", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index a39ac136422c..40e0dd80c7f3 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.5", + "version": "1.18.6", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 6afa905bb463..100aa8552681 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.5", + "version": "1.18.6", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 7960a72dfe2d..380bef3358b6 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.5", + "version": "1.18.6", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fdeab9b82b3a..ab795d5327c5 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 093439705af3..9859ba179855 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index d011dbb4735a..80504ced29d9 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 1fba11705367..2d4bc6c65d3b 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index e4fc453450b1..33e8109358fb 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index b14e49e2d883..d070a6a96e07 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index f621709185b9..09493362f924 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index e6795405832c..1d1607612a89 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index d450262d85f9..f86400885bcd 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.5", + "version": "1.18.6", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 971c3c89d0f1..3174e2113a91 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.5", + "version": "1.18.6", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 2918b28f3985..0ac92292d5fd 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.5", + "version": "1.18.6", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 4e89e1f08063..55684f7ab38c 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.5", + "version": "1.18.6", "publisher": "sst-dev", "repository": { "type": "git", From 759f6afa800d6c0a91b84b552ee292f0a15085b2 Mon Sep 17 00:00:00 2001 From: David Siewert Date: Mon, 27 Jul 2026 08:48:01 +0600 Subject: [PATCH 077/333] fix(app): add scroll to project selector dropdown (#39016) Co-authored-by: Brendan Allan --- .../components/prompt-project-selector.tsx | 68 ++++++++++--------- 1 file changed, 35 insertions(+), 33 deletions(-) diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index 1e5445517dbc..e47058f6a770 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -364,41 +364,43 @@ export function PromptProjectSelector(props: {

    - 1} - fallback={ - - - {(project) => ( - - )} - - - } - > - - props.controller.projects().some((project) => project.server?.key === server!.key), - )} +
    + 1} + fallback={ + + + {(project) => ( + + )} + + + } > - {(server) => ( -
    -
    - {server!.name} + + props.controller.projects().some((project) => project.server?.key === server!.key), + )} + > + {(server) => ( +
    +
    + {server!.name} +
    + + project.server?.key === server!.key)}> + {(project) => ( + + )} + +
    - - project.server?.key === server!.key)}> - {(project) => ( - - )} - - -
    - )} - - + )} + + +
    From 7ffc22c0ef6aba89fcf0e9de3a58e78a983c1dac Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Mon, 27 Jul 2026 02:49:22 +0000 Subject: [PATCH 078/333] chore: generate --- packages/app/src/components/prompt-project-selector.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index e47058f6a770..c11096596179 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -390,7 +390,9 @@ export function PromptProjectSelector(props: { {server!.name}
    - project.server?.key === server!.key)}> + project.server?.key === server!.key)} + > {(project) => ( )} From 9ca2b44235390b8e62fbe5c1a2ccceffbfd4bb9a Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Mon, 27 Jul 2026 11:05:21 +0800 Subject: [PATCH 079/333] Connect provider e2e test (#39039) --- .../user-story/model-selection-flow.spec.ts | 101 ++++++++++++++++++ packages/app/e2e/utils/mock-server.ts | 23 +++- .../components/dialog-connect-provider.tsx | 3 +- .../components/dialog-select-directory-v2.tsx | 1 + .../components/dialog-select-directory.tsx | 4 +- .../dialog-select-model-unpaid-v2.tsx | 3 +- .../app/src/components/prompt-input-v2.tsx | 2 + 7 files changed, 130 insertions(+), 7 deletions(-) create mode 100644 packages/app/e2e/user-story/model-selection-flow.spec.ts diff --git a/packages/app/e2e/user-story/model-selection-flow.spec.ts b/packages/app/e2e/user-story/model-selection-flow.spec.ts new file mode 100644 index 000000000000..2a220245e978 --- /dev/null +++ b/packages/app/e2e/user-story/model-selection-flow.spec.ts @@ -0,0 +1,101 @@ +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectAppVisible } from "../utils/waits" + +const directory = "C:/OpenCode/NewProject" + +test("creates a session in a new project, connects OpenCode Go, and selects its model", async ({ page }) => { + let connectedGo = false + let pendingGo = false + const connections: Array<{ integrationID: string; body: unknown }> = [] + + await mockOpenCodeServer(page, { + directory, + project: { + id: "proj_model_selection_flow", + worktree: directory, + vcs: "git", + name: "NewProject", + time: { created: 1_700_000_000_000, updated: 1_700_000_000_000 }, + sandboxes: [], + }, + provider: () => ({ + all: [ + { + id: "opencode", + name: "OpenCode", + models: { + "free-model": { + id: "free-model", + name: "Free Model", + cost: { input: 0, output: 0 }, + limit: { context: 200_000 }, + }, + }, + }, + { + id: "opencode-go", + name: "OpenCode Go", + models: { + "go-model-1": { + id: "go-model-1", + name: "Go Model 1", + cost: { input: 1, output: 1 }, + limit: { context: 200_000 }, + }, + }, + }, + ], + connected: connectedGo ? ["opencode", "opencode-go"] : ["opencode"], + default: { providerID: "opencode", modelID: "free-model" }, + }), + integrationMethods: { "opencode-go": [{ type: "api", label: "API key" }] }, + onConnectKey: (input) => { + connections.push(input) + if (input.integrationID === "opencode-go") pendingGo = true + }, + onInstanceDispose: () => { + if (pendingGo) connectedGo = true + }, + sessions: [], + pageMessages: () => ({ items: [] }), + fileList: (path) => + path + ? [] + : [{ name: "NewProject", path: "NewProject", absolute: directory, type: "directory", ignored: false }], + findFiles: () => ["NewProject"], + }) + await page.addInitScript(() => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem("opencode.global.dat:server", JSON.stringify({ projects: { local: [] } })) + }) + + await page.goto("/") + const addProject = page.locator('[data-action="home-add-project-row"]') + await expectAppVisible(addProject) + await addProject.click() + await page.locator("[data-directory-path]").click() + + await page.locator('[data-action="home-new-session"]').click() + await expectAppVisible(page.locator('[data-component="prompt-input-v2"]')) + + const modelControl = page.locator('[data-action="prompt-model"]') + await modelControl.click() + await expect(page.locator('[data-section="free-models"]')).toContainText("Free models provided by OpenCode") + + await page.locator('[data-provider-id="opencode-go"]').click() + await page.locator('[data-input="provider-api-key"]').fill("mock-go-api-key") + await page.locator('[data-action="provider-connect-submit"]').click() + await expect(page.locator('[data-component="dialog-v2"]')).toHaveCount(0) + expect(connections).toEqual([ + { integrationID: "opencode-go", body: { type: "api", key: "mock-go-api-key" } }, + ]) + + await expect(modelControl).toHaveAttribute("data-control-type", "popover") + await modelControl.click() + const goModel = page.locator('[data-option-key="opencode-go:go-model-1"]') + await expect(goModel).toBeVisible() + await goModel.click() + + await expect(modelControl).toContainText("Go Model 1") +}) diff --git a/packages/app/e2e/utils/mock-server.ts b/packages/app/e2e/utils/mock-server.ts index 0e7dfc087cc5..76987421b607 100644 --- a/packages/app/e2e/utils/mock-server.ts +++ b/packages/app/e2e/utils/mock-server.ts @@ -5,7 +5,10 @@ const emptyObject = new Set(["/global/config", "/config", "/provider/auth", "/mc export interface MockServerConfig { protocol?: "v1" | "v2" - provider: unknown + provider: unknown | (() => unknown) + integrationMethods?: Record + onConnectKey?: (input: { integrationID: string; body: unknown }) => void + onInstanceDispose?: () => void directory: string project: unknown sessions: ({ id: string } & Record)[] @@ -31,7 +34,6 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { const cursors = new Map() let nextCursor = 0 const staticRoutes: Record = { - "/provider": config.provider, "/path": { state: config.directory, config: config.directory, @@ -75,6 +77,18 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { 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 === "/provider") + return json(route, typeof config.provider === "function" ? config.provider() : config.provider) + if (path === "/provider/auth") return json(route, config.integrationMethods ?? {}) + const legacyAuth = path.match(/^\/auth\/([^/]+)$/)?.[1] + if (legacyAuth && route.request().method() === "PUT") { + config.onConnectKey?.({ integrationID: legacyAuth, body: route.request().postDataJSON() }) + return json(route, true) + } + if (path === "/instance/dispose" && route.request().method() === "POST") { + config.onInstanceDispose?.() + return json(route, true) + } if (path === "/permission") return json(route, typeof config.permissions === "function" ? config.permissions() : (config.permissions ?? [])) if (path === "/question") @@ -130,8 +144,11 @@ export async function mockOpenCodeServer(page: Page, config: MockServerConfig) { 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") + const integrationConnect = path.match(/^\/api\/integration\/([^/]+)\/connect\/key$/)?.[1] + if (integrationConnect && route.request().method() === "POST") { + config.onConnectKey?.({ integrationID: integrationConnect, body: route.request().postDataJSON() }) 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 }) diff --git a/packages/app/src/components/dialog-connect-provider.tsx b/packages/app/src/components/dialog-connect-provider.tsx index 744bc7922788..19aa8ce89e75 100644 --- a/packages/app/src/components/dialog-connect-provider.tsx +++ b/packages/app/src/components/dialog-connect-provider.tsx @@ -857,6 +857,7 @@ function ProviderConnection(props: { ref={apiKey} class="!w-full" name="apiKey" + data-input="provider-api-key" placeholder={language.t("provider.connect.apiKey.placeholder")} value={formStore.value} invalid={formStore.error !== undefined} @@ -873,7 +874,7 @@ function ProviderConnection(props: {
    )}
    - + {language.t("common.continue")} diff --git a/packages/app/src/components/dialog-select-directory-v2.tsx b/packages/app/src/components/dialog-select-directory-v2.tsx index 847239d098b5..f3376ad35f26 100644 --- a/packages/app/src/components/dialog-select-directory-v2.tsx +++ b/packages/app/src/components/dialog-select-directory-v2.tsx @@ -329,6 +329,7 @@ export function DialogSelectDirectoryV2(props: DialogSelectDirectoryV2Props) { {(suggestion, index) => ( + )} onClose={restoreFocus} - > - - - - - {props.controls.model.selection.current()?.name ?? - language.t("dialog.model.select.title")} - - - + />
    diff --git a/packages/app/src/components/prompt-project-selector.tsx b/packages/app/src/components/prompt-project-selector.tsx index c11096596179..b84afdb56bf0 100644 --- a/packages/app/src/components/prompt-project-selector.tsx +++ b/packages/app/src/components/prompt-project-selector.tsx @@ -18,6 +18,7 @@ import { useLanguage } from "@/context/language" import { displayName, getProjectAvatarSource } from "@/pages/layout/helpers" import { pathKey } from "@/utils/path-key" import { handleDocumentSearchKeydown } from "@/utils/search-keydown" +import { createMenuDismissController } from "@/utils/menu-dismiss-controller" export type PromptProject = { name?: string @@ -197,8 +198,8 @@ export function PromptProjectSelector(props: { }) { const [triggerReady, setTriggerReady] = createSignal(false) let contentRef: HTMLDivElement | undefined + const dismiss = createMenuDismissController(() => contentRef) let triggerFrame: number | undefined - let restoreTrigger = true // Floating UI requires a connected anchor; route transitions can construct this trigger before adoption. const setTriggerRef = (element: HTMLButtonElement) => { @@ -221,25 +222,15 @@ export function PromptProjectSelector(props: { props.controller.active() ? contentRef?.querySelector(`[data-option-key="${CSS.escape(props.controller.active())}"]`) : undefined - const afterClose = (callback: () => void) => { - const complete = () => { - if (contentRef?.isConnected) { - requestAnimationFrame(complete) - return - } - requestAnimationFrame(() => requestAnimationFrame(callback)) - } - requestAnimationFrame(complete) - } const selectProject = (project: PromptProject) => { - restoreTrigger = false + dismiss.preventTriggerRestore() props.controller.setOpen(false) - afterClose(() => props.controller.select(project)) + dismiss.afterClose(() => props.controller.select(project)) } const selectAction = (server?: string) => { - restoreTrigger = false + dismiss.preventTriggerRestore() props.controller.setOpen(false) - afterClose(() => props.controller.add(server)) + dismiss.afterClose(() => props.controller.add(server)) } const selectActive = () => { const project = props.controller.activeProject() @@ -267,7 +258,7 @@ export function PromptProjectSelector(props: { ) .filter((element) => !contentRef?.contains(element) && !element.hasAttribute("data-focus-trap")) .findLast((element) => element.offsetParent !== null) - restoreTrigger = false + dismiss.preventTriggerRestore() target?.focus() queueMicrotask(() => { if (props.controller.open()) props.controller.setOpen(false) @@ -291,7 +282,10 @@ export function PromptProjectSelector(props: { placement={props.placement ?? "bottom"} gutter={4} modal={false} - onOpenChange={(open) => props.controller.setOpen(open)} + onOpenChange={(open) => { + if (open) dismiss.allowTriggerRestore() + props.controller.setOpen(open) + }} > @@ -300,11 +294,9 @@ export function PromptProjectSelector(props: { id="prompt-project-menu" class="w-[243px] overflow-hidden rounded-md border-0 bg-v2-background-bg-layer-01 p-0 shadow-[var(--v2-elevation-floating)] focus:outline-none [&[data-closed]]:!animate-none" onOpenAutoFocus={(event) => event.preventDefault()} - onPointerDownOutside={() => (restoreTrigger = false)} - onFocusOutside={() => (restoreTrigger = false)} - onCloseAutoFocus={(event) => { - if (!restoreTrigger) event.preventDefault() - }} + onPointerDownOutside={dismiss.preventTriggerRestore} + onFocusOutside={dismiss.preventTriggerRestore} + onCloseAutoFocus={dismiss.onCloseAutoFocus} >
    diff --git a/packages/app/src/utils/menu-dismiss-controller.ts b/packages/app/src/utils/menu-dismiss-controller.ts new file mode 100644 index 000000000000..0a3009eb716f --- /dev/null +++ b/packages/app/src/utils/menu-dismiss-controller.ts @@ -0,0 +1,30 @@ +/** Coordinates focus restoration and actions that must run after menu content unmounts. */ +export function createMenuDismissController(content: () => HTMLElement | undefined) { + let restoreTrigger = true + + return { + /** Allows the menu primitive to restore focus to its trigger when closing. */ + allowTriggerRestore() { + restoreTrigger = true + }, + /** Keeps focus at its current or next destination instead of returning it to the trigger. */ + preventTriggerRestore() { + restoreTrigger = false + }, + /** Applies the current restoration policy during the menu primitive's close-focus event. */ + onCloseAutoFocus(event: Event) { + if (!restoreTrigger) event.preventDefault() + }, + /** Runs an action after the menu unmounts and its focus-close work has settled. */ + afterClose(callback: () => void) { + const complete = () => { + if (content()?.isConnected) { + requestAnimationFrame(complete) + return + } + requestAnimationFrame(() => requestAnimationFrame(callback)) + } + requestAnimationFrame(complete) + }, + } +} diff --git a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx index 0c7ff8bbdd65..0741f7e4ef9e 100644 --- a/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx +++ b/packages/storybook/.storybook/mocks/app/components/dialog-select-model.tsx @@ -1,9 +1,8 @@ -import { splitProps } from "solid-js" +import { splitProps, type JSX } from "solid-js" -export function ModelSelectorPopover(props: { triggerAs: any; triggerProps?: Record; children: any }) { - const [local] = splitProps(props, ["triggerAs", "triggerProps", "children"]) - const Trigger = local.triggerAs - return {local.children} +export function ModelSelectorPopover(props: { trigger: (props: Record) => JSX.Element }) { + const [local] = splitProps(props, ["trigger"]) + return <>{local.trigger({})} } export const ModelSelectorPopoverV2 = ModelSelectorPopover From 3cc70160deb0eda7f67fbf5b0c0780000f5c342d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 01:43:55 +0000 Subject: [PATCH 104/333] chore: generate --- packages/app/src/components/dialog-select-model.tsx | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/packages/app/src/components/dialog-select-model.tsx b/packages/app/src/components/dialog-select-model.tsx index e323c6f99cca..9066f72434e5 100644 --- a/packages/app/src/components/dialog-select-model.tsx +++ b/packages/app/src/components/dialog-select-model.tsx @@ -1,13 +1,5 @@ import { Popover as Kobalte } from "@kobalte/core/popover" -import { - Component, - ComponentProps, - createEffect, - createMemo, - For, - JSX, - Show, -} from "solid-js" +import { Component, ComponentProps, createEffect, createMemo, For, JSX, Show } from "solid-js" import { createStore } from "solid-js/store" import { useLocal } from "@/context/local" import { useDialog } from "@opencode-ai/ui/context/dialog" From 3f9dad3fd0d4ce01ccb443896bce93d9e7f390eb Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 01:57:02 +0000 Subject: [PATCH 105/333] 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 db0259a67e75..06650c6488ec 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-2suqkFAUeT2YgppKqP14s0C+GE3TR3uNKOOD8vWEiAg=", - "aarch64-linux": "sha256-cJ/XYTodYmgs9ZFQhHQbfZPqxjIMmEvz062OsX6/6p4=", - "aarch64-darwin": "sha256-tKyqHeI6r1aHEk4vBS39+eA6wh9KcIwsa2ctcktKkEc=", - "x86_64-darwin": "sha256-1u7+dNjRcqKSvUTCEarVJ9fMrRUyg9y+4seZAm6Fmus=" + "x86_64-linux": "sha256-szhy0258K6IMWi3WRrAzRfhEEIDkGTS04KQwQw+DXyI=", + "aarch64-linux": "sha256-eVqUhXGKgx7rmRzpdr9G59Xrxvj2J0Dhx4Q7QRBHKiQ=", + "aarch64-darwin": "sha256-r/xAkD/yktfxOVTAArWYb0ZMWe/hEqh80dkYsigRkm8=", + "x86_64-darwin": "sha256-xEKSQZM4gYoz9RoaHEvuxrT8AFXHx8hMD1oKGi8lc3Y=" } } From 237e694df02830a586db9846c39e3f2a6e0d3c3f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 03:56:40 +0000 Subject: [PATCH 106/333] fix(app): expand Windows file tree folders (#39249) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .../open-file-expand-folder.spec.ts | 132 ++++++++++++++++++ packages/app/src/context/file/path.test.ts | 23 +++ packages/app/src/context/file/path.ts | 7 +- 3 files changed, 161 insertions(+), 1 deletion(-) create mode 100644 packages/app/e2e/regression/open-file-expand-folder.spec.ts diff --git a/packages/app/e2e/regression/open-file-expand-folder.spec.ts b/packages/app/e2e/regression/open-file-expand-folder.spec.ts new file mode 100644 index 000000000000..37739d2fbbc8 --- /dev/null +++ b/packages/app/e2e/regression/open-file-expand-folder.spec.ts @@ -0,0 +1,132 @@ +import { base64Encode } from "@opencode-ai/core/util/encode" +import { expect, test } from "@playwright/test" +import { mockOpenCodeServer } from "../utils/mock-server" +import { expectSessionTitle } from "../utils/waits" + +const directory = "C:/OpenCode/OpenFileExpand" +const projectID = "proj_open_file_expand" +const sessionID = "ses_open_file_expand" +const title = "Open file expand" +const server = `http://${process.env.PLAYWRIGHT_SERVER_HOST ?? "127.0.0.1"}:${process.env.PLAYWRIGHT_SERVER_PORT ?? "4096"}` + +test.use({ viewport: { width: 1440, height: 900 } }) + +test("expands a folder whose path has a trailing Windows separator", async ({ page }) => { + await mockOpenCodeServer(page, { + directory, + project: { + id: projectID, + worktree: directory, + vcs: "git", + name: "open-file-expand", + time: { created: 1700000000000, updated: 1700000000000 }, + sandboxes: [], + }, + provider: { + all: [ + { + id: "opencode", + name: "OpenCode", + models: { test: { id: "test", name: "Test", limit: { context: 200_000 } } }, + }, + ], + connected: ["opencode"], + default: { providerID: "opencode", modelID: "test" }, + }, + sessions: [ + { + id: sessionID, + slug: sessionID, + projectID, + directory, + title, + version: "dev", + time: { created: 1700000000000, updated: 1700000000000 }, + }, + ], + vcsDiff: [], + fileList: (path) => { + if (path === "frontend\\" || path === "frontend") { + return [ + { + name: "app.ts", + path: "frontend\\app.ts", + absolute: `${directory}/frontend/app.ts`, + type: "file" as const, + ignored: false, + }, + ] + } + if (path) return [] + return [ + { + name: "frontend", + path: "frontend\\", + absolute: `${directory}/frontend`, + type: "directory" as const, + ignored: false, + }, + { + name: "README.md", + path: "README.md", + absolute: `${directory}/README.md`, + type: "file" as const, + ignored: false, + }, + ] + }, + fileContent: (path) => ({ type: "text", content: `contents:${path}` }), + pageMessages: () => ({ items: [] }), + }) + + await page.addInitScript( + ({ directory, server, sessionID }) => { + localStorage.setItem( + "settings.v3", + JSON.stringify({ general: { newLayoutDesigns: true, shouldDisplayTabsToast: false } }), + ) + localStorage.setItem( + "opencode.global.dat:server", + JSON.stringify({ + projects: { local: [{ worktree: directory, expanded: true }] }, + lastProject: { local: directory }, + }), + ) + localStorage.setItem( + "opencode.global.dat:layout", + JSON.stringify({ review: { diffStyle: "split", panelOpened: true } }), + ) + localStorage.setItem( + "opencode.global.dat:review-panel-v2", + JSON.stringify({ sidebarOpened: true, sidebarWidth: 240, expandMode: "collapse" }), + ) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([{ type: "session", server, sessionId: sessionID }]), + ) + }, + { directory, server, sessionID }, + ) + + await page.goto(`/server/${base64Encode(server)}/session/${sessionID}`) + await expectSessionTitle(page, title) + + const panel = page.locator("#review-panel") + await panel.getByRole("button", { name: "Open file" }).click() + await expect(panel.getByRole("tab", { name: "Open file" })).toHaveAttribute("data-selected", "") + + const sidebar = panel.locator('[data-component="session-review-v2-sidebar-root"]') + await expect(sidebar).toBeVisible() + + const frontendRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend"]') + await expect(frontendRow).toBeVisible() + await expect(frontendRow).toHaveAttribute("aria-expanded", "false") + await frontendRow.click() + await expect(frontendRow).toHaveAttribute("aria-expanded", "true") + + const appRow = panel.locator('[data-slot="file-tree-v2-row"][data-path="frontend/app.ts"]') + await expect(appRow).toBeVisible() + await appRow.click() + await expect(panel.getByRole("tab", { name: "app.ts" })).toHaveAttribute("data-selected", "") + await expect(panel.getByText("contents:frontend/app.ts", { exact: true })).toBeVisible() +}) diff --git a/packages/app/src/context/file/path.test.ts b/packages/app/src/context/file/path.test.ts index feef6d466ef4..99dd88ae0e9c 100644 --- a/packages/app/src/context/file/path.test.ts +++ b/packages/app/src/context/file/path.test.ts @@ -21,6 +21,29 @@ describe("file path helpers", () => { expect(path.normalize("c:\\repo\\src\\app.ts")).toBe("src\\app.ts") }) + test("normalizes Windows directory separators", () => { + const path = createPathHelpers(() => "C:\\repo") + expect(path.normalizeDir("frontend\\")).toBe("frontend") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + expect(path.normalizeDir("C:\\repo\\frontend\\")).toBe("frontend") + }) + + test("normalizes separators for Windows roots written with forward slashes", () => { + const path = createPathHelpers(() => "C:/repo") + expect(path.normalizeDir("frontend\\src\\")).toBe("frontend/src") + }) + + test("normalizes separators for Windows UNC roots", () => { + const path = createPathHelpers(() => "\\\\server\\share") + expect(path.normalizeDir("\\\\server\\share\\frontend\\")).toBe("frontend") + }) + + test("preserves backslashes in POSIX directory names", () => { + const path = createPathHelpers(() => "/repo") + expect(path.normalizeDir("literal\\name\\")).toBe("literal\\name\\") + expect(path.normalizeDir("literal\\name/")).toBe("literal\\name") + }) + test("keeps query/hash stripping behavior stable", () => { expect(stripQueryAndHash("a/b.ts#L12?x=1")).toBe("a/b.ts") expect(stripQueryAndHash("a/b.ts?x=1#L12")).toBe("a/b.ts") diff --git a/packages/app/src/context/file/path.ts b/packages/app/src/context/file/path.ts index 53f072b6cb26..2bc4bde5e9b4 100644 --- a/packages/app/src/context/file/path.ts +++ b/packages/app/src/context/file/path.ts @@ -140,7 +140,12 @@ export function createPathHelpers(scope: () => string) { return normalize(tabValue) } - const normalizeDir = (input: string) => normalize(input).replace(/\/+$/, "") + const normalizeDir = (input: string) => { + const path = normalize(input) + const root = scope() + const windows = /^[A-Za-z]:/.test(root) || root.startsWith("\\\\") + return (windows ? path.replace(/\\/g, "/") : path).replace(/\/+$/, "") + } return { normalize, From 87db7a7f0af47035c8b533f7da41c2999415dc0b Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:59:30 +0800 Subject: [PATCH 107/333] fix(app): follow visual tab order (#39241) --- .../regression/tab-navigate-mousedown.spec.ts | 34 +++- .../app/src/components/titlebar-tab-nav.tsx | 150 +++++++++--------- .../src/components/titlebar-tab-order.test.ts | 23 +++ .../app/src/components/titlebar-tab-order.ts | 12 ++ .../app/src/components/titlebar-tab-strip.tsx | 139 ++++++++++++---- packages/app/src/components/titlebar.tsx | 34 ---- 6 files changed, 248 insertions(+), 144 deletions(-) create mode 100644 packages/app/src/components/titlebar-tab-order.test.ts create mode 100644 packages/app/src/components/titlebar-tab-order.ts diff --git a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts index ae61b2acbfdc..b969b590d89b 100644 --- a/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts +++ b/packages/app/e2e/regression/tab-navigate-mousedown.spec.ts @@ -5,6 +5,8 @@ import { currentSession } from "../utils/mock-server" const server = "http://127.0.0.1:4096" const sessionA = session("ses_tab_a", "Tab A session") const sessionB = session("ses_tab_b", "Tab B session") +const sessionC = session("ses_tab_c", "Tab C session") +const unresolvedSessionID = "ses_tab_unresolved" test("pressing mouse down on a tab navigates before mouse up", async ({ page }) => { await mockServer(page) @@ -40,6 +42,34 @@ test("pressing mouse down on a tab navigates before mouse up", async ({ page }) await expect(page).toHaveURL(new RegExp(`${hrefB.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) }) +test("keyboard navigation follows the visible tab order", async ({ page }) => { + await mockServer(page) + await page.addInitScript( + ({ server, sessionA, unresolved, sessionC }) => { + localStorage.setItem("settings.v3", JSON.stringify({ general: { newLayoutDesigns: true } })) + localStorage.setItem( + "opencode.window.browser.dat:tabs", + JSON.stringify([ + { type: "session", server, sessionId: sessionA }, + { type: "session", server, sessionId: unresolved }, + { type: "session", server, sessionId: sessionC }, + ]), + ) + }, + { server, sessionA: sessionA.id, unresolved: unresolvedSessionID, sessionC: sessionC.id }, + ) + + const hrefA = `/server/${base64Encode(server)}/session/${sessionA.id}` + const hrefC = `/server/${base64Encode(server)}/session/${sessionC.id}` + await page.goto(hrefA) + await expect(page.locator("[data-titlebar-tab-slot]:visible")).toHaveCount(2) + await expect(page.locator(`[data-titlebar-tab-slot]:has(a[href="${hrefC}"])`)).toBeVisible() + + await page.keyboard.press("Control+Alt+ArrowRight") + + await expect(page).toHaveURL(new RegExp(`${hrefC.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}$`)) +}) + function session(id: string, title: string) { return { id, @@ -53,10 +83,12 @@ function session(id: string, title: string) { } async function mockServer(page: Page) { - const sessions = [sessionA, sessionB] + const sessions = [sessionA, sessionB, sessionC] await page.route("**/*", async (route) => { const url = new URL(route.request().url()) if (url.origin !== server) return route.fallback() + if ([`/api/session/${unresolvedSessionID}`, `/session/${unresolvedSessionID}`].includes(url.pathname)) + return new Promise(() => {}) 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 }) diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index ceb43d4486f1..35f600c9f608 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -195,88 +195,86 @@ export function TabNavItem(props: { closeTab(event) }} > - - { - event.preventDefault() + { + event.preventDefault() + event.stopPropagation() + }} + onMouseDown={(event) => { + // Navigate on mousedown to shave the press-release delay off tab switches. + if (event.button !== 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + onClick={(event) => { + event.preventDefault() + // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. + if (event.detail > 0) return + if (editing()) return + if (props.suppressNavigation?.()) return + props.onNavigate() + }} + class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" + > + + + + { + titleEl = el + titleEl.textContent = title() ?? "" + }} + data-slot="tab-title" + data-titlebar-tab-title + class="min-w-0 flex-1 outline-none leading-4" + classList={{ + "overflow-hidden text-clip whitespace-nowrap": !editing(), + "select-text": editing(), + }} + contenteditable={editing() ? true : undefined} + onDblClick={openRename} + onKeyDown={(event) => { event.stopPropagation() + if (event.key === "Enter") { + event.preventDefault() + void closeRename(true) + return + } + if (event.key !== "Escape") return + event.preventDefault() + titleEl.textContent = props.session()?.title ?? "" + void closeRename(false) }} - onMouseDown={(event) => { - // Navigate on mousedown to shave the press-release delay off tab switches. - if (event.button !== 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() + onBlur={() => void closeRename(true)} + onPointerDown={(event) => { + if (!editing()) return + event.stopPropagation() }} onClick={(event) => { + if (!editing()) return event.preventDefault() - // Mouse navigation already happened on mousedown; detail 0 means keyboard activation. - if (event.detail > 0) return - if (editing()) return - if (props.suppressNavigation?.()) return - props.onNavigate() }} - class="flex h-full min-w-0 flex-1 flex-row items-center gap-1.5 text-[13px] font-medium text-v2-text-text-faint group-data-[active='true']:text-v2-text-text-base group-data-[editing='true']:text-v2-text-text-base [-webkit-user-drag:none]" - > - - - - { - titleEl = el - titleEl.textContent = title() ?? "" - }} - data-slot="tab-title" - data-titlebar-tab-title - class="min-w-0 flex-1 outline-none leading-4" - classList={{ - "overflow-hidden text-clip whitespace-nowrap": !editing(), - "select-text": editing(), - }} - contenteditable={editing() ? true : undefined} - onDblClick={openRename} - onKeyDown={(event) => { - event.stopPropagation() - if (event.key === "Enter") { - event.preventDefault() - void closeRename(true) - return - } - if (event.key !== "Escape") return - event.preventDefault() - titleEl.textContent = props.session()?.title ?? "" - void closeRename(false) - }} - onBlur={() => void closeRename(true)} - onPointerDown={(event) => { - if (!editing()) return - event.stopPropagation() - }} - onClick={(event) => { - if (!editing()) return - event.preventDefault() - }} - /> - - + /> +
    { + test("follows the visible left-to-right order", () => { + expect(adjacentTabKey(["c", "a", "b"], "c", 1)).toBe("a") + expect(adjacentTabKey(["c", "a", "b"], "a", -1)).toBe("c") + }) + + test("skips tabs omitted from the visible order", () => { + expect(adjacentTabKey(["a", "c"], "a", 1)).toBe("c") + expect(adjacentTabKey(["a", "c"], "c", 1)).toBe("a") + }) +}) + +test("merges reordered visible tabs around hidden tabs", () => { + expect(mergeVisibleTabOrder(["a", "hidden", "b", "c"], ["a", "b", "c"], ["c", "a", "b"])).toEqual([ + "c", + "hidden", + "a", + "b", + ]) +}) diff --git a/packages/app/src/components/titlebar-tab-order.ts b/packages/app/src/components/titlebar-tab-order.ts new file mode 100644 index 000000000000..a906b1fb329a --- /dev/null +++ b/packages/app/src/components/titlebar-tab-order.ts @@ -0,0 +1,12 @@ +export function adjacentTabKey(order: string[], current: string | undefined, offset: -1 | 1) { + if (!current || order.length === 0) return + const index = order.indexOf(current) + if (index === -1) return + return order[(index + offset + order.length) % order.length] +} + +export function mergeVisibleTabOrder(all: string[], current: string[], next: string[]) { + const visible = new Set(current) + const reordered = next.values() + return all.map((key) => (visible.has(key) ? (reordered.next().value ?? key) : key)) +} diff --git a/packages/app/src/components/titlebar-tab-strip.tsx b/packages/app/src/components/titlebar-tab-strip.tsx index 9dc44a65cd85..0a4b4da12dfe 100644 --- a/packages/app/src/components/titlebar-tab-strip.tsx +++ b/packages/app/src/components/titlebar-tab-strip.tsx @@ -1,4 +1,5 @@ -import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount } from "solid-js" +import { createEffect, createMemo, createResource, createRoot, For, onCleanup, onMount, Show } from "solid-js" +import { createStore } from "solid-js/store" import { createResizeObserver } from "@solid-primitives/resize-observer" import { DragDropProvider, PointerSensor } from "@dnd-kit/solid" import { isSortable, useSortable } from "@dnd-kit/solid/sortable" @@ -17,6 +18,8 @@ import { createTabPromptState } from "@/context/prompt" import { base64Encode } from "@opencode-ai/core/util/encode" import { showToast } from "@/utils/toast" import { canStartTabDrag, isTabCloseTarget } from "./titlebar-tab-gesture" +import { adjacentTabKey, mergeVisibleTabOrder } from "./titlebar-tab-order" +import type { Session } from "@opencode-ai/sdk/v2" function SessionTabSlot(props: { tab: SessionTab @@ -24,12 +27,12 @@ function SessionTabSlot(props: { index: () => number active: () => boolean forceTruncate: boolean - serverCtx: () => ServerCtx | undefined + session: () => Session | undefined + fallbackTitle?: string + onRename: (title: string) => Promise onNavigate: (element: HTMLDivElement) => void onClose: () => void }) { - const tabs = useTabs() - const language = useLanguage() const sortable = useSortable({ get id() { return props.id @@ -39,6 +42,47 @@ function SessionTabSlot(props: { }, }) let ref!: HTMLDivElement + + return ( +
    + { + ref = el + }} + href={tabHref(props.tab)} + server={props.tab.server} + session={props.session} + fallbackTitle={props.fallbackTitle} + onRename={props.onRename} + onNavigate={() => props.onNavigate(ref)} + onClose={props.onClose} + active={props.active()} + forceTruncate={props.forceTruncate} + dragging={sortable.isDragSource()} + /> +
    + ) +} + +function SessionTabEntry(props: { + tab: SessionTab + id: string + index: () => number + active: () => boolean + forceTruncate: boolean + serverCtx: () => ServerCtx | undefined + onVisibleChange: (visible: boolean) => void + onNavigate: (element: HTMLDivElement) => void + onClose: () => void +}) { + const tabs = useTabs() + const language = useLanguage() const sdk = createMemo(() => props.serverCtx()?.sdk ?? null) const cachedSession = createMemo(() => props.serverCtx()?.sync.session.peek(props.tab.sessionId)) const persisted = createMemo(() => tabs.info[props.id]) @@ -51,6 +95,7 @@ function SessionTabSlot(props: { ) const session = createMemo(() => cachedSession() ?? loadedSession()) const missingSession = createMemo(() => !!props.serverCtx() && !loadedSession.loading && !session()) + const visible = createMemo(() => !!session() || missingSession() || !!persisted()?.title) let prefetched = false const rename = async (title: string) => { @@ -72,6 +117,8 @@ function SessionTabSlot(props: { } } + createEffect(() => props.onVisibleChange(visible())) + createEffect(() => { const ctx = props.serverCtx() const value = session() @@ -103,30 +150,20 @@ function SessionTabSlot(props: { }) return ( -
    - { - ref = el - }} - href={tabHref(props.tab)} - server={props.tab.server} + + props.onNavigate(ref)} + onNavigate={props.onNavigate} onClose={props.onClose} - active={props.active()} - forceTruncate={props.forceTruncate} - dragging={sortable.isDragSource()} /> -
    + ) } @@ -183,11 +220,39 @@ export function TitlebarTabStrip(props: { }) { const global = useGlobal() const language = useLanguage() + const command = useCommand() let scrollRef!: HTMLDivElement let listRef!: HTMLDivElement let resizeFrame: number | undefined + const [visibility, setVisibility] = createStore>({}) + const visibleTabs = createMemo(() => props.tabs.filter((tab) => tab.type === "draft" || visibility[tabKey(tab)])) + const visibleTabIds = () => visibleTabs().map(tabKey) - const tabIds = () => props.tabs.map(tabKey) + command.register("titlebar-tab-cycle", () => [ + { + id: `tab.prev`, + category: "tab", + title: "", + keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(-1), + }, + { + id: `tab.next`, + category: "tab", + title: "", + keybind: `mod+option+ArrowRight,ctrl+tab`, + hidden: true, + onSelect: () => selectAdjacentTab(1), + }, + ]) + + function selectAdjacentTab(offset: -1 | 1) { + const current = props.currentTab() + const key = adjacentTabKey(visibleTabIds(), current ? tabKey(current) : undefined, offset) + const next = props.tabs.find((tab) => tabKey(tab) === key) + if (next) props.onNavigate(next) + } function refreshOverflow() { if (!scrollRef) return @@ -215,7 +280,7 @@ export function TitlebarTabStrip(props: { createEffect(() => { props.tabs.length - tabIds() + visibleTabIds() refreshOverflow() }) @@ -251,22 +316,29 @@ export function TitlebarTabStrip(props: { props.onNavigate(tab, tabEl ?? undefined) }} onDragEnd={(event) => { - const current = tabIds() + const current = visibleTabIds() const source = event.operation.source if (event.canceled || !isSortable(source)) return const { initialIndex, index } = source if (initialIndex !== index) { - props.onReorder(arrayMove(current, source.initialIndex, source.index)) + props.onReorder( + mergeVisibleTabOrder( + props.tabs.map(tabKey), + current, + arrayMove(current, source.initialIndex, source.index), + ), + ) } }} >
    - {(tab, index) => { + {(tab) => { const id = tabKey(tab) let ref!: HTMLDivElement - useTabShortcut(index, () => props.onNavigate(tab, ref)) + const visibleIndex = () => visibleTabs().findIndex((item) => tabKey(item) === id) + useTabShortcut(visibleIndex, () => props.onNavigate(tab, ref)) const serverCtx = createMemo(() => { if (tab.type !== "session") return const conn = global.servers.list().find((item) => ServerConnection.key(item) === tab.server) @@ -275,13 +347,14 @@ export function TitlebarTabStrip(props: { if (tab.type === "session") { return ( - props.currentTab() === tab} forceTruncate={props.forceTruncate} serverCtx={serverCtx} + onVisibleChange={(visible) => setVisibility(id, visible)} onNavigate={(element) => { ref = element props.onNavigate(tab, element) @@ -295,7 +368,7 @@ export function TitlebarTabStrip(props: { props.currentTab() === tab} title={language.t("command.session.new")} onNavigate={(element) => { @@ -329,7 +402,7 @@ function useTabShortcut(index: () => number, onSelect: () => void) { command.register(() => { const number = index() + 1 - if (number > 9) return [] + if (number < 1 || number > 9) return [] return [ { id: `tab.${number}`, diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 41d134f0f41e..32d7e80b2f0f 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -338,40 +338,6 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl keybind: "mod+shift+t", onSelect: () => tabsStoreActions.reopenClosedTab(), }, - { - id: `tab.prev`, - category: "tab", - title: "", - keybind: `mod+option+ArrowLeft,ctrl+shift+tab`, - hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index -= 1 - if (index === -1) index = tabsStore.length - 1 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, - }, - { - id: `tab.next`, - category: "tab", - title: "", - keybind: `mod+option+ArrowRight,ctrl+tab`, - hidden: true, - onSelect: () => { - let index = tabsStore.findIndex((tab) => tab === currentTab()) - if (index === -1) return - - index += 1 - if (index === tabsStore.length) index = 0 - - const next = tabsStore[index] - if (next) tabs.select(next) - }, - }, ].filter((v) => v !== undefined) }) From 9c8060d96db2ed9fa6b45f8b19f8ec2aefadf2eb Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 28 Jul 2026 12:18:27 +0800 Subject: [PATCH 108/333] refactor(app): extract keybind settings controller (#39226) --- .../app/src/components/settings-keybinds.tsx | 350 +++++++++++++++--- .../test-browser/settings-keybinds.test.ts | 107 ++++++ 2 files changed, 396 insertions(+), 61 deletions(-) create mode 100644 packages/app/test-browser/settings-keybinds.test.ts diff --git a/packages/app/src/components/settings-keybinds.tsx b/packages/app/src/components/settings-keybinds.tsx index cdec8b435c8d..ce74be45864f 100644 --- a/packages/app/src/components/settings-keybinds.tsx +++ b/packages/app/src/components/settings-keybinds.tsx @@ -30,6 +30,8 @@ type KeybindMeta = { type KeybindMap = Record type CommandContext = ReturnType +type LanguageContext = ReturnType +type SettingsContext = ReturnType const GROUPS: KeybindGroup[] = ["General", "Session", "Navigation", "Model and agent", "Terminal", "Prompt"] @@ -122,7 +124,7 @@ function keybinds(value: unknown): KeybindMap { return value as KeybindMap } -function listFor(command: CommandContext, map: KeybindMap, palette: string) { +function listFor(command: Pick, map: KeybindMap, palette: string) { const out = new Map() out.set(PALETTE_ID, { title: palette, group: "General" }) @@ -262,7 +264,274 @@ function useKeyCapture(input: { }) } +export function createKeybindSettingsController( + input: { + command: Pick + settings: { + current: { keybinds: unknown } + keybinds: Pick + } + target?: Document + notify?: (toast: { title: string; description: string }) => void + }, + language: Pick = useLanguage(), +) { + const [store, setStore] = createStore({ active: null as string | null }) + const overrides = createMemo(() => keybinds(input.settings.current.keybinds)) + const list = createMemo(() => { + language.locale() + return listFor(input.command, overrides(), language.t("command.palette")) + }) + const grouped = createMemo(() => groupedFor(list())) + const title = (id: string) => list().get(id)?.title ?? "" + const effective = (id: string) => { + if (id === PALETTE_ID) return input.settings.keybinds.get(id) ?? DEFAULT_PALETTE_KEYBIND + + const custom = input.settings.keybinds.get(id) + if (typeof custom === "string") return custom + + const live = input.command.options.find((item) => item.id === id) + if (live?.keybind) return live.keybind + return input.command.catalog.find((item) => item.id === id)?.keybind + } + const used = createMemo(() => { + const value = new Map() + + for (const id of list().keys()) { + for (const signature of signatures(effective(id))) { + const items = value.get(signature) + if (items) { + items.push({ id, title: title(id) }) + continue + } + value.set(signature, [{ id, title: title(id) }]) + } + } + + return value + }) + const stop = () => { + if (!store.active) return + setStore("active", null) + input.command.keybinds(true) + } + const toggle = (id: string) => { + if (store.active === id) { + stop() + return + } + if (store.active) stop() + setStore("active", id) + input.command.keybinds(false) + } + const notify = input.notify ?? ((toast: { title: string; description: string }) => showToast(toast)) + + const handle = (event: KeyboardEvent) => { + const id = store.active + if (!id) return + + event.preventDefault() + event.stopPropagation() + event.stopImmediatePropagation() + + if (event.key === "Escape") { + stop() + return + } + + const clear = + (event.key === "Backspace" || event.key === "Delete") && + !event.ctrlKey && + !event.metaKey && + !event.altKey && + !event.shiftKey + if (clear) { + input.settings.keybinds.set(id, "none") + stop() + return + } + + const next = recordKeybind(event) + if (!next) return + + const conflicts = new Map() + for (const signature of signatures(next)) { + for (const item of used().get(signature) ?? []) { + if (item.id === id) continue + conflicts.set(item.id, item.title) + } + } + + if (conflicts.size > 0) { + notify({ + title: language.t("settings.shortcuts.conflict.title"), + description: language.t("settings.shortcuts.conflict.description", { + keybind: formatKeybind(next, language.t), + titles: [...conflicts.values()].join(", "), + }), + }) + return + } + + input.settings.keybinds.set(id, next) + stop() + } + + const target = input.target ?? (typeof document === "object" ? document : undefined) + if (target) makeEventListener(target, "keydown", handle, { capture: true }) + + onCleanup(() => { + if (store.active) input.command.keybinds(true) + }) + + return { + catalog: { + groups: GROUPS, + filtered: (query: string) => + filteredFor(query, list(), grouped(), (id) => formatKeybind(effective(id) ?? "", language.t)), + title, + keybind: (id: string) => formatKeybind(effective(id) ?? "", language.t), + }, + capture: { + active: () => store.active, + toggle, + }, + settings: { + hasOverrides: () => Object.values(overrides()).some((value) => typeof value === "string"), + reset: () => { + stop() + input.settings.keybinds.resetAll() + notify({ + title: language.t("settings.shortcuts.reset.toast.title"), + description: language.t("settings.shortcuts.reset.toast.description"), + }) + }, + }, + } +} + +function SettingsKeybindsV2() { + const command = useCommand() + const settings = useSettings() + const controller = createKeybindSettingsController({ + command, + settings, + }) + + return ( + + ) +} + +function SettingsKeybindsV2View(props: { + groups: KeybindGroup[] + filtered: (query: string) => Map + title: (id: string) => string + keybind: (id: string) => string + active: () => string | null + onCapture: (id: string) => void + hasOverrides: () => boolean + onReset: () => void +}) { + const language = useLanguage() + const [store, setStore] = createStore({ filter: "" }) + const filtered = createMemo(() => props.filtered(store.filter)) + const hasResults = createMemo(() => props.groups.some((group) => (filtered().get(group)?.length ?? 0) > 0)) + + return ( + <> +
    +
    +

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

    + + {language.t("settings.shortcuts.reset.button")} + +
    + +
    +
    +
    + + {(group) => ( + 0}> +
    +

    {language.t(groupKey[group])}

    + + + {(id) => ( +
    + {props.title(id)} + +
    + )} +
    +
    +
    +
    + )} +
    + +
    + {language.t("settings.shortcuts.search.empty")} + "{store.filter}" +
    +
    +
    +
    + + ) +} + export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { + if (props.v2) return + const command = useCommand() const language = useLanguage() const settings = useSettings() @@ -476,78 +745,37 @@ export const SettingsKeybinds: Component<{ v2?: boolean }> = (props) => { ) return ( - -
    -
    -
    -

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

    - -
    - -
    - - setStore("filter", v)} - placeholder={language.t("settings.shortcuts.search.placeholder")} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - class="flex-1" - /> - - setStore("filter", "")} /> - -
    -
    -
    - {groups} -
    - } - > - <> -
    -
    -

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

    - +
    +
    +
    +
    +

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

    +
    -
    -
    {groups}
    - - +
    + {groups} +
    ) } diff --git a/packages/app/test-browser/settings-keybinds.test.ts b/packages/app/test-browser/settings-keybinds.test.ts new file mode 100644 index 000000000000..75f0b7554341 --- /dev/null +++ b/packages/app/test-browser/settings-keybinds.test.ts @@ -0,0 +1,107 @@ +import { describe, expect, test } from "bun:test" +import { createRoot } from "solid-js" +import { createKeybindSettingsController } from "../src/components/settings-keybinds" + +function setup(overrides: Record = {}) { + const changes: [string, string][] = [] + const suppression: boolean[] = [] + const notifications: { title: string; description?: string }[] = [] + let resets = 0 + let controller: ReturnType + + const dispose = createRoot((dispose) => { + controller = createKeybindSettingsController( + { + command: { + catalog: [ + { id: "session.alpha", title: "Alpha", keybind: "mod+a" }, + { id: "session.beta", title: "Beta", keybind: "mod+b" }, + ], + options: [], + keybinds: (enabled) => suppression.push(enabled), + }, + settings: { + current: { keybinds: overrides }, + keybinds: { + get: (id) => overrides[id], + set: (id, value) => { + overrides[id] = value + changes.push([id, value]) + }, + resetAll: () => { + resets++ + }, + }, + }, + notify: (toast) => notifications.push(toast), + }, + { + locale: () => "en", + t: (key, params) => { + if (params) return `${key}:${Object.values(params).join("|")}` + if (key === "common.key.alt") return "Alt" + return String(key) + }, + }, + ) + return dispose + }) + + return { + controller: controller!, + changes, + suppression, + notifications, + resets: () => resets, + dispose, + } +} + +function modKey(key: string) { + const mac = /(Mac|iPod|iPhone|iPad)/.test(navigator.platform) + return new KeyboardEvent("keydown", { key, ctrlKey: !mac, metaKey: mac, bubbles: true, cancelable: true }) +} + +describe("keybind settings controller", () => { + test("derives the catalog, effective bindings, and filtered groups", () => { + const state = setup({ "session.beta": "alt+k" }) + + expect(state.controller.catalog.title("session.alpha")).toBe("Alpha") + expect(state.controller.catalog.keybind("session.beta")).toBe("Alt+K") + expect(state.controller.catalog.filtered("alt k").get("Session")).toEqual(["session.beta"]) + expect(state.controller.settings.hasOverrides()).toBe(true) + + state.dispose() + }) + + test("captures bindings, rejects conflicts, and restores command handling", () => { + const state = setup() + + state.controller.capture.toggle("session.beta") + document.dispatchEvent(modKey("a")) + expect(state.changes).toEqual([]) + expect(state.notifications).toHaveLength(1) + expect(state.controller.capture.active()).toBe("session.beta") + + document.dispatchEvent(modKey("x")) + expect(state.changes).toEqual([["session.beta", "mod+x"]]) + expect(state.suppression).toEqual([false, true]) + expect(state.controller.capture.active()).toBeNull() + + state.controller.capture.toggle("session.alpha") + state.dispose() + expect(state.suppression).toEqual([false, true, false, true]) + document.dispatchEvent(modKey("z")) + expect(state.changes).toEqual([["session.beta", "mod+x"]]) + }) + + test("resets persisted overrides and reports success", () => { + const state = setup({ "session.alpha": "none" }) + + state.controller.settings.reset() + expect(state.resets()).toBe(1) + expect(state.notifications[0]?.title).toBe("settings.shortcuts.reset.toast.title") + + state.dispose() + }) +}) From 921b1c6a34651870db4585e4e4114f0ba87102a7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:19:33 -0500 Subject: [PATCH 109/333] feat(mcp): upgrade client SDK to v2 (#39247) Co-authored-by: Matt Carey --- bun.lock | 82 +-- package.json | 1 - packages/opencode/package.json | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 146 ++-- packages/opencode/src/mcp/auth.ts | 4 + packages/opencode/src/mcp/catalog.ts | 82 +-- packages/opencode/src/mcp/index.ts | 75 ++- packages/opencode/src/mcp/oauth-callback.ts | 12 +- packages/opencode/src/mcp/oauth-provider.ts | 85 ++- .../routes/instance/httpapi/groups/mcp.ts | 1 + .../routes/instance/httpapi/handlers/mcp.ts | 2 +- packages/opencode/src/tool/code-mode.ts | 3 +- .../test/fixture/mcp-lifecycle-stdio.ts | 7 +- .../test/fixture/mcp-session-recovery.ts | 4 +- packages/opencode/test/mcp/catalog.test.ts | 22 +- packages/opencode/test/mcp/headers.test.ts | 25 +- packages/opencode/test/mcp/lifecycle.test.ts | 31 +- .../test/mcp/oauth-auto-connect.test.ts | 8 +- .../opencode/test/mcp/oauth-browser.test.ts | 6 +- .../opencode/test/mcp/oauth-callback.test.ts | 2 +- .../opencode/test/mcp/oauth-provider.test.ts | 41 -- .../test/mcp/session-recovery.test.ts | 2 +- .../test/tool/code-mode-integration.test.ts | 17 +- packages/opencode/test/tool/code-mode.test.ts | 2 +- packages/opencode/test/tool/registry.test.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 + packages/sdk/js/src/v2/gen/types.gen.ts | 1 + .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 629 ------------------ 28 files changed, 270 insertions(+), 1027 deletions(-) delete mode 100644 patches/@modelcontextprotocol%2Fsdk@1.29.0.patch diff --git a/bun.lock b/bun.lock index 39749a43cae1..f5df0e1fad37 100644 --- a/bun.lock +++ b/bun.lock @@ -592,7 +592,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/client": "2.0.0-beta.5", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -666,6 +666,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", + "@modelcontextprotocol/server": "2.0.0-beta.5", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -1072,7 +1073,6 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "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", @@ -1674,8 +1674,6 @@ "@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], - "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], - "@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="], "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], @@ -1830,7 +1828,11 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg=="], + + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0-beta.5", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew=="], + + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" } }, "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], @@ -3390,7 +3392,7 @@ "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], - "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -3402,8 +3404,6 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], - "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], - "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -3708,8 +3708,6 @@ "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], - "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], - "expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], @@ -4094,8 +4092,6 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], - "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], - "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -5004,8 +5000,6 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], - "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], - "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -5954,15 +5948,13 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], + "@modelcontextprotocol/client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], + "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], - "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], - - "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6238,6 +6230,8 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], + "body-parser/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -6320,6 +6314,8 @@ "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], + "express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], + "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -6468,8 +6464,6 @@ "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], - "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], - "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -6798,58 +6792,28 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], - "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], - - "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], - - "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], - - "@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], - - "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], - - "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], - - "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], - - "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], - - "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], - - "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], - - "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], - "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], - "@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], "@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], - "@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], - "@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], - "@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -6862,14 +6826,10 @@ "@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], - "@octokit/graphql/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="], "@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - "@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], @@ -7378,10 +7338,6 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], - "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - - "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], - "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7390,20 +7346,14 @@ "@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - "@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], - "@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], - "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/package.json b/package.json index 5fd0f1d51ad8..176c7a245942 100644 --- a/package.json +++ b/package.json @@ -154,7 +154,6 @@ "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index aa09e4610cfd..6544ba09e25a 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -30,6 +30,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", + "@modelcontextprotocol/server": "2.0.0-beta.5", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -80,7 +81,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/sdk": "1.29.0", + "@modelcontextprotocol/client": "2.0.0-beta.5", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c2d2ee2f3b73..009b6ba7559f 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -2,13 +2,10 @@ import { cmd } from "./cmd" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { effectCmd } from "../effect-cmd" import { Cause } from "effect" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" -import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" +import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { MCP } from "../../mcp" +import { CLIENT_OPTIONS, MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" @@ -731,107 +728,52 @@ export const McpDebugCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Testing connection...") - // Test basic HTTP connectivity first - try { - const response = await fetch(serverConfig.url, { - method: "POST", - headers: { - ...serverConfig.headers, - "Content-Type": "application/json", - Accept: "application/json, text/event-stream", + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + let authorizationUrl: URL | undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async (url) => { + authorizationUrl = url }, - body: JSON.stringify({ - jsonrpc: "2.0", - method: "initialize", - params: { - protocolVersion: LATEST_PROTOCOL_VERSION, - capabilities: {}, - clientInfo: { name: "opencode-debug", version: InstallationVersion }, - }, - id: 1, - }), - }) - - spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) - - // Check for WWW-Authenticate header - const wwwAuth = response.headers.get("www-authenticate") - if (wwwAuth) { - prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) - } - - if (response.status === 401) { - prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth") - - // Try to discover OAuth metadata - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async () => {}, - }, - auth, - ) - - prompts.log.info("Testing OAuth flow (without completing authorization)...") - - // Try creating transport with auth provider to trigger discovery - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, - requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, - }) + }, + auth, + ) + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, + requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, + }) + const client = new Client({ name: "opencode-debug", version: InstallationVersion }, CLIENT_OPTIONS) - try { - const client = new Client({ - name: "opencode-debug", - version: InstallationVersion, - }) - await client.connect(transport) - prompts.log.success("Connection successful (already authenticated)") - await client.close() - } catch (error) { - if (error instanceof UnauthorizedError) { - prompts.log.info(`OAuth flow triggered: ${error.message}`) - - // Check if dynamic registration would be attempted - const clientInfo = await authProvider.clientInformation() - if (clientInfo) { - prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - } else { - prompts.log.info("No client ID - dynamic registration will be attempted") - } - } else { - prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) - } - } - } else if (response.status >= 200 && response.status < 300) { - prompts.log.success("Server responded successfully (no auth required or already authenticated)") - const body = await response.text() - try { - const json = JSON.parse(body) - if (json.result?.serverInfo) { - prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) - } - } catch { - // Not JSON, ignore - } + try { + await client.connect(transport) + spinner.stop("SDK connection successful") + prompts.log.success( + `Connected using MCP ${client.getNegotiatedProtocolVersion() ?? "unknown"} (${client.getProtocolEra() ?? "unknown"})`, + ) + const serverInfo = client.getServerVersion() + if (serverInfo) prompts.log.info(`Server info: ${JSON.stringify(serverInfo)}`) + } catch (error) { + if (error instanceof UnauthorizedError) { + spinner.stop("OAuth required") + prompts.log.info(`OAuth flow triggered: ${error.message}`) + if (authorizationUrl) prompts.log.info(`Authorization URL: ${authorizationUrl}`) + const clientInfo = await authProvider.clientInformation() + if (clientInfo) prompts.log.info(`Client ID available: ${clientInfo.client_id}`) + if (!clientInfo) prompts.log.info("No client ID - dynamic registration will be attempted") } else { - prompts.log.warn(`Unexpected status: ${response.status}`) - const body = await response.text().catch(() => "") - if (body) { - prompts.log.info(`Response body: ${body.substring(0, 500)}`) - } + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) } - } catch (error) { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + } finally { + await client.close().catch(() => {}) } prompts.outro("Debug complete") diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 808aa3029625..543b4fb0e33f 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -11,6 +11,7 @@ export const Tokens = Schema.Struct({ refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), scope: Schema.mutableKey(Schema.optional(Schema.String)), + issuer: Schema.mutableKey(Schema.optional(Schema.String)), }) export type Tokens = Schema.Schema.Type @@ -19,6 +20,9 @@ export const ClientInfo = Schema.Struct({ clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), + redirectUris: Schema.mutableKey(Schema.optional(Schema.Array(Schema.String))), + issuer: Schema.mutableKey(Schema.optional(Schema.String)), + configPreRegistered: Schema.mutableKey(Schema.optional(Schema.Boolean)), }) export type ClientInfo = Schema.Schema.Type diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 3f985eeb94dc..113ddbd5b857 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,40 +1,8 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { - CallToolResultSchema, - ListToolsResultSchema, - ToolSchema, - type Tool as MCPToolDef, -} from "@modelcontextprotocol/sdk/types.js" +import { Client, type Tool as MCPToolDef } from "@modelcontextprotocol/client" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 -const MAX_LIST_PAGES = 1_000 - -const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ - tools: ToolSchema.omit({ outputSchema: true }).array(), -}) - -export async function paginate( - list: (cursor?: string) => Promise, - items: (result: R) => T[], -) { - const result: T[] = [] - const cursors = new Set() - let cursor: string | undefined - - for (let page = 0; page < MAX_LIST_PAGES; page++) { - const page = await list(cursor) - result.push(...items(page)) - if (page.nextCursor === undefined) return result - if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`) - cursors.add(page.nextCursor) - cursor = page.nextCursor - } - - throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) -} - export function defs(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } @@ -56,7 +24,6 @@ export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: numbe name: mcpTool.name, arguments: (args || {}) as Record, }, - CallToolResultSchema, { resetTimeoutOnProgress: true, signal: options.abortSignal, @@ -118,53 +85,26 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) -export function prompts(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.prompts) return Promise.resolve([]) - return paginate( - (cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }), - (result) => result.prompts, - ) +export async function prompts(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.prompts) return [] + return (await client.listPrompts(undefined, { timeout })).prompts } -export function resources(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) - return paginate( - (cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }), - (result) => result.resources, - ) +export async function resources(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return [] + return (await client.listResources(undefined, { timeout })).resources } -export function resourceTemplates(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) - return paginate( - (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), - (result) => result.resourceTemplates, - ) +export async function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return [] + return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates } function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - try: () => - paginate( - async (cursor) => { - const params = cursor === undefined ? undefined : { cursor } - try { - return await client.listTools(params, { timeout }) - } catch (error) { - if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error - return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) - } - }, - (result) => result.tools, - ), + try: async () => (await client.listTools(undefined, { timeout })).tools, catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } -function isOutputSchemaValidationError(error: Error) { - return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( - error.message, - ) -} - export * as McpCatalog from "./catalog" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 05f12fa2ee45..75d685b82a9d 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -3,18 +3,18 @@ import { pathToFileURL } from "node:url" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" -import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" -import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" -import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { - ListRootsRequestSchema, + Client, + type ClientOptions, + StreamableHTTPClientTransport, + SSEClientTransport, + UnauthorizedError, + RegistrationRejectedError, + SdkHttpError, type LoggingMessageNotification, - LoggingMessageNotificationSchema, type Tool as MCPToolDef, - ToolListChangedNotificationSchema, -} from "@modelcontextprotocol/sdk/types.js" +} from "@modelcontextprotocol/client" +import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" import { Config } from "@/config/config" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { NamedError } from "@opencode-ai/core/util/error" @@ -36,7 +36,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpBrowser } from "./browser" const DEFAULT_TIMEOUT = 30_000 -const CLIENT_OPTIONS = { +export const CLIENT_OPTIONS = { capabilities: { // https://github.com/anomalyco/opencode/issues/11948 // sampling: {}, @@ -47,6 +47,8 @@ const CLIENT_OPTIONS = { // https://github.com/anomalyco/opencode/issues/28567 // tasks: {}, }, + versionNegotiation: { mode: "auto" }, + listMaxPages: 1_000, } satisfies ClientOptions export const Resource = Schema.Struct({ @@ -70,13 +72,19 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP name: Schema.String, }) {} -type MCPClient = Client +type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void } function createClient(directory: string) { - const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) - client.setRequestHandler(ListRootsRequestSchema, () => - Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), + const client: MCPClient = new Client( + { name: "opencode", version: InstallationVersion }, + { + ...CLIENT_OPTIONS, + listChanged: { + tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) }, + }, + }, ) + client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] })) return client } @@ -190,7 +198,11 @@ export interface Interface { mcpName: string, onAuthorization?: (authorizationUrl: string) => void, ) => Effect.Effect - readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect + readonly finishAuth: ( + mcpName: string, + authorizationCode: string, + iss?: string, + ) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect readonly hasStoredTokens: (mcpName: string) => Effect.Effect @@ -291,11 +303,18 @@ const layer = Layer.effect( Effect.map((client) => ({ client, transportName: name })), Effect.catch((error) => { const lastError = error instanceof Error ? error : new Error(String(error)) + const registrationRejected = + error instanceof RegistrationRejectedError || + lastError.message.includes("registration") || + lastError.message.includes("client_id") const isAuthError = - error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) + error instanceof UnauthorizedError || + registrationRejected || + (authProvider && error instanceof SdkHttpError && error.status === 401) || + (authProvider && lastError.message.includes("OAuth")) if (isAuthError) { - if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { + if (registrationRejected) { lastStatus = { status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", @@ -454,12 +473,16 @@ const layer = Layer.effect( ) } - client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => + client.setNotificationHandler("notifications/message", (notification) => bridge.promise(serverLog(name, notification.params)), ) if (!client.getServerCapabilities()?.tools) return - client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { + client.onToolsChanged = async (error) => { + if (error) { + await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message })) + return + } if (s.clients[name] !== client || s.status[name]?.status !== "connected") return const listed = await bridge.promise(McpCatalog.defs(client, timeout)) @@ -468,7 +491,7 @@ const layer = Layer.effect( s.defs[name] = listed await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) - }) + } } function serverLog(name: string, params: LoggingMessageNotification["params"]) { @@ -904,7 +927,7 @@ const layer = Layer.effect( }), ) - const code = yield* Effect.promise(() => callbackPromise) + const callback = yield* Effect.promise(() => callbackPromise) const storedState = yield* auth.getOAuthState(mcpName) if (storedState !== result.oauthState) { @@ -912,16 +935,20 @@ const layer = Layer.effect( throw new Error("OAuth state mismatch - potential CSRF attack") } yield* auth.clearOAuthState(mcpName) - return yield* finishAuth(mcpName, code) + return yield* finishAuth(mcpName, callback.code, callback.iss) }) - const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { + const finishAuth = Effect.fn("MCP.finishAuth")(function* ( + mcpName: string, + authorizationCode: string, + iss?: string, + ) { yield* requireMcpConfig(mcpName) const pending = pendingOAuthTransports.get(mcpName) if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) const error = yield* Effect.tryPromise({ - try: () => pending.transport.finishAuth(authorizationCode), + try: () => pending.transport.finishAuth(authorizationCode, iss), catch: (error) => error, }).pipe( Effect.match({ diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 84007902b8c0..71f6ec95399a 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -9,8 +9,13 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1" let currentPort = OAUTH_CALLBACK_PORT let currentPath = OAUTH_CALLBACK_PATH +export interface AuthorizationCallback { + code: string + iss?: string +} + interface PendingAuth { - resolve: (code: string) => void + resolve: (callback: AuthorizationCallback) => void reject: (error: Error) => void timeout: ReturnType } @@ -49,6 +54,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). } const code = url.searchParams.get("code") + const iss = url.searchParams.get("iss") ?? undefined const state = url.searchParams.get("state") const error = url.searchParams.get("error") const errorDescription = url.searchParams.get("error_description") @@ -95,7 +101,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). clearTimeout(pending.timeout) pendingAuths.delete(state) cleanupStateIndex(state) - pending.resolve(code) + pending.resolve({ code, iss }) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(OauthCallbackPage.success({ provider: "MCP" })) @@ -130,7 +136,7 @@ export async function ensureRunning(redirectUri?: string): Promise { }) } -export function waitForCallback(oauthState: string, mcpName?: string): Promise { +export function waitForCallback(oauthState: string, mcpName?: string): Promise { if (mcpName) mcpNameToState.set(mcpName, oauthState) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index 596bfe1d551f..a3f99a55f5ba 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -1,10 +1,9 @@ -import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { + OAuthClientProvider, OAuthClientMetadata, - OAuthTokens, - OAuthClientInformation, - OAuthClientInformationFull, -} from "@modelcontextprotocol/sdk/shared/auth.js" + StoredOAuthTokens, + StoredOAuthClientInformation, +} from "@modelcontextprotocol/client" import { Effect } from "effect" import { McpAuth } from "./auth" @@ -23,6 +22,14 @@ export interface McpOAuthCallbacks { onRedirect: (url: URL) => void | Promise } +function registrationMetadata(info: StoredOAuthClientInformation) { + return { + clientIdIssuedAt: "client_id_issued_at" in info ? info.client_id_issued_at : undefined, + clientSecretExpiresAt: "client_secret_expires_at" in info ? info.client_secret_expires_at : undefined, + redirectUris: "redirect_uris" in info ? info.redirect_uris : undefined, + } +} + export class McpOAuthProvider implements OAuthClientProvider { constructor( protected mcpName: string, @@ -52,18 +59,21 @@ export class McpOAuthProvider implements OAuthClientProvider { } } - async clientInformation(): Promise { + async clientInformation(): Promise { + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (this.config.clientId) { + const issuer = entry?.clientInfo?.clientId === this.config.clientId ? entry.clientInfo.issuer : undefined return { client_id: this.config.clientId, client_secret: this.config.clientSecret, + ...(issuer !== undefined ? { issuer } : {}), } } // Check stored client info (from dynamic registration) // Use getForUrl to validate credentials are for the current server URL - const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (entry?.clientInfo) { + if (entry.clientInfo.configPreRegistered) return undefined // Check if client secret has expired if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { return undefined @@ -71,6 +81,14 @@ export class McpOAuthProvider implements OAuthClientProvider { return { client_id: entry.clientInfo.clientId, client_secret: entry.clientInfo.clientSecret, + ...(entry.clientInfo.clientIdIssuedAt !== undefined + ? { client_id_issued_at: entry.clientInfo.clientIdIssuedAt } + : {}), + ...(entry.clientInfo.clientSecretExpiresAt !== undefined + ? { client_secret_expires_at: entry.clientInfo.clientSecretExpiresAt } + : {}), + redirect_uris: entry.clientInfo.redirectUris ? [...entry.clientInfo.redirectUris] : [this.redirectUrl], + ...(entry.clientInfo.issuer !== undefined ? { issuer: entry.clientInfo.issuer } : {}), } } @@ -78,22 +96,36 @@ export class McpOAuthProvider implements OAuthClientProvider { return undefined } - async saveClientInformation(info: OAuthClientInformationFull): Promise { + async saveClientInformation(info: StoredOAuthClientInformation): Promise { + if (this.config.clientId && info.client_id === this.config.clientId) { + await Effect.runPromise( + this.auth.updateClientInfo( + this.mcpName, + { clientId: info.client_id, issuer: info.issuer, configPreRegistered: true }, + this.serverUrl, + ), + ) + return + } + + const metadata = registrationMetadata(info) await Effect.runPromise( this.auth.updateClientInfo( this.mcpName, { clientId: info.client_id, clientSecret: info.client_secret, - clientIdIssuedAt: info.client_id_issued_at, - clientSecretExpiresAt: info.client_secret_expires_at, + clientIdIssuedAt: metadata.clientIdIssuedAt, + clientSecretExpiresAt: metadata.clientSecretExpiresAt, + redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl], + issuer: info.issuer, }, this.serverUrl, ), ) } - async tokens(): Promise { + async tokens(): Promise { // Use getForUrl to validate tokens are for the current server URL const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (!entry?.tokens) return undefined @@ -106,18 +138,20 @@ export class McpOAuthProvider implements OAuthClientProvider { ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000)) : undefined, scope: entry.tokens.scope, + issuer: entry.tokens.issuer, } } - async saveTokens(tokens: OAuthTokens): Promise { + async saveTokens(tokens: StoredOAuthTokens): Promise { await Effect.runPromise( this.auth.updateTokens( this.mcpName, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, + expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined, scope: tokens.scope, + issuer: tokens.issuer, }, this.serverUrl, ), @@ -181,10 +215,10 @@ export class McpOAuthProvider implements OAuthClientProvider { } export class McpOAuthPendingProvider extends McpOAuthProvider { - private pendingClientInfo?: OAuthClientInformationFull - private pendingTokens?: OAuthTokens + private pendingClientInfo?: StoredOAuthClientInformation + private pendingTokens?: StoredOAuthTokens - override async clientInformation(): Promise { + override async clientInformation(): Promise { if (!this.config.clientId) return this.pendingClientInfo return { client_id: this.config.clientId, @@ -192,15 +226,15 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { } } - override async saveClientInformation(info: OAuthClientInformationFull): Promise { + override async saveClientInformation(info: StoredOAuthClientInformation): Promise { this.pendingClientInfo = info } - override async tokens(): Promise { + override async tokens(): Promise { return this.pendingTokens } - override async saveTokens(tokens: OAuthTokens): Promise { + override async saveTokens(tokens: StoredOAuthTokens): Promise { this.pendingTokens = tokens } @@ -211,6 +245,7 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { async commit(): Promise { if (!this.pendingTokens) return + const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined await Effect.runPromise( this.auth.set( this.mcpName, @@ -218,16 +253,22 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { tokens: { accessToken: this.pendingTokens.access_token, refreshToken: this.pendingTokens.refresh_token, - expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, + expiresAt: + this.pendingTokens.expires_in !== undefined + ? Date.now() / 1000 + this.pendingTokens.expires_in + : undefined, scope: this.pendingTokens.scope, + issuer: this.pendingTokens.issuer, }, clientInfo: this.pendingClientInfo && !this.config.clientId ? { clientId: this.pendingClientInfo.client_id, clientSecret: this.pendingClientInfo.client_secret, - clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, - clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, + clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt, + clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt, + redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl], + issuer: this.pendingClientInfo.issuer, } : undefined, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index a6fb064d73e4..ca56d10b4e41 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -20,6 +20,7 @@ export const AuthStartResponse = Schema.Struct({ }) export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, + iss: Schema.optional(Schema.String), }) export const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index cdf0cc1e70eb..3a367a84e790 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -38,7 +38,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler payload: typeof AuthCallbackPayload.Type }) { return yield* mcp - .finishAuth(ctx.params.name, ctx.payload.code) + .finishAuth(ctx.params.name, ctx.payload.code, ctx.payload.iss) .pipe( Effect.catchTag("MCP.NotFoundError", (error) => Effect.fail( diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 332d4b43f150..5d9b809a30af 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -1,5 +1,5 @@ import * as Tool from "./tool" -import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js" +import { type CallToolResult } from "@modelcontextprotocol/client" import { Cause, Effect, Schema } from "effect" import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode" import { MCP } from "@/mcp" @@ -149,7 +149,6 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: return yield* Effect.promise(async () => { const raw = await input.entry.tool.client.callTool( { name: input.entry.tool.def.name, arguments: input.args }, - CallToolResultSchema, { resetTimeoutOnProgress: true, signal: input.ctx.abort, diff --git a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts index b01ed921cfd4..6260c7e3d4dc 100644 --- a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts +++ b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts @@ -1,6 +1,5 @@ -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server } from "@modelcontextprotocol/server" +import { StdioServerTransport } from "@modelcontextprotocol/server/stdio" if (process.argv.includes("--hang")) { const pidFile = process.env.MCP_LIFECYCLE_PID_FILE @@ -11,7 +10,7 @@ if (process.argv.includes("--hang")) { const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } }) -server.setRequestHandler(ListToolsRequestSchema, () => +server.setRequestHandler("tools/list", () => Promise.resolve({ tools: [ { diff --git a/packages/opencode/test/fixture/mcp-session-recovery.ts b/packages/opencode/test/fixture/mcp-session-recovery.ts index c20fb5aa5876..7e7e576f1c32 100644 --- a/packages/opencode/test/fixture/mcp-session-recovery.ts +++ b/packages/opencode/test/fixture/mcp-session-recovery.ts @@ -1,6 +1,4 @@ -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" -import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" +import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" const posts: Array<{ method: string; session: string | null }> = [] let initializeCount = 0 diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 7b0d6403bb16..d1367df30400 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,8 +1,6 @@ import { describe, expect, test } from "bun:test" -import { Client } from "@modelcontextprotocol/sdk/client/index.js" -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Client, InMemoryTransport } from "@modelcontextprotocol/client" +import { Server } from "@modelcontextprotocol/server" import { McpCatalog } from "@/mcp/catalog" import { Effect } from "effect" @@ -52,16 +50,16 @@ describe("McpCatalog.convertTool", () => { test("preserves output schema validation across paginated tool discovery", async () => { const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, ({ params }) => + server.setRequestHandler("tools/list", ({ params }) => Promise.resolve( params?.cursor === "page-2" ? { tools: [ { name: "second", - inputSchema: { type: "object" }, + inputSchema: { type: "object" as const }, outputSchema: { - type: "object", + type: "object" as const, properties: { value: { type: "number" } }, required: ["value"], }, @@ -72,9 +70,9 @@ test("preserves output schema validation across paginated tool discovery", async tools: [ { name: "first", - inputSchema: { type: "object" }, + inputSchema: { type: "object" as const }, outputSchema: { - type: "object", + type: "object" as const, properties: { value: { type: "string" } }, required: ["value"], }, @@ -84,7 +82,7 @@ test("preserves output schema validation across paginated tool discovery", async }, ), ) - server.setRequestHandler(CallToolRequestSchema, ({ params }) => + server.setRequestHandler("tools/call", ({ params }) => Promise.resolve({ content: [], structuredContent: { value: params.name === "first" ? 42 : 1 }, @@ -98,9 +96,7 @@ test("preserves output schema validation across paginated tool discovery", async try { const tools = await Effect.runPromise(McpCatalog.defs(client)) expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) - await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( - "Structured content does not match the tool's output schema", - ) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i) } finally { await Promise.all([client.close(), server.close()]) } diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 31cfc20d51c6..323aea2e478f 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,7 +1,5 @@ import { describe, expect } from "bun:test" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -13,7 +11,7 @@ const serve = Effect.acquireRelease( Effect.promise(async () => { const requests: Headers[] = [] const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -38,6 +36,11 @@ const serve = Effect.acquireRelease( (server) => Effect.promise(server.close), ) +const serveUnauthorized = Effect.acquireRelease( + Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("Unauthorized", { status: 401 }) })), + (server) => Effect.sync(() => server.stop(true)), +) + describe("mcp.headers", () => { it.instance("headers are passed to transports when oauth is enabled (default)", () => Effect.gen(function* () { @@ -99,4 +102,18 @@ describe("mcp.headers", () => { } }), ) + + it.instance("reports 401 as failed when oauth is explicitly disabled", () => + Effect.gen(function* () { + const server = yield* serveUnauthorized + const mcp = yield* MCP.Service + const result = yield* mcp.add("unauthorized-no-oauth", { + type: "remote", + url: server.url.toString(), + oauth: false, + }) + + expect(result.status).toMatchObject({ "unauthorized-no-oauth": { status: "failed" } }) + }), + ) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index 80c8fd22f886..b0018987797f 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,18 +1,12 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect } from "bun:test" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" import { - GetPromptRequestSchema, - ListPromptsRequestSchema, - ListResourcesRequestSchema, - ListResourceTemplatesRequestSchema, - ListToolsRequestSchema, - ReadResourceRequestSchema, + Server, + WebStandardStreamableHTTPServerTransport, type ServerCapabilities, type Tool, -} from "@modelcontextprotocol/sdk/types.js" +} from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -66,35 +60,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio }) if (capabilities.tools) { - protocol.setRequestHandler(ListToolsRequestSchema, (request) => { + protocol.setRequestHandler("tools/list", (request) => { if (state.listToolsError) throw new Error(state.listToolsError) const page = state.toolPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor }) }) } if (capabilities.prompts) { - protocol.setRequestHandler(ListPromptsRequestSchema, (request) => { + protocol.setRequestHandler("prompts/list", (request) => { const page = state.promptPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler(GetPromptRequestSchema, async () => { + protocol.setRequestHandler("prompts/get", async () => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] } }) } if (capabilities.resources) { - protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { + protocol.setRequestHandler("resources/list", (request) => { const page = state.resourcePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { + protocol.setRequestHandler("resources/templates/list", (request) => { const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resourceTemplates: page?.items ?? state.resourceTemplates, nextCursor: page?.nextCursor, }) }) - protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => { + protocol.setRequestHandler("resources/read", async (request) => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { contents: [{ uri: request.params.uri, text: "resource result" }] } }) @@ -145,7 +139,7 @@ function hangingLifecycleServer() { return Effect.acquireRelease( Effect.promise(async () => { const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -284,7 +278,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat }), ) -it.instance("accepts empty cursors and rejects repeated cursors", () => +it.instance("accepts empty cursors and terminates on repeated cursors", () => Effect.gen(function* () { const empty = yield* lifecycleServer({ capabilities: { prompts: {} } }) empty.state.promptPages = { @@ -301,7 +295,8 @@ it.instance("accepts empty cursors and rejects repeated cursors", () => const result = yield* mcp.add("looping-cursor", remote(looping.url)) expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"]) - expect(statusName(result.status, "looping-cursor")).toBe("failed") + expect(statusName(result.status, "looping-cursor")).toBe("connected") + expect(Object.keys(yield* mcp.tools())).toEqual([]) }), ) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 5f8889068c33..735dfaf8bedd 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,7 +1,5 @@ import { expect } from "bun:test" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" -import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -40,13 +38,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) { let requiresAuth = true if (capabilities === "tools") { - protocol.setRequestHandler(ListToolsRequestSchema, () => { + protocol.setRequestHandler("tools/list", () => { listToolsCalls++ return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] }) }) } if (capabilities === "resources") { - protocol.setRequestHandler(ListResourcesRequestSchema, () => + protocol.setRequestHandler("resources/list", () => Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }), ) } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 9573805a9a14..507b59b691d4 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,7 +1,5 @@ import { expect } from "bun:test" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" -import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" +import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { Config } from "../../src/config/config" @@ -41,7 +39,7 @@ const serveOAuthMcp = Effect.acquireRelease( Effect.promise(async () => { const requests: Array<{ pathname: string; headers: Headers }> = [] const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index 1666a37142b0..b7db18034fe1 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => { const response = await fetch(`${redirectUri}?code=code&state=success`) expect(response.status).toBe(200) - expect(await callback).toBe("code") + expect(await callback).toEqual({ code: "code", iss: undefined }) expect(McpOAuthCallback.isRunning()).toBe(false) }) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 249c49e8f91d..64c2cb668774 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,5 +1,4 @@ import { test, expect, describe } from "bun:test" -import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" import type { McpAuth } from "../../src/mcp/auth" @@ -60,43 +59,3 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) - -describe("MCP OAuth scope selection", () => { - test("adds offline_access when the authorization server and client support refresh tokens", () => { - expect( - determineScope({ - resourceMetadata: { - resource: "https://mcp.example.com/mcp", - scopes_supported: ["resource.read"], - }, - authServerMetadata: { - issuer: "https://auth.example.com", - authorization_endpoint: "https://auth.example.com/authorize", - token_endpoint: "https://auth.example.com/token", - response_types_supported: ["code"], - scopes_supported: ["resource.read", "offline_access"], - }, - clientMetadata: makeProvider({}).clientMetadata, - }), - ).toBe("resource.read offline_access") - }) - - test("does not add unsupported authorization server scopes", () => { - expect( - determineScope({ - resourceMetadata: { - resource: "https://mcp.example.com/mcp", - scopes_supported: ["resource.read"], - }, - authServerMetadata: { - issuer: "https://auth.example.com", - authorization_endpoint: "https://auth.example.com/authorize", - token_endpoint: "https://auth.example.com/token", - response_types_supported: ["code"], - scopes_supported: ["resource.read"], - }, - clientMetadata: makeProvider({}).clientMetadata, - }), - ).toBe("resource.read") - }) -}) diff --git a/packages/opencode/test/mcp/session-recovery.test.ts b/packages/opencode/test/mcp/session-recovery.test.ts index 658650822007..7011ec564bfd 100644 --- a/packages/opencode/test/mcp/session-recovery.test.ts +++ b/packages/opencode/test/mcp/session-recovery.test.ts @@ -2,7 +2,7 @@ import path from "node:path" import { describe, expect, test } from "bun:test" describe("mcp session recovery", () => { - test("reinitializes and retries once after a session-bound POST returns 404", async () => { + test.skip("reinitializes and retries once after a session-bound POST returns 404", async () => { const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], { cwd: path.join(import.meta.dir, "../.."), stdout: "pipe", diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 671acd896222..32cb420681a9 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -8,15 +8,14 @@ import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" import { MessageID, SessionID } from "@/session/schema" -import { Server } from "@modelcontextprotocol/sdk/server/index.js" -import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" -import type { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { Server } from "@modelcontextprotocol/server" import { - CallToolRequestSchema, + InMemoryTransport, LATEST_PROTOCOL_VERSION, - ListToolsRequestSchema, + type CallToolResult, + type Client, type Tool as MCPToolDef, -} from "@modelcontextprotocol/sdk/types.js" +} from "@modelcontextprotocol/client" import { Cause, Effect, Exit, Layer } from "effect" const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -100,7 +99,7 @@ const TOOL_DEFS: MCPToolDef[] = [ }, ] as MCPToolDef[] -function handleCall(name: string, args: Record) { +function handleCall(name: string, args: Record): CallToolResult { switch (name) { case "get_text": return { content: [{ type: "text", text: `hello ${args.name}` }] } @@ -122,8 +121,8 @@ let description: string async function buildTool() { const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) - server.setRequestHandler(CallToolRequestSchema, async (req) => + server.setRequestHandler("tools/list", async () => ({ tools: TOOL_DEFS })) + server.setRequestHandler("tools/call", async (req) => handleCall(req.params.name, (req.params.arguments ?? {}) as Record), ) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index 34b3faa610d7..cc32d2a5f3ed 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode" -import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c8c5fac59559..c0810a6d7ed9 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -20,7 +20,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { MCP } from "@/mcp" -import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index 9ed0084aac84..babdbc9c517e 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2328,6 +2328,7 @@ export class Auth2 extends HeyApiClient { directory?: string workspace?: string code?: string + iss?: string }, options?: Options, ) { @@ -2340,6 +2341,7 @@ export class Auth2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "code" }, + { in: "body", key: "iss" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d32..f0db3236eabb 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8563,6 +8563,7 @@ export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartRespo export type McpAuthCallbackData = { body?: { code: string + iss?: string } path: { name: string diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch deleted file mode 100644 index 13b8000a0139..000000000000 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ /dev/null @@ -1,629 +0,0 @@ -diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts -index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 ---- a/dist/cjs/client/index.d.ts -+++ b/dist/cjs/client/index.d.ts -@@ -428,6 +428,8 @@ export declare class Client>; -+ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; - callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ - [x: string]: unknown; - content: ({ -diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts -index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 ---- a/dist/esm/client/index.d.ts -+++ b/dist/esm/client/index.d.ts -@@ -428,6 +428,8 @@ export declare class Client>; -+ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; - callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ - [x: string]: unknown; - content: ({ -diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js -index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 ---- a/dist/cjs/client/index.js -+++ b/dist/cjs/client/index.js -@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { - } - async connect(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = async () => { -+ await this._initialize(transport); -+ }; - // When transport sessionId is already set this means we are trying to reconnect. - // In this case we don't need to initialize again. - if (transport.sessionId !== undefined) { - return; - } - try { -- const result = await this.request({ -- method: 'initialize', -- params: { -- protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, -- capabilities: this._capabilities, -- clientInfo: this._clientInfo -- } -- }, types_js_1.InitializeResultSchema, options); -- if (result === undefined) { -- throw new Error(`Server sent invalid initialize result: ${result}`); -- } -- if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -- } -- this._serverCapabilities = result.capabilities; -- this._serverVersion = result.serverInfo; -- // HTTP transports must set the protocol version in each header after initialization. -- if (transport.setProtocolVersion) { -- transport.setProtocolVersion(result.protocolVersion); -- } -- this._instructions = result.instructions; -- await this.notification({ -- method: 'notifications/initialized' -- }); -- // Set up list changed handlers now that we know server capabilities -- if (this._pendingListChangedConfig) { -- this._setupListChangedHandlers(this._pendingListChangedConfig); -- this._pendingListChangedConfig = undefined; -- } -+ await this._initialize(transport, options); - } - catch (error) { - // Disconnect if initialization fails. -@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol { - throw error; - } - } -+ async _initialize(transport, options) { -+ const result = await this.request({ -+ method: 'initialize', -+ params: { -+ protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, -+ capabilities: this._capabilities, -+ clientInfo: this._clientInfo -+ } -+ }, types_js_1.InitializeResultSchema, options); -+ if (result === undefined) { -+ throw new Error(`Server sent invalid initialize result: ${result}`); -+ } -+ if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -+ } -+ this._serverCapabilities = result.capabilities; -+ this._serverVersion = result.serverInfo; -+ // HTTP transports must set the protocol version in each header after initialization. -+ if (transport.setProtocolVersion) { -+ transport.setProtocolVersion(result.protocolVersion); -+ } -+ this._instructions = result.instructions; -+ await this.notification({ -+ method: 'notifications/initialized' -+ }); -+ // Set up list changed handlers now that we know server capabilities -+ if (this._pendingListChangedConfig) { -+ this._setupListChangedHandlers(this._pendingListChangedConfig); -+ this._pendingListChangedConfig = undefined; -+ } -+ } - /** - * After initialization has completed, this will be populated with the server's reported capabilities. - */ -@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol { - * Called after listTools() to pre-compile validators for better performance. - */ -- cacheToolMetadata(tools) { -- this._cachedToolOutputValidators.clear(); -- this._cachedKnownTaskTools.clear(); -- this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools, reset = true) { -+ if (reset) { -+ this._cachedToolOutputValidators.clear(); -+ this._cachedKnownTaskTools.clear(); -+ this._cachedRequiredTaskTools.clear(); -+ } - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { -@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { - async listTools(params, options) { - const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation -- this.cacheToolMetadata(result.tools); -+ this.cacheToolMetadata(result.tools, params?.cursor === undefined); - return result; - } - /** -diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js -index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 ---- a/dist/cjs/client/streamableHttp.js -+++ b/dist/cjs/client/streamableHttp.js -@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { - this.onclose?.(); - } - async send(message, options) { -+ return this._send(message, options, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) { -+ await this._sessionRecovery; -+ return true; -+ } -+ if (this._sessionId !== expiredSessionId) -+ return true; -+ this._sessionId = undefined; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); - try { -+ await this._sessionRecovery; -+ } -+ catch (error) { -+ this._sessionId = undefined; -+ await this.close(); -+ throw error; -+ } -+ finally { -+ this._sessionRecovery = undefined; -+ } -+ return true; -+ } -+ async _send(message, options, isSessionRetry) { -+ try { -+ if (this._sessionRecovery && !(0, types_js_1.isInitializeRequest)(message) && !(0, types_js_1.isInitializedNotification)(message)) { -+ await this._sessionRecovery; -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - // If we have at last event ID, we need to reconnect the SSE stream -@@ -298,6 +329,7 @@ class StreamableHTTPClientTransport { - return; - } - const headers = await this._commonHeaders(); -+ const requestSessionId = headers.get('mcp-session-id') ?? undefined; - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - const init = { -@@ -310,11 +342,20 @@ class StreamableHTTPClientTransport { - const response = await (this._fetch ?? fetch)(this._url, init); - // Handle session ID received during initialization - const sessionId = response.headers.get('mcp-session-id'); -- if (sessionId) { -+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { - this._sessionId = sessionId; - } - if (!response.ok) { - const text = await response.text().catch(() => null); -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !(0, types_js_1.isInitializedNotification)(message)) { -+ const recovered = await this._recoverSession(requestSessionId); -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ if (recovered) { -+ return this._send(message, options, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - // Prevent infinite recursion when server returns 401 after successful auth - if (this._hasCompletedAuthFlow) { -@@ -335,7 +376,7 @@ class StreamableHTTPClientTransport { - // Mark that we completed auth flow - this._hasCompletedAuthFlow = true; - // Purposely _not_ awaited, so we don't call onerror twice -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - if (response.status === 403 && this._authProvider) { - const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response); -@@ -362,7 +403,7 @@ class StreamableHTTPClientTransport { - if (result !== 'AUTHORIZED') { - throw new auth_js_1.UnauthorizedError(); - } -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - } - throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); -diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js -index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb77538182b 100644 ---- a/dist/cjs/shared/protocol.js -+++ b/dist/cjs/shared/protocol.js -@@ -744,7 +744,12 @@ class Protocol { - } - else { - // No related task - send through transport normally -- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { -+ this._transport.send(jsonrpcRequest, { -+ relatedRequestId, -+ resumptionToken, -+ onresumptiontoken, -+ isRequestActive: () => this._responseHandlers.has(messageId) -+ }).catch(error => { - this._cleanupTimeout(messageId); - reject(error); - }); -diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts -index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 ---- a/dist/cjs/client/auth.d.ts -+++ b/dist/cjs/client/auth.d.ts -@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+export declare function determineScope(options: { -+ requestedScope?: string; -+ resourceMetadata?: OAuthProtectedResourceMetadata; -+ authServerMetadata?: AuthorizationServerMetadata; -+ clientMetadata: OAuthClientMetadata; -+}): string | undefined; - /** - * Orchestrates the full auth flow with a server. - * -diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js -index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644 ---- a/dist/cjs/client/auth.js -+++ b/dist/cjs/client/auth.js -@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0; - exports.selectClientAuthMethod = selectClientAuthMethod; - exports.parseErrorResponse = parseErrorResponse; - exports.auth = auth; -+exports.determineScope = determineScope; - exports.isHttpsUrl = isHttpsUrl; - exports.selectResourceURL = selectResourceURL; - exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams; -@@ -186,6 +187,19 @@ async function auth(provider, options) { - throw error; - } - } -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { -+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; -+ if (effectiveScope && -+ authServerMetadata?.scopes_supported?.includes('offline_access') && -+ !effectiveScope.split(' ').includes('offline_access') && -+ clientMetadata.grant_types?.includes('refresh_token')) { -+ effectiveScope = `${effectiveScope} offline_access`; -+ } -+ return effectiveScope; -+} - async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { - // Check if the provider has cached discovery state to skip discovery - const cachedState = await provider.discoveryState?.(); -@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res - }); - } - const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); -- // Apply scope selection strategy (SEP-835): -- // 1. WWW-Authenticate scope (passed via `scope` param) -- // 2. PRM scopes_supported -- // 3. Client metadata scope (user-configured fallback) -- // The resolved scope is used consistently for both DCR and the authorization request. -- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; -+ const resolvedScope = determineScope({ -+ requestedScope: scope, -+ resourceMetadata, -+ authServerMetadata: metadata, -+ clientMetadata: provider.clientMetadata -+ }); - // Handle client registration if needed - let clientInformation = await Promise.resolve(provider.clientInformation()); - if (!clientInformation) { -@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo - if (scope) { - authorizationUrl.searchParams.set('scope', scope); - } -- if (scope?.includes('offline_access')) { -+ if (scope?.split(' ').includes('offline_access')) { - // if the request includes the OIDC-only "offline_access" scope, - // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access - // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess -diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts -index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 ---- a/dist/esm/client/auth.d.ts -+++ b/dist/esm/client/auth.d.ts -@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+export declare function determineScope(options: { -+ requestedScope?: string; -+ resourceMetadata?: OAuthProtectedResourceMetadata; -+ authServerMetadata?: AuthorizationServerMetadata; -+ clientMetadata: OAuthClientMetadata; -+}): string | undefined; - /** - * Orchestrates the full auth flow with a server. - * -diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js -index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644 ---- a/dist/esm/client/auth.js -+++ b/dist/esm/client/auth.js -@@ -161,6 +161,19 @@ export async function auth(provider, options) { - throw error; - } - } -+/** -+ * Selects scopes per the MCP spec and augments them for refresh token support. -+ */ -+export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { -+ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; -+ if (effectiveScope && -+ authServerMetadata?.scopes_supported?.includes('offline_access') && -+ !effectiveScope.split(' ').includes('offline_access') && -+ clientMetadata.grant_types?.includes('refresh_token')) { -+ effectiveScope = `${effectiveScope} offline_access`; -+ } -+ return effectiveScope; -+} - async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { - // Check if the provider has cached discovery state to skip discovery - const cachedState = await provider.discoveryState?.(); -@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res - }); - } - const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); -- // Apply scope selection strategy (SEP-835): -- // 1. WWW-Authenticate scope (passed via `scope` param) -- // 2. PRM scopes_supported -- // 3. Client metadata scope (user-configured fallback) -- // The resolved scope is used consistently for both DCR and the authorization request. -- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; -+ const resolvedScope = determineScope({ -+ requestedScope: scope, -+ resourceMetadata, -+ authServerMetadata: metadata, -+ clientMetadata: provider.clientMetadata -+ }); - // Handle client registration if needed - let clientInformation = await Promise.resolve(provider.clientInformation()); - if (!clientInformation) { -@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli - if (scope) { - authorizationUrl.searchParams.set('scope', scope); - } -- if (scope?.includes('offline_access')) { -+ if (scope?.split(' ').includes('offline_access')) { - // if the request includes the OIDC-only "offline_access" scope, - // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access - // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess -diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js -index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 ---- a/dist/esm/client/index.js -+++ b/dist/esm/client/index.js -@@ -284,41 +284,16 @@ export class Client extends Protocol { - } - async connect(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = async () => { -+ await this._initialize(transport); -+ }; - // When transport sessionId is already set this means we are trying to reconnect. - // In this case we don't need to initialize again. - if (transport.sessionId !== undefined) { - return; - } - try { -- const result = await this.request({ -- method: 'initialize', -- params: { -- protocolVersion: LATEST_PROTOCOL_VERSION, -- capabilities: this._capabilities, -- clientInfo: this._clientInfo -- } -- }, InitializeResultSchema, options); -- if (result === undefined) { -- throw new Error(`Server sent invalid initialize result: ${result}`); -- } -- if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -- } -- this._serverCapabilities = result.capabilities; -- this._serverVersion = result.serverInfo; -- // HTTP transports must set the protocol version in each header after initialization. -- if (transport.setProtocolVersion) { -- transport.setProtocolVersion(result.protocolVersion); -- } -- this._instructions = result.instructions; -- await this.notification({ -- method: 'notifications/initialized' -- }); -- // Set up list changed handlers now that we know server capabilities -- if (this._pendingListChangedConfig) { -- this._setupListChangedHandlers(this._pendingListChangedConfig); -- this._pendingListChangedConfig = undefined; -- } -+ await this._initialize(transport, options); - } - catch (error) { - // Disconnect if initialization fails. -@@ -326,6 +301,37 @@ export class Client extends Protocol { - throw error; - } - } -+ async _initialize(transport, options) { -+ const result = await this.request({ -+ method: 'initialize', -+ params: { -+ protocolVersion: LATEST_PROTOCOL_VERSION, -+ capabilities: this._capabilities, -+ clientInfo: this._clientInfo -+ } -+ }, InitializeResultSchema, options); -+ if (result === undefined) { -+ throw new Error(`Server sent invalid initialize result: ${result}`); -+ } -+ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { -+ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); -+ } -+ this._serverCapabilities = result.capabilities; -+ this._serverVersion = result.serverInfo; -+ // HTTP transports must set the protocol version in each header after initialization. -+ if (transport.setProtocolVersion) { -+ transport.setProtocolVersion(result.protocolVersion); -+ } -+ this._instructions = result.instructions; -+ await this.notification({ -+ method: 'notifications/initialized' -+ }); -+ // Set up list changed handlers now that we know server capabilities -+ if (this._pendingListChangedConfig) { -+ this._setupListChangedHandlers(this._pendingListChangedConfig); -+ this._pendingListChangedConfig = undefined; -+ } -+ } - /** - * After initialization has completed, this will be populated with the server's reported capabilities. - */ -@@ -537,9 +543,11 @@ export class Client extends Protocol { - * Called after listTools() to pre-compile validators for better performance. - */ -- cacheToolMetadata(tools) { -- this._cachedToolOutputValidators.clear(); -- this._cachedKnownTaskTools.clear(); -- this._cachedRequiredTaskTools.clear(); -+ cacheToolMetadata(tools, reset = true) { -+ if (reset) { -+ this._cachedToolOutputValidators.clear(); -+ this._cachedKnownTaskTools.clear(); -+ this._cachedRequiredTaskTools.clear(); -+ } - for (const tool of tools) { - // If the tool has an outputSchema, create and cache the validator - if (tool.outputSchema) { -@@ -565,7 +573,7 @@ export class Client extends Protocol { - async listTools(params, options) { - const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); - // Cache the tools and their output schemas for future validation -- this.cacheToolMetadata(result.tools); -+ this.cacheToolMetadata(result.tools, params?.cursor === undefined); - return result; - } - /** -diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js -index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 ---- a/dist/esm/client/streamableHttp.js -+++ b/dist/esm/client/streamableHttp.js -@@ -1,5 +1,5 @@ - import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; --import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; -+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; - import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; - import { EventSourceParserStream } from 'eventsource-parser/stream'; - // Default reconnection options for StreamableHTTP connections -@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { - this.onclose?.(); - } - async send(message, options) { -+ return this._send(message, options, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) { -+ await this._sessionRecovery; -+ return true; -+ } -+ if (this._sessionId !== expiredSessionId) -+ return true; -+ this._sessionId = undefined; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); - try { -+ await this._sessionRecovery; -+ } -+ catch (error) { -+ this._sessionId = undefined; -+ await this.close(); -+ throw error; -+ } -+ finally { -+ this._sessionRecovery = undefined; -+ } -+ return true; -+ } -+ async _send(message, options, isSessionRetry) { -+ try { -+ if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) { -+ await this._sessionRecovery; -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - // If we have at last event ID, we need to reconnect the SSE stream -@@ -294,6 +325,7 @@ export class StreamableHTTPClientTransport { - return; - } - const headers = await this._commonHeaders(); -+ const requestSessionId = headers.get('mcp-session-id') ?? undefined; - headers.set('content-type', 'application/json'); - headers.set('accept', 'application/json, text/event-stream'); - const init = { -@@ -306,11 +338,20 @@ export class StreamableHTTPClientTransport { - const response = await (this._fetch ?? fetch)(this._url, init); - // Handle session ID received during initialization - const sessionId = response.headers.get('mcp-session-id'); -- if (sessionId) { -+ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { - this._sessionId = sessionId; - } - if (!response.ok) { - const text = await response.text().catch(() => null); -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) { -+ const recovered = await this._recoverSession(requestSessionId); -+ if (options?.isRequestActive?.() === false) { -+ throw new Error('Request is no longer active'); -+ } -+ if (recovered) { -+ return this._send(message, options, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - // Prevent infinite recursion when server returns 401 after successful auth - if (this._hasCompletedAuthFlow) { -@@ -331,7 +372,7 @@ export class StreamableHTTPClientTransport { - // Mark that we completed auth flow - this._hasCompletedAuthFlow = true; - // Purposely _not_ awaited, so we don't call onerror twice -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - if (response.status === 403 && this._authProvider) { - const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); -@@ -358,7 +399,7 @@ export class StreamableHTTPClientTransport { - if (result !== 'AUTHORIZED') { - throw new UnauthorizedError(); - } -- return this.send(message); -+ return this._send(message, options, isSessionRetry); - } - } - throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); -diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js -index bfa2b7120a0f50c569364ea5264e6f811076f44f..abd8dfd707c155f71dae7aeeeeaf7547368ac749 100644 ---- a/dist/esm/shared/protocol.js -+++ b/dist/esm/shared/protocol.js -@@ -740,7 +740,12 @@ export class Protocol { - } - else { - // No related task - send through transport normally -- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { -+ this._transport.send(jsonrpcRequest, { -+ relatedRequestId, -+ resumptionToken, -+ onresumptiontoken, -+ isRequestActive: () => this._responseHandlers.has(messageId) -+ }).catch(error => { - this._cleanupTimeout(messageId); - reject(error); - }); From d50b9e8f9a562bb23196f5b91b2263ac0450d0e0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 04:20:55 +0000 Subject: [PATCH 110/333] chore: generate --- packages/sdk/openapi.json | 3 +++ 1 file changed, 3 insertions(+) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b300754bc859..6150b75e64c6 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3360,6 +3360,9 @@ "properties": { "code": { "type": "string" + }, + "iss": { + "type": "string" } }, "required": ["code"], From 5e3905118339a2970e209e7941c4d572640b9fa0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Mon, 27 Jul 2026 23:21:00 -0500 Subject: [PATCH 111/333] fix(provider): omit deprecated Gemini sampling defaults (#38924) Co-authored-by: Aiden Cline --- packages/opencode/src/provider/transform.ts | 23 +++++-- .../opencode/test/provider/transform.test.ts | 64 ++++++++++++++++++- 2 files changed, 80 insertions(+), 7 deletions(-) diff --git a/packages/opencode/src/provider/transform.ts b/packages/opencode/src/provider/transform.ts index 705af3d5c1b5..303ff11fdfd8 100644 --- a/packages/opencode/src/provider/transform.ts +++ b/packages/opencode/src/provider/transform.ts @@ -516,12 +516,20 @@ export function message(msgs: ModelMessage[], model: Provider.Model, options: Re return msgs } +const GEMINI_MODELS_WITH_SAMPLING_DEFAULTS = [ + /gemini-2[.-]5(?:[.-]|$)/, + /gemini-3-(?:flash|pro)(?:[.-]|$)/, + /gemini-3[.-]1(?:[.-]|$)/, + /gemini-3[.-]5-flash(?!-lite)(?:[.-]|$)/, +] + export function temperature(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("north-mini-code")) return 1.0 if (id.includes("qwen")) return 0.55 if (id.includes("claude")) return undefined - if (id.includes("gemini")) return 1.0 + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 1.0 : undefined if (id.includes("glm-4.6")) return 1.0 if (id.includes("glm-4.7")) return 1.0 if (id.includes("minimax-m2")) return 1.0 @@ -536,21 +544,24 @@ export function temperature(model: Provider.Model) { } export function topP(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("qwen")) return 1 - if (["minimax-m2", "gemini", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 0.95 : undefined + if (["minimax-m2", "kimi-k2.5", "kimi-k2p5", "kimi-k2-5"].some((s) => id.includes(s))) { return 0.95 } return undefined } export function topK(model: Provider.Model) { - const id = model.id.toLowerCase() + const id = model.api.id.toLowerCase() if (id.includes("minimax-m2")) { if (["m2.", "m25", "m21"].some((s) => id.includes(s))) return 40 return 20 } - if (id.includes("gemini")) return 64 + if (id.includes("gemini")) + return GEMINI_MODELS_WITH_SAMPLING_DEFAULTS.some((model) => model.test(id)) ? 64 : undefined return undefined } diff --git a/packages/opencode/test/provider/transform.test.ts b/packages/opencode/test/provider/transform.test.ts index 93e166c4a8c9..0b88fa59dff8 100644 --- a/packages/opencode/test/provider/transform.test.ts +++ b/packages/opencode/test/provider/transform.test.ts @@ -3186,7 +3186,69 @@ describe("ProviderTransform.message - cache control on gateway", () => { describe("ProviderTransform.temperature - Cohere North", () => { test("defaults north-mini-code models to 1.0", () => { - expect(ProviderTransform.temperature({ id: "cohere/North-Mini-Code-1-0-latest" } as any)).toBe(1.0) + expect( + ProviderTransform.temperature({ + id: "cohere/North-Mini-Code-1-0-latest", + api: { id: "North-Mini-Code-1-0-latest" }, + } as any), + ).toBe(1.0) + }) +}) + +describe("ProviderTransform sampling defaults - Gemini", () => { + const model = (id: string) => + ({ + id: `google/${id}`, + api: { id }, + }) as any + + const alias = (id: string, apiID: string) => + ({ + id, + api: { id: apiID }, + }) as any + + test.each([ + "gemini-3.5-flash-lite", + "gemini-3-5-flash-lite", + "gemini-3.6-flash", + "gemini-3-6-flash", + "gemini-4-pro", + "gemini-future", + ])("omits deprecated sampling controls for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBeUndefined() + expect(ProviderTransform.topP(model(id))).toBeUndefined() + expect(ProviderTransform.topK(model(id))).toBeUndefined() + }) + + test.each([ + "gemini-2.5-flash", + "gemini-2.5-pro", + "gemini-2.5-flash-lite", + "gemini-2-5-flash-lite", + "gemini-3-flash-preview", + "gemini-3-pro-image", + "gemini-3.1-flash-lite", + "gemini-3.1-pro-preview", + "gemini-3-1-pro-preview", + "gemini-3.5-flash", + "gemini-3-5-flash", + ])("preserves sampling defaults for %s", (id) => { + expect(ProviderTransform.temperature(model(id))).toBe(1) + expect(ProviderTransform.topP(model(id))).toBe(0.95) + expect(ProviderTransform.topK(model(id))).toBe(64) + }) + + test("uses the API model ID for configured aliases", () => { + const deprecated = alias("google/gemini-3.5-flash", "google/gemini-3.6-flash") + expect(ProviderTransform.temperature(deprecated)).toBeUndefined() + expect(ProviderTransform.topP(deprecated)).toBeUndefined() + expect(ProviderTransform.topK(deprecated)).toBeUndefined() + + const supported = alias("my-gemini", "google/gemini-2.5-flash") + expect(ProviderTransform.temperature(supported)).toBe(1) + expect(ProviderTransform.topP(supported)).toBe(0.95) + expect(ProviderTransform.topK(supported)).toBe(64) }) }) From 172d08cb981248023c82f5b9d138763b27d69783 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 04:34:03 +0000 Subject: [PATCH 112/333] 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 06650c6488ec..ce982c7ac491 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-szhy0258K6IMWi3WRrAzRfhEEIDkGTS04KQwQw+DXyI=", - "aarch64-linux": "sha256-eVqUhXGKgx7rmRzpdr9G59Xrxvj2J0Dhx4Q7QRBHKiQ=", - "aarch64-darwin": "sha256-r/xAkD/yktfxOVTAArWYb0ZMWe/hEqh80dkYsigRkm8=", - "x86_64-darwin": "sha256-xEKSQZM4gYoz9RoaHEvuxrT8AFXHx8hMD1oKGi8lc3Y=" + "x86_64-linux": "sha256-YH/hjSpK7t5RKE2ie6l+EDKaXKoqVXR11ihGXK957DQ=", + "aarch64-linux": "sha256-fJztjpptVyHStsCWxaaAaQnfxu4Uh2fv79qCag1pokg=", + "aarch64-darwin": "sha256-C9Mno6ts6f19iwPAiO9299CMpK7/dmSL5JS9JSTAmKw=", + "x86_64-darwin": "sha256-zcfjXO73bPK1JtcxekDNyGPwIkj3LMITzAB0hq2g/Ro=" } } From c3be6c496528ca6772736b46d1f539f3ca1cefab Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:00:38 -0500 Subject: [PATCH 113/333] fix(mcp): honor callback port in debug (#39259) --- packages/opencode/src/cli/cmd/mcp.ts | 1 + packages/opencode/src/mcp/catalog.ts | 63 +++++++++++++--------- packages/opencode/src/mcp/index.ts | 7 +-- packages/opencode/src/session/tools.ts | 2 +- packages/opencode/src/tool/code-mode.ts | 22 +------- packages/opencode/test/mcp/catalog.test.ts | 44 ++++++++++++++- 6 files changed, 84 insertions(+), 55 deletions(-) diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index 009b6ba7559f..c9f74e2bc039 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -737,6 +737,7 @@ export const McpDebugCommand = effectCmd({ clientId: oauthConfig?.clientId, clientSecret: oauthConfig?.clientSecret, scope: oauthConfig?.scope, + callbackPort: oauthConfig?.callbackPort, redirectUri: oauthConfig?.redirectUri, }, { diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 113ddbd5b857..226cd6bb2584 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,44 +1,57 @@ -import { Client, type Tool as MCPToolDef } from "@modelcontextprotocol/client" +import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 + +export interface McpTool { + readonly def: MCPToolDef + readonly client: Client + readonly timeout?: number +} + +export async function callTool( + tool: McpTool, + args: Record, + signal?: AbortSignal, +): Promise { + const result = await tool.client.callTool( + { name: tool.def.name, arguments: args }, + { + resetTimeoutOnProgress: true, + signal, + timeout: tool.timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (result.isError) + throw new Error( + result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) + return result +} + export function defs(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } -export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { +export function convertTool(tool: McpTool): Tool { const inputSchema: JSONSchema7 = { - ...(mcpTool.inputSchema as JSONSchema7), + ...(tool.def.inputSchema as JSONSchema7), type: "object", - properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"], + properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"], additionalProperties: false, } return dynamicTool({ - description: mcpTool.description ?? "", + description: tool.def.description ?? "", inputSchema: jsonSchema(inputSchema), execute: async (args: unknown, options) => { - const result = await client.callTool( - { - name: mcpTool.name, - arguments: (args || {}) as Record, - }, - { - resetTimeoutOnProgress: true, - signal: options.abortSignal, - timeout, - // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. - onprogress: () => {}, - }, - ) - if (result.isError) - throw new Error( - result.content - .flatMap((item) => (item.type === "text" ? [item.text] : [])) - .filter((text) => text.trim()) - .join("\n\n") || "MCP tool returned an error", - ) + const result = await callTool(tool, (args || {}) as Record, options.abortSignal) if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) return result return { diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 75d685b82a9d..939c4420472f 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -162,12 +162,7 @@ export interface ServerInstructions { } /** An MCP tool in its native shape; consumers adapt it to their own tool format. */ -export interface McpTool { - /** Shared cached definition; consumers must copy rather than mutate it. */ - readonly def: MCPToolDef - readonly client: MCPClient - readonly timeout?: number -} +export type McpTool = McpCatalog.McpTool export interface Interface { readonly status: () => Effect.Effect> diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index 0f401c7562fa..d93fb66f2d09 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (flags.experimentalCodeMode) return tools for (const [key, entry] of Object.entries(yield* mcp.tools())) { - const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout) + const item = McpCatalog.convertTool(entry) const execute = item.execute if (!execute) continue diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index 5d9b809a30af..a046b4093d89 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -145,27 +145,7 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: ) const result: CallToolResult = yield* Effect.gen(function* () { yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) - // Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns. - return yield* Effect.promise(async () => { - const raw = await input.entry.tool.client.callTool( - { name: input.entry.tool.def.name, arguments: input.args }, - { - resetTimeoutOnProgress: true, - signal: input.ctx.abort, - timeout: input.entry.tool.timeout, - // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. - onprogress: () => {}, - }, - ) - if (raw.isError) - throw new Error( - raw.content - .flatMap((item) => (item.type === "text" ? [item.text] : [])) - .filter((text) => text.trim()) - .join("\n\n") || "MCP tool returned an error", - ) - return raw - }) + return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort)) }).pipe( Effect.withSpan("Tool.execute", { attributes: { diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index d1367df30400..3d77cb22b754 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -28,7 +28,10 @@ describe("McpCatalog.convertTool", () => { test("preserves content when structuredContent is also present", async () => { const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } - const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) + const converted = McpCatalog.convertTool({ + def: mcpTool(), + client: clientReturning({ content, structuredContent }), + }) const output = await converted.execute?.({}, options) @@ -37,7 +40,10 @@ describe("McpCatalog.convertTool", () => { test("falls back to structuredContent only when content is absent", async () => { const structuredContent = { results: [{ title: "one" }] } - const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) + const converted = McpCatalog.convertTool({ + def: mcpTool(), + client: clientReturning({ content: [], structuredContent }), + }) const output = await converted.execute?.({}, options) @@ -48,6 +54,40 @@ describe("McpCatalog.convertTool", () => { }) }) +describe("McpCatalog.callTool", () => { + test("forwards the request options", async () => { + const controller = new AbortController() + let request: unknown + let options: unknown + const client = { + callTool: async (input: unknown, config: unknown) => { + request = input + options = config + return { content: [] } + }, + } as unknown as Client + + await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal) + + expect(request).toEqual({ name: "screenshot", arguments: { value: true } }) + expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 }) + expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function") + }) + + test("throws text returned by an MCP tool error", async () => { + const client = clientReturning({ + isError: true, + content: [ + { type: "image", data: "AAAA", mimeType: "image/png" }, + { type: "text", text: "first" }, + { type: "text", text: "second" }, + ], + }) + + await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond") + }) +}) + test("preserves output schema validation across paginated tool discovery", async () => { const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) server.setRequestHandler("tools/list", ({ params }) => From 484f00ebf44fbb9ec938b2155dad42c34fc5a7a7 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 28 Jul 2026 00:35:19 -0500 Subject: [PATCH 114/333] fix(mcp): recover expired SDK sessions (#39265) --- bun.lock | 3 +- package.json | 3 +- .../test/fixture/mcp-session-recovery.ts | 10 +- .../test/mcp/session-recovery.test.ts | 22 +- ...ontextprotocol%2Fclient@2.0.0-beta.5.patch | 214 ++++++++++++++++++ 5 files changed, 247 insertions(+), 5 deletions(-) create mode 100644 patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch diff --git a/bun.lock b/bun.lock index f5df0e1fad37..de6eb95f9c61 100644 --- a/bun.lock +++ b/bun.lock @@ -1071,8 +1071,9 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "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", diff --git a/package.json b/package.json index 176c7a245942..662f9a4c3c23 100644 --- a/package.json +++ b/package.json @@ -155,6 +155,7 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch" } } diff --git a/packages/opencode/test/fixture/mcp-session-recovery.ts b/packages/opencode/test/fixture/mcp-session-recovery.ts index 7e7e576f1c32..381c187f0043 100644 --- a/packages/opencode/test/fixture/mcp-session-recovery.ts +++ b/packages/opencode/test/fixture/mcp-session-recovery.ts @@ -1,8 +1,11 @@ import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" const posts: Array<{ method: string; session: string | null }> = [] +const concurrent = process.env.MCP_RECOVERY_CONCURRENT === "1" let initializeCount = 0 let pingCount = 0 +let replacementStarted!: () => void +const replacement = new Promise((resolve) => (replacementStarted = resolve)) const server = Bun.serve({ port: 0, async fetch(request) { @@ -15,6 +18,7 @@ const server = Bun.serve({ if (message.method === "initialize") { initializeCount++ + if (initializeCount === 2) replacementStarted() return Response.json( { jsonrpc: "2.0", @@ -32,7 +36,8 @@ const server = Bun.serve({ if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) pingCount++ - if (pingCount === 1) return new Response("Session not found", { status: 404 }) + if (concurrent && pingCount === 2) await replacement + if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 }) return Response.json({ jsonrpc: "2.0", id: message.id, result: {} }) }, }) @@ -40,7 +45,8 @@ const client = new Client({ name: "test", version: "1" }) try { await client.connect(new StreamableHTTPClientTransport(server.url)) - await client.ping() + if (concurrent) await Promise.all([client.ping(), client.ping()]) + else await client.ping() process.stdout.write(JSON.stringify(posts)) } finally { await client.close() diff --git a/packages/opencode/test/mcp/session-recovery.test.ts b/packages/opencode/test/mcp/session-recovery.test.ts index 7011ec564bfd..f7c5787a4e94 100644 --- a/packages/opencode/test/mcp/session-recovery.test.ts +++ b/packages/opencode/test/mcp/session-recovery.test.ts @@ -2,7 +2,7 @@ import path from "node:path" import { describe, expect, test } from "bun:test" describe("mcp session recovery", () => { - test.skip("reinitializes and retries once after a session-bound POST returns 404", async () => { + test("reinitializes and retries once after a session-bound POST returns 404", async () => { const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], { cwd: path.join(import.meta.dir, "../.."), stdout: "pipe", @@ -24,4 +24,24 @@ describe("mcp session recovery", () => { { method: "ping", session: "replacement" }, ]) }) + + test("retries a concurrent stale response after recovery completes", async () => { + const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], { + cwd: path.join(import.meta.dir, "../.."), + env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" }, + stdout: "pipe", + stderr: "pipe", + }) + const [code, stdout, stderr] = await Promise.all([ + child.exited, + Bun.readableStreamToText(child.stdout), + Bun.readableStreamToText(child.stderr), + ]) + + expect(code, stderr).toBe(0) + const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }> + expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null]) + expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2) + expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2) + }) }) diff --git a/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch b/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch new file mode 100644 index 000000000000..4205f158317d --- /dev/null +++ b/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch @@ -0,0 +1,214 @@ +diff --git a/dist/index.cjs b/dist/index.cjs +index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370a4553855 100644 +--- a/dist/index.cjs ++++ b/dist/index.cjs +@@ -3154,6 +3154,7 @@ var Client = class extends require_src.Protocol { + */ + async _connectPlainLegacy(transport, options) { + await super.connect(transport); ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + if (transport.sessionId !== void 0) { + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); +@@ -3170,6 +3171,7 @@ var Client = class extends require_src.Protocol { + * the handshake; its completion sets the negotiated (legacy) version. + */ + async _legacyHandshake(transport, options) { ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions); + try { + const offeredVersion = legacyVersions[0]; +@@ -3208,6 +3210,7 @@ var Client = class extends require_src.Protocol { + await super.connect(transport); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); ++ if (negotiatedProtocolVersion !== void 0 && !require_src.isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); + return; + } + this._resetConnectionState(); +@@ -5211,10 +5214,32 @@ var StreamableHTTPClientTransport = class { + } + } + async send(message, options) { +- return this._send(message, options, false); ++ return this._send(message, options, false, 0, false); ++ } ++ async _recoverSession(expiredSessionId) { ++ if (this._sessionRecovery) return this._sessionRecovery; ++ if (!this.onsessionexpired) return false; ++ if (this._sessionId !== expiredSessionId) return true; ++ this._sessionId = void 0; ++ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); ++ try { ++ return await this._sessionRecovery; ++ } catch (error) { ++ this._sessionId = void 0; ++ await this.close(); ++ throw error; ++ } finally { ++ this._sessionRecovery = void 0; ++ } + } +- async _send(message, options, isAuthRetry, stepUpRetries = 0) { ++ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { + try { ++ const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); ++ const isInitialized = Array.isArray(message) ? message.some((m) => require_src.isInitializedNotification(m)) : require_src.isInitializedNotification(message); ++ if (this._sessionRecovery && !isHandshake && !isInitialized) { ++ await this._sessionRecovery; ++ options?.requestSignal?.throwIfAborted(); ++ } + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { + this._startOrAuthSse({ +@@ -5226,8 +5251,8 @@ var StreamableHTTPClientTransport = class { + } + const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); +- const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); + if (isHandshake) headers.delete("mcp-session-id"); ++ const requestSessionId = headers.get("mcp-session-id") || void 0; + if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); +@@ -5249,8 +5274,14 @@ var StreamableHTTPClientTransport = class { + signal + }; + const response = await (this._fetch ?? fetch)(this._url, init); +- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; ++ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; + if (!response.ok) { ++ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { ++ if (await this._recoverSession(requestSessionId)) { ++ options?.requestSignal?.throwIfAborted(); ++ return this._send(message, options, isAuthRetry, stepUpRetries, true); ++ } ++ } + if (response.status === 401 && this._authProvider) { + if (response.headers.has("www-authenticate")) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); +@@ -5264,7 +5295,7 @@ var StreamableHTTPClientTransport = class { + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); +- return this._send(message, options, true, stepUpRetries); ++ return this._send(message, options, true, stepUpRetries, isSessionRetry); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) throw new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { +@@ -5284,7 +5315,7 @@ var StreamableHTTPClientTransport = class { + statusText: response.statusText, + text + }, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError(); +- return this._send(message, options, isAuthRetry, stepUpRetries + 1); ++ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); + } + } + if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { +diff --git a/dist/index.mjs b/dist/index.mjs +index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a7749668c39747 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -3151,6 +3151,7 @@ var Client = class extends Protocol { + */ + async _connectPlainLegacy(transport, options) { + await super.connect(transport); ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + if (transport.sessionId !== void 0) { + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); +@@ -3167,6 +3168,7 @@ var Client = class extends Protocol { + * the handshake; its completion sets the negotiated (legacy) version. + */ + async _legacyHandshake(transport, options) { ++ transport.onsessionexpired = () => this._legacyHandshake(transport, options); + const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); + try { + const offeredVersion = legacyVersions[0]; +@@ -3205,6 +3207,7 @@ var Client = class extends Protocol { + await super.connect(transport); + const negotiatedProtocolVersion = this._negotiatedProtocolVersion; + if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); ++ if (negotiatedProtocolVersion !== void 0 && !isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); + return; + } + this._resetConnectionState(); +@@ -5208,10 +5211,32 @@ var StreamableHTTPClientTransport = class { + } + } + async send(message, options) { +- return this._send(message, options, false); ++ return this._send(message, options, false, 0, false); ++ } ++ async _recoverSession(expiredSessionId) { ++ if (this._sessionRecovery) return this._sessionRecovery; ++ if (!this.onsessionexpired) return false; ++ if (this._sessionId !== expiredSessionId) return true; ++ this._sessionId = void 0; ++ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); ++ try { ++ return await this._sessionRecovery; ++ } catch (error) { ++ this._sessionId = void 0; ++ await this.close(); ++ throw error; ++ } finally { ++ this._sessionRecovery = void 0; ++ } + } +- async _send(message, options, isAuthRetry, stepUpRetries = 0) { ++ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { + try { ++ const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); ++ const isInitialized = Array.isArray(message) ? message.some((m) => isInitializedNotification(m)) : isInitializedNotification(message); ++ if (this._sessionRecovery && !isHandshake && !isInitialized) { ++ await this._sessionRecovery; ++ options?.requestSignal?.throwIfAborted(); ++ } + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { + this._startOrAuthSse({ +@@ -5223,8 +5248,8 @@ var StreamableHTTPClientTransport = class { + } + const headers = await this._commonHeaders(); + this._applyBodyDerivedHeaders(headers, message); +- const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); + if (isHandshake) headers.delete("mcp-session-id"); ++ const requestSessionId = headers.get("mcp-session-id") || void 0; + if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { + if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; + headers.set(name, value); +@@ -5246,8 +5271,14 @@ var StreamableHTTPClientTransport = class { + signal + }; + const response = await (this._fetch ?? fetch)(this._url, init); +- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; ++ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; + if (!response.ok) { ++ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { ++ if (await this._recoverSession(requestSessionId)) { ++ options?.requestSignal?.throwIfAborted(); ++ return this._send(message, options, isAuthRetry, stepUpRetries, true); ++ } ++ } + if (response.status === 401 && this._authProvider) { + if (response.headers.has("www-authenticate")) { + const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); +@@ -5261,7 +5292,7 @@ var StreamableHTTPClientTransport = class { + fetchFn: this._fetchWithInit + }); + await response.text?.().catch(() => {}); +- return this._send(message, options, true, stepUpRetries); ++ return this._send(message, options, true, stepUpRetries, isSessionRetry); + } + await response.text?.().catch(() => {}); + if (isAuthRetry) throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { +@@ -5281,7 +5312,7 @@ var StreamableHTTPClientTransport = class { + statusText: response.statusText, + text + }, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError(); +- return this._send(message, options, isAuthRetry, stepUpRetries + 1); ++ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); + } + } + if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { From c8487bac54d220b085b86b344cb15c61d67f215d Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 05:50:46 +0000 Subject: [PATCH 115/333] 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 ce982c7ac491..1b85cf68b214 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-YH/hjSpK7t5RKE2ie6l+EDKaXKoqVXR11ihGXK957DQ=", - "aarch64-linux": "sha256-fJztjpptVyHStsCWxaaAaQnfxu4Uh2fv79qCag1pokg=", - "aarch64-darwin": "sha256-C9Mno6ts6f19iwPAiO9299CMpK7/dmSL5JS9JSTAmKw=", - "x86_64-darwin": "sha256-zcfjXO73bPK1JtcxekDNyGPwIkj3LMITzAB0hq2g/Ro=" + "x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=", + "aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=", + "aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=", + "x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA=" } } From 8021dbd80f7176e5537564af4fcbb90c8c0ad012 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 28 Jul 2026 06:07:51 +0000 Subject: [PATCH 116/333] sync release versions for v1.18.8 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index de6eb95f9c61..b4571f1314a0 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.7", + "version": "1.18.8", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.7", + "version": "1.18.8", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.7", + "version": "1.18.8", "bin": { "opencode": "./bin/opencode", }, @@ -691,7 +691,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -767,7 +767,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "cross-spawn": "catalog:", }, @@ -782,7 +782,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -797,7 +797,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -842,7 +842,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -855,7 +855,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -889,7 +889,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -908,7 +908,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -950,7 +950,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -977,7 +977,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1028,7 +1028,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 8341a2b26884..73840b2dbf77 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.7", + "version": "1.18.8", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index c93fa6625533..45271e869c99 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index acdaca0ec552..51df6e53a576 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.7", + "version": "1.18.8", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index dc2a03a1c5af..446c3d0854ae 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 140c32ecb4cc..f5a1bbb72a4b 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 9101f0ea4635..937c0cf5386f 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.7", + "version": "1.18.8", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 939824a4db0f..531b0ec651e1 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.7", + "version": "1.18.8", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index a6c4d2ec7807..a556e236bb0f 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index d9da0f892ec5..c1635e632384 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.7", + "version": "1.18.8", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 4ac36046a1ab..2f3b90ae9ba1 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 341291dd265d..a3e9ea8814ef 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.7", + "version": "1.18.8", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index d14decf085b0..507ad6769ea3 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.7", + "version": "1.18.8", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7c20f5e29938..7a4c65783f60 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 1c1bcf83ecc2..e7f851616160 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.7", + "version": "1.18.8", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 980c2b24a945..1eaf4333fc25 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.7", + "version": "1.18.8", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index cd62b1eb1331..6d3a826e59a1 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.7", + "version": "1.18.8", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 6544ba09e25a..293a678e9bb5 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.7", + "version": "1.18.8", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 058803a25214..fefab272f5fe 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 422f5ac849b9..8e8c19d73b86 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index d4ffebe4fdfd..4532b2e37bc8 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index fe07ed5e452d..879f254df122 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 85d1ede18462..5636890bb27e 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index c32ac327d7bc..aceecc49d5d7 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index d1e74ec3d8b0..73de8bbced2a 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 8f2f05e5c335..af0f57d18754 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 4f9850eba8e6..8207147917cd 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.7", + "version": "1.18.8", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index d5022aeb48eb..e5e7a15a1cce 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.7", + "version": "1.18.8", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 1966ee63fb83..963e9dfc5f5a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.7", + "version": "1.18.8", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 6a53c96c5b66..a5948bb436eb 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.7", + "version": "1.18.8", "publisher": "sst-dev", "repository": { "type": "git", From d46be565aa7197446a408adf2dbb531d02d4295c Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:02:26 +0800 Subject: [PATCH 117/333] refactor(app): thin new session composition (#39227) --- packages/app/src/components/session/index.ts | 1 - .../session/session-new-design-view.tsx | 16 - packages/app/src/pages/new-session.tsx | 295 ++---------------- .../new-session-draft-controller.ts | 64 ++++ .../pages/new-session/new-session-view.tsx | 165 ++++++++++ .../new-session-workspace-controller.test.ts | 43 +++ .../new-session-workspace-controller.ts | 77 +++++ .../new-session/use-new-session-commands.tsx | 44 +++ 8 files changed, 420 insertions(+), 285 deletions(-) delete mode 100644 packages/app/src/components/session/session-new-design-view.tsx create mode 100644 packages/app/src/pages/new-session/new-session-draft-controller.ts create mode 100644 packages/app/src/pages/new-session/new-session-view.tsx create mode 100644 packages/app/src/pages/new-session/new-session-workspace-controller.test.ts create mode 100644 packages/app/src/pages/new-session/new-session-workspace-controller.ts create mode 100644 packages/app/src/pages/new-session/use-new-session-commands.tsx diff --git a/packages/app/src/components/session/index.ts b/packages/app/src/components/session/index.ts index 9e44cea17be6..c4fdb2ff3834 100644 --- a/packages/app/src/components/session/index.ts +++ b/packages/app/src/components/session/index.ts @@ -4,4 +4,3 @@ export { SortableTab, FileVisual } from "./session-sortable-tab" export { SortableTabV2 } from "./session-sortable-tab-v2" export { SortableTerminalTab } from "./session-sortable-terminal-tab" export { NewSessionView } from "./session-new-view" -export { NewSessionDesignView } from "./session-new-design-view" diff --git a/packages/app/src/components/session/session-new-design-view.tsx b/packages/app/src/components/session/session-new-design-view.tsx deleted file mode 100644 index a324c64fa6df..000000000000 --- a/packages/app/src/components/session/session-new-design-view.tsx +++ /dev/null @@ -1,16 +0,0 @@ -import type { JSX } from "solid-js" -import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" - -export function NewSessionDesignView(props: { children: JSX.Element }) { - return ( -
    -
    -
    - -
    {props.children}
    -
    -
    -
    - ) -} diff --git a/packages/app/src/pages/new-session.tsx b/packages/app/src/pages/new-session.tsx index 570f04463e70..7c7b89f58927 100644 --- a/packages/app/src/pages/new-session.tsx +++ b/packages/app/src/pages/new-session.tsx @@ -1,290 +1,49 @@ -import { Show, createEffect, createMemo, createResource, createSignal, onCleanup, untrack } from "solid-js" -import { createStore } from "solid-js/store" -import { Portal } from "solid-js/web" -import { useSearchParams } from "@solidjs/router" -import { Tooltip } from "@opencode-ai/ui/tooltip" -import { useDialog } from "@opencode-ai/ui/context/dialog" -import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" -import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" -import { NewSessionDesignView } from "@/components/session" -import { PromptInputV2Composer, usePromptInputV2Controller } from "@/components/prompt-input-v2" -import { StatusPopoverV2 } from "@/components/status-popover" -import { - PromptProjectAddButton, - PromptProjectSelector, - createPromptProjectController, -} from "@/components/prompt-project-selector" -import { useComments } from "@/context/comments" -import { usePrompt } from "@/context/prompt" -import { useSDK } from "@/context/sdk" -import { useSync } from "@/context/sync" -import { useServerSync } from "@/context/server-sync" -import { useLanguage } from "@/context/language" -import { useSettings } from "@/context/settings" -import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" -import { useSessionKey } from "@/pages/session/session-layout" -import { useComposerCommands } from "@/pages/session/use-composer-commands" -import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" -import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" +import { createPromptProjectController } from "@/components/prompt-project-selector" import { useTitlebarRightMount } from "@/components/titlebar" -import { useCommand } from "@/context/command" -import { useProviders } from "@/hooks/use-providers" -import { useSettingsCommand } from "@/components/settings-dialog" -import { Persist, persisted } from "@/utils/persist" -import createPresence from "solid-presence" -import { useLocal } from "@/context/local" -import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" - -const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" -const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 -const providerTipExitDuration = 250 +import { useSettings } from "@/context/settings" +import { createEffect, createResource } from "solid-js" +import { createNewSessionDraftController } from "./new-session/new-session-draft-controller" +import { NewSessionStatus, NewSessionView } from "./new-session/new-session-view" +import { createNewSessionWorkspaceController } from "./new-session/new-session-workspace-controller" +import { useNewSessionCommands } from "./new-session/use-new-session-commands" -/** - * The `/new-session` draft page. Unlike `session.tsx`, this only renders the prompt - * composer for a brand-new session — no terminal, review pane, file tree, or message - * timeline. Submitting promotes the draft into a real session (see prompt-input/submit). - */ +/** The draft-only V2 session page. Submitting promotes the draft into a real session. */ export default function NewSessionPage() { - const prompt = usePrompt() - const sdk = useSDK() - const sync = useSync() - const serverSync = useServerSync() - const comments = useComments() - const language = useLanguage() const settings = useSettings() - const dialog = useDialog() - const command = useCommand() - const providers = useProviders(() => sdk().directory) - const openProviders = () => { - void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { - void dialog.show(() => sdk().directory} />) - }) - } - useSettingsCommand() - const route = useSessionKey() - const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() - const local = useLocal() - const model = createPromptModelSelection({ agent: local.agent.current }) - - useComposerCommands({ model }) - - const inputController = createPromptInputController({ - sessionKey: route.sessionKey, - sessionID: () => route.params.id, - queryOptions: serverSync().queryOptions, - model, - }) - const projectControls = createPromptProjectControls() - - const [store, setStore] = createStore<{ worktree?: string }>({}) const rightMount = useTitlebarRightMount() - - const showWorkspaceBar = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") - const newSessionWorktree = createMemo(() => { - if (!showWorkspaceBar()) return "main" - if (store.worktree) return store.worktree - const project = sync().project - if (project && sdk().directory !== project.worktree) return sdk().directory - return "main" - }) - const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) - const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) - const selectedBranch = createMemo(() => { - const worktree = newSessionWorktree() - if (worktree === "main" || worktree === "create") return localBranch() - return serverSync().child(worktree)[0].vcs?.branch ?? localBranch() - }) - const promptInputV2Controller = usePromptInputV2Controller({ - get controls() { - return inputController() - }, - get newSessionWorktree() { - return newSessionWorktree() - }, - onNewSessionWorktreeReset: () => setStore("worktree", undefined), - onSubmit: () => comments.clear(), - }) - const projectController = createPromptProjectController({ - controls: projectControls, - onDone: promptInputV2Controller.restoreFocus, - }) - - command.register("new-session", () => [ - { - id: "command.palette", - title: language.t("command.palette"), - hidden: true, - onSelect: async () => { - const { DialogSelectFile } = await import("@/components/dialog-select-file") - void dialog.show(() => ) - }, - }, - { - id: "input.focus", - title: language.t("command.input.focus"), - category: language.t("command.category.view"), - keybind: "ctrl+l", - onSelect: () => promptInputV2Controller.restoreFocus(), + const workspace = createNewSessionWorkspaceController() + const draft = createNewSessionDraftController({ + worktree: workspace.selection.value, + resetWorktree: workspace.selection.reset, + }) + const project = createPromptProjectController({ + controls: draft.project.controls, + onDone: draft.input.restoreFocus, + }) + useNewSessionCommands({ + restoreFocus: draft.input.restoreFocus, + project: { + empty: project.empty, + open: () => project.setOpen(true), }, - { - id: "project.select", - title: language.t("session.new.project.search"), - category: language.t("command.category.project"), - keybind: "mod+shift+o", - disabled: projectController.empty(), - onSelect: () => projectController.setOpen(true), - }, - ]) - - createEffect(() => { - if (!prompt.ready()) return - untrack(() => { - const text = searchParams.prompt - if (!text) return - prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) - setSearchParams({ ...searchParams, prompt: undefined }) - }) }) - createEffect(() => { - if (!prompt.ready()) return - promptInputV2Controller.restoreFocus() + if (!draft.prompt.ready()) return + draft.input.restoreFocus() }) - const ready = Promise.resolve() const [suspendUntilPromptReady] = createResource( - () => prompt.ready.promise ?? ready, + () => draft.prompt.readyPromise() ?? ready, (promise) => promise.then(() => true), ) return (
    {suspendUntilPromptReady()} - - {(mount) => ( - - - - - - - - )} - +
    -
    -
    - -
    -
    - - - - - -
    - - } - > - - setStore( - "worktree", - value === "main" && sync().project?.worktree !== sdk().directory - ? sync().project?.worktree - : value, - ) - } - onDone={promptInputV2Controller.restoreFocus} - /> - -
    -
    -
    - {/**/} -
    -
    - serverSync().child(sdk().directory)[0].provider_ready} - connected={() => providers.paid().length > 0} - openProviders={openProviders} - /> -
    -
    +
    ) } - -function ProviderTip(props: { ready: () => boolean; connected: () => boolean; openProviders: () => void }) { - const language = useLanguage() - const [persistedState, setPersistedState, , persistedReady] = persisted( - Persist.global("new-session.provider-tip"), - createStore({ dismissedAt: 0 }), - ) - const visible = createMemo( - () => - props.ready() && - persistedReady() && - !props.connected() && - Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, - ) - - function dismiss() { - setPersistedState("dismissedAt", Date.now()) - } - - const [ref, setRef] = createSignal() - const presence = createPresence({ - show: () => visible(), - element: () => ref() ?? null, - }) - - return ( - -
    -
    - - - - -
    -
    -
    - ) -} diff --git a/packages/app/src/pages/new-session/new-session-draft-controller.ts b/packages/app/src/pages/new-session/new-session-draft-controller.ts new file mode 100644 index 000000000000..bf22834e48d9 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-draft-controller.ts @@ -0,0 +1,64 @@ +import { useSearchParams } from "@solidjs/router" +import { createEffect, untrack } from "solid-js" +import { usePromptInputV2Controller } from "@/components/prompt-input-v2" +import { useComments } from "@/context/comments" +import { useLocal } from "@/context/local" +import { usePrompt } from "@/context/prompt" +import { useServerSync } from "@/context/server-sync" +import { createPromptInputController, createPromptProjectControls } from "@/pages/session/composer" +import { createPromptModelSelection } from "@/pages/session/composer/prompt-model-selection" +import { useSessionKey } from "@/pages/session/session-layout" +import { useComposerCommands } from "@/pages/session/use-composer-commands" + +export function createNewSessionDraftController(workspace: { worktree: () => string; resetWorktree: () => void }) { + const prompt = usePrompt() + const serverSync = useServerSync() + const comments = useComments() + const local = useLocal() + const route = useSessionKey() + const [searchParams, setSearchParams] = useSearchParams<{ draftId?: string; prompt?: string }>() + const model = createPromptModelSelection({ agent: () => local.agent.current() }) + + useComposerCommands({ model }) + + const controls = createPromptInputController({ + sessionKey: route.sessionKey, + sessionID: () => route.params.id, + queryOptions: serverSync().queryOptions, + model, + }) + const projectControls = createPromptProjectControls() + const input = usePromptInputV2Controller({ + get controls() { + return controls() + }, + get newSessionWorktree() { + return workspace.worktree() + }, + onNewSessionWorktreeReset: workspace.resetWorktree, + onSubmit: comments.clear, + }) + + createEffect(() => { + if (!prompt.ready()) return + untrack(() => { + const text = searchParams.prompt + if (!text) return + prompt.set([{ type: "text", content: text, start: 0, end: text.length }], text.length) + setSearchParams({ ...searchParams, prompt: undefined }) + }) + }) + + return { + input, + prompt: { + ready: prompt.ready, + readyPromise: () => prompt.ready.promise, + }, + project: { + controls: projectControls, + }, + } +} + +export type NewSessionDraftController = ReturnType diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx new file mode 100644 index 000000000000..3117bf800ad6 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -0,0 +1,165 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { Tooltip } from "@opencode-ai/ui/tooltip" +import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" +import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" +import { WordmarkV2 } from "@opencode-ai/ui/v2/wordmark-v2" +import { Show, createMemo, createSignal, type Accessor } from "solid-js" +import { createStore } from "solid-js/store" +import { Portal } from "solid-js/web" +import createPresence from "solid-presence" +import { PromptInputV2Composer } from "@/components/prompt-input-v2" +import { PromptGitStatus, PromptWorkspaceSelector } from "@/components/prompt-workspace-selector" +import { + PromptProjectAddButton, + PromptProjectSelector, + type PromptProjectController, +} from "@/components/prompt-project-selector" +import { StatusPopoverV2 } from "@/components/status-popover" +import { useLanguage } from "@/context/language" +import { useSDK } from "@/context/sdk" +import { useServerSync } from "@/context/server-sync" +import { useProviders } from "@/hooks/use-providers" +import { NEW_SESSION_CONTENT_WIDTH } from "@/pages/session/new-session-layout" +import { Persist, persisted } from "@/utils/persist" +import type { NewSessionDraftController } from "./new-session-draft-controller" +import type { NewSessionWorkspaceController } from "./new-session-workspace-controller" + +const providerTipDismissalDuration = 30 * 24 * 60 * 60 * 1000 + +export function NewSessionView(props: { + input: NewSessionDraftController["input"] + project: PromptProjectController + workspace: NewSessionWorkspaceController +}) { + return ( +
    +
    +
    +
    + +
    + + + + + +
    + + + } + > + + +
    +
    +
    +
    +
    + +
    +
    + ) +} + +export function NewSessionStatus(props: { mount: Accessor; visible: Accessor }) { + const language = useLanguage() + + return ( + + {(mount) => ( + + + + + + + + )} + + ) +} + +function ProviderTip() { + const language = useLanguage() + const dialog = useDialog() + const sdk = useSDK() + const serverSync = useServerSync() + const providers = useProviders(() => sdk().directory) + const [persistedState, setPersistedState, , persistedReady] = persisted( + Persist.global("new-session.provider-tip"), + createStore({ dismissedAt: 0 }), + ) + const visible = createMemo( + () => + serverSync().child(sdk().directory)[0].provider_ready && + persistedReady() && + providers.paid().length === 0 && + Date.now() - persistedState.dismissedAt >= providerTipDismissalDuration, + ) + const [ref, setRef] = createSignal() + const presence = createPresence({ + show: visible, + element: () => ref() ?? null, + }) + const openProviders = () => { + void import("@/components/dialog-connect-provider").then(({ DialogConnectProvider }) => { + void dialog.show(() => sdk().directory} />) + }) + } + + return ( + +
    +
    + + + + +
    +
    +
    + ) +} diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts new file mode 100644 index 000000000000..2a79ae77fa7a --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, test } from "bun:test" +import { + normalizeNewSessionWorktree, + resolveNewSessionBranch, + resolveNewSessionWorktree, +} from "./new-session-workspace-controller" + +describe("new session workspace selection", () => { + test("uses main when the workspace bar is unavailable", () => { + expect( + resolveNewSessionWorktree({ + enabled: false, + selected: "/project/feature", + directory: "/project/feature", + projectWorktree: "/project", + }), + ).toBe("main") + }) + + test("derives an existing worktree from the current directory", () => { + expect( + resolveNewSessionWorktree({ enabled: true, directory: "/project/feature", projectWorktree: "/project" }), + ).toBe("/project/feature") + expect(resolveNewSessionWorktree({ enabled: true, directory: "/project", projectWorktree: "/project" })).toBe( + "main", + ) + }) + + test("normalizes main to the project root outside the main worktree", () => { + expect(normalizeNewSessionWorktree("main", "/project/feature", "/project")).toBe("/project") + expect(normalizeNewSessionWorktree("main", "/project", "/project")).toBe("main") + }) + + test("falls back to the local branch for main, create, and unknown worktrees", () => { + const branch = (worktree: string) => (worktree === "/project/feature" ? "feature" : undefined) + expect(resolveNewSessionBranch({ worktree: "main", local: "dev", worktreeBranch: branch })).toBe("dev") + expect(resolveNewSessionBranch({ worktree: "create", local: "dev", worktreeBranch: branch })).toBe("dev") + expect(resolveNewSessionBranch({ worktree: "/project/feature", local: "dev", worktreeBranch: branch })).toBe( + "feature", + ) + expect(resolveNewSessionBranch({ worktree: "/missing", local: "dev", worktreeBranch: branch })).toBe("dev") + }) +}) diff --git a/packages/app/src/pages/new-session/new-session-workspace-controller.ts b/packages/app/src/pages/new-session/new-session-workspace-controller.ts new file mode 100644 index 000000000000..f3fc9b2708d4 --- /dev/null +++ b/packages/app/src/pages/new-session/new-session-workspace-controller.ts @@ -0,0 +1,77 @@ +import { createMemo, createSignal } from "solid-js" +import { useSDK } from "@/context/sdk" +import { useServerSync } from "@/context/server-sync" +import { useSync } from "@/context/sync" + +const workspaceBarEnabled = import.meta.env.VITE_OPENCODE_CHANNEL !== "prod" + +export function resolveNewSessionWorktree(input: { + enabled: boolean + selected?: string + directory: string + projectWorktree?: string +}) { + if (!input.enabled) return "main" + if (input.selected) return input.selected + if (input.projectWorktree && input.directory !== input.projectWorktree) return input.directory + return "main" +} + +export function normalizeNewSessionWorktree(value: string, directory: string, projectWorktree?: string) { + if (value === "main" && projectWorktree !== directory) return projectWorktree + return value +} + +export function resolveNewSessionBranch(input: { + worktree: string + local?: string + worktreeBranch: (worktree: string) => string | undefined +}) { + if (input.worktree === "main" || input.worktree === "create") return input.local + return input.worktreeBranch(input.worktree) ?? input.local +} + +export function createNewSessionWorkspaceController() { + const sdk = useSDK() + const sync = useSync() + const serverSync = useServerSync() + const [worktree, setWorktree] = createSignal() + const visible = createMemo(() => workspaceBarEnabled && sync().project?.vcs === "git") + const value = createMemo(() => + resolveNewSessionWorktree({ + enabled: visible(), + selected: worktree(), + directory: sdk().directory, + projectWorktree: sync().project?.worktree, + }), + ) + const projectRoot = createMemo(() => sync().project?.worktree ?? sdk().directory) + const localBranch = createMemo(() => serverSync().child(projectRoot())[0].vcs?.branch) + const branch = createMemo(() => + resolveNewSessionBranch({ + worktree: value(), + local: localBranch(), + worktreeBranch: (worktree) => serverSync().child(worktree)[0].vcs?.branch, + }), + ) + + return { + selection: { + value, + reset: () => setWorktree(), + set: (worktree: string) => + setWorktree(normalizeNewSessionWorktree(worktree, sdk().directory, sync().project?.worktree)), + }, + project: { + root: projectRoot, + workspaces: () => sync().project?.sandboxes ?? [], + git: () => sync().project?.vcs === "git", + }, + bar: { + visible, + branch, + }, + } +} + +export type NewSessionWorkspaceController = ReturnType diff --git a/packages/app/src/pages/new-session/use-new-session-commands.tsx b/packages/app/src/pages/new-session/use-new-session-commands.tsx new file mode 100644 index 000000000000..a0d835f97f53 --- /dev/null +++ b/packages/app/src/pages/new-session/use-new-session-commands.tsx @@ -0,0 +1,44 @@ +import { useDialog } from "@opencode-ai/ui/context/dialog" +import { useSettingsCommand } from "@/components/settings-dialog" +import { useCommand } from "@/context/command" +import { useLanguage } from "@/context/language" + +export function useNewSessionCommands(input: { + restoreFocus: () => void + project: { + empty: () => boolean + open: () => void + } +}) { + const command = useCommand() + const dialog = useDialog() + const language = useLanguage() + + useSettingsCommand() + command.register("new-session", () => [ + { + id: "command.palette", + title: language.t("command.palette"), + hidden: true, + onSelect: async () => { + const { DialogSelectFile } = await import("@/components/dialog-select-file") + void dialog.show(() => ) + }, + }, + { + id: "input.focus", + title: language.t("command.input.focus"), + category: language.t("command.category.view"), + keybind: "ctrl+l", + onSelect: input.restoreFocus, + }, + { + id: "project.select", + title: language.t("session.new.project.search"), + category: language.t("command.category.project"), + keybind: "mod+shift+o", + disabled: input.project.empty(), + onSelect: input.project.open, + }, + ]) +} From 7dae9a10839504942ec405e26377527b9472b857 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 07:03:56 +0000 Subject: [PATCH 118/333] chore: generate --- packages/app/src/pages/new-session/new-session-view.tsx | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/packages/app/src/pages/new-session/new-session-view.tsx b/packages/app/src/pages/new-session/new-session-view.tsx index 3117bf800ad6..84c671e140eb 100644 --- a/packages/app/src/pages/new-session/new-session-view.tsx +++ b/packages/app/src/pages/new-session/new-session-view.tsx @@ -51,10 +51,7 @@ export function NewSessionView(props: { + } > Date: Tue, 28 Jul 2026 09:35:57 +0200 Subject: [PATCH 119/333] feat(desktop): collapse model provider sections (#39283) --- .../app/src/components/settings-v2/models.tsx | 117 +++++++++++++----- .../components/settings-v2/settings-v2.css | 58 ++++++++- 2 files changed, 139 insertions(+), 36 deletions(-) diff --git a/packages/app/src/components/settings-v2/models.tsx b/packages/app/src/components/settings-v2/models.tsx index a3f058670e4a..482e8cbffbd4 100644 --- a/packages/app/src/components/settings-v2/models.tsx +++ b/packages/app/src/components/settings-v2/models.tsx @@ -5,9 +5,12 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" import { type Component, For, Show } from "solid-js" +import { createStore } from "solid-js/store" import { useLanguage } from "@/context/language" import { useModels } from "@/context/models" +import { useServerSDK } from "@/context/server-sdk" import { popularProviders } from "@/hooks/use-providers" +import { Persist, persisted } from "@/utils/persist" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import "./settings-v2.css" @@ -19,6 +22,11 @@ const PROVIDER_ICON_SIZE = 16 export const SettingsModelsV2: Component = () => { const language = useLanguage() const models = useModels() + const serverSdk = useServerSDK() + const [store, setStore] = persisted( + Persist.serverGlobal(serverSdk().scope, "settings-v2.models.providers"), + createStore({ collapsed: {} as Record }), + ) const list = useFilteredList({ items: (_filter) => models.list(), @@ -94,41 +102,82 @@ export const SettingsModelsV2: Component = () => { } > - {(group) => ( -
    -
    - -

    {group.items[0].provider.name}

    + {(group) => { + const searching = () => list.filter().length > 0 + const expanded = () => searching() || !store.collapsed[group.category] + + return ( +
    +

    + +

    + + + + {(item) => { + const key = { providerID: item.provider.id, modelID: item.id } + return ( + +
    + { + models.setVisibility(key, checked) + }} + hideLabel + > + {item.name} + +
    +
    + ) + }} +
    +
    +
    - - - {(item) => { - const key = { providerID: item.provider.id, modelID: item.id } - return ( - -
    - { - models.setVisibility(key, checked) - }} - hideLabel - > - {item.name} - -
    -
    - ) - }} -
    -
    -
    - )} + ) + }} diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index 64a55ea3f47d..c4d47344a26c 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -373,14 +373,64 @@ } .settings-v2-models { - gap: 24px; + gap: 12px; +} + +.settings-v2-models .settings-v2-section { + gap: 12px; +} + +.settings-v2-models .settings-v2-section[data-expanded] { + padding-bottom: 12px; } .settings-v2-models-group-header { + display: flex; + align-items: center; + height: 28px; +} + +.settings-v2-models-group-trigger { + display: flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 4px 8px 4px 4px; + border: 0; + border-radius: 4px; + background: transparent; + color: var(--v2-text-text-base); +} + +.settings-v2-models-group-trigger:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; +} + +@media (hover: hover) { + .settings-v2-models-group-trigger:not(:disabled):hover { + background-color: var(--v2-background-bg-layer-02); + } +} + +.settings-v2-models-group-trigger:disabled { + cursor: default; +} + +.settings-v2-models-group-chevron { + display: flex; + width: 20px; + height: 20px; + flex-shrink: 0; + align-items: center; + justify-content: center; + color: var(--v2-icon-icon-muted); +} + +.settings-v2-models-group-label { display: flex; align-items: center; gap: 8px; - padding-bottom: 8px; } .settings-v2-models .settings-v2-section-title { @@ -394,6 +444,10 @@ color: var(--v2-icon-icon-base); } +.settings-v2-models [data-component="settings-v2-list"] { + padding-inline: 16px; +} + .settings-v2-models .settings-v2-section-title + [data-component="settings-v2-list"] { margin-top: 0; } From 8c81f9a40b0eb8ccef911a033ea685e3233ad461 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:12:59 +0800 Subject: [PATCH 120/333] refactor(app): extract v2 settings controllers (#39228) --- .../general-controller-behavior.ts | 70 ++ .../settings-v2/general-controllers.test.ts | 53 ++ .../settings-v2/general-controllers.ts | 173 +++++ .../src/components/settings-v2/general.tsx | 668 +++++++----------- 4 files changed, 554 insertions(+), 410 deletions(-) create mode 100644 packages/app/src/components/settings-v2/general-controller-behavior.ts create mode 100644 packages/app/src/components/settings-v2/general-controllers.test.ts create mode 100644 packages/app/src/components/settings-v2/general-controllers.ts diff --git a/packages/app/src/components/settings-v2/general-controller-behavior.ts b/packages/app/src/components/settings-v2/general-controller-behavior.ts new file mode 100644 index 000000000000..64bd4c118755 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controller-behavior.ts @@ -0,0 +1,70 @@ +import { onCleanup } from "solid-js" + +export type ShellOption = { + path: string + name: string + acceptable: boolean +} + +export type ShellSelectOption = { + id: string + value: string + name: string + terminalOnly: boolean +} + +export function createShellOptions(input: { shells: ShellOption[]; current: string | undefined }) { + const counts = input.shells.reduce((result, shell) => { + result.set(shell.name, (result.get(shell.name) ?? 0) + 1) + return result + }, new Map()) + const options: ShellSelectOption[] = [ + { id: "auto", value: "", name: "", terminalOnly: false }, + ...input.shells.map((shell) => { + const ambiguous = (counts.get(shell.name) ?? 0) > 1 + const name = ambiguous ? shell.path : shell.name + return { + id: shell.path, + value: ambiguous ? shell.path : shell.name, + name, + terminalOnly: !shell.acceptable, + } + }), + ] + if (input.current && !options.some((option) => option.value === input.current)) { + options.push({ id: input.current, value: input.current, name: input.current, terminalOnly: false }) + } + return options +} + +export function createSoundPreviewController(player: (id: string | undefined) => Promise<(() => void) | undefined>) { + let cleanup: (() => void) | undefined + let timeout: ReturnType | undefined + let run = 0 + + const stop = () => { + run += 1 + cleanup?.() + clearTimeout(timeout) + cleanup = undefined + timeout = undefined + } + const play = (id: string | undefined) => { + stop() + if (!id) return + const current = ++run + timeout = setTimeout(() => { + timeout = undefined + void player(id).then((next) => { + if (run === current) { + cleanup = next + return + } + next?.() + }) + }, 100) + } + + onCleanup(stop) + return { play, stop } +} diff --git a/packages/app/src/components/settings-v2/general-controllers.test.ts b/packages/app/src/components/settings-v2/general-controllers.test.ts new file mode 100644 index 000000000000..09c8d21bd538 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, test, vi } from "bun:test" +import { createRoot } from "solid-js" +import { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" + +describe("settings v2 controllers", () => { + test("normalizes shell names and preserves an unavailable configured shell", () => { + expect( + createShellOptions({ + shells: [ + { path: "/bin/bash", name: "bash", acceptable: true }, + { path: "/opt/bash", name: "bash", acceptable: false }, + { path: "/bin/zsh", name: "zsh", acceptable: true }, + ], + current: "fish", + }), + ).toEqual([ + { id: "auto", value: "", name: "", terminalOnly: false }, + { id: "/bin/bash", value: "/bin/bash", name: "/bin/bash", terminalOnly: false }, + { id: "/opt/bash", value: "/opt/bash", name: "/opt/bash", terminalOnly: true }, + { id: "/bin/zsh", value: "zsh", name: "zsh", terminalOnly: false }, + { id: "fish", value: "fish", name: "fish", terminalOnly: false }, + ]) + }) + + test("debounces previews and stops owned audio on disposal", async () => { + vi.useFakeTimers() + try { + const played: string[] = [] + const stopped: string[] = [] + const owned = createRoot((dispose) => ({ + dispose, + preview: createSoundPreviewController(async (id) => { + played.push(id ?? "") + return () => stopped.push(id ?? "") + }), + })) + + owned.preview.play("first") + vi.advanceTimersByTime(99) + expect(played).toEqual([]) + + owned.preview.play("second") + vi.advanceTimersByTime(100) + await Promise.resolve() + expect(played).toEqual(["second"]) + + owned.dispose() + expect(stopped).toEqual(["second"]) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/packages/app/src/components/settings-v2/general-controllers.ts b/packages/app/src/components/settings-v2/general-controllers.ts new file mode 100644 index 000000000000..ae77fa332f22 --- /dev/null +++ b/packages/app/src/components/settings-v2/general-controllers.ts @@ -0,0 +1,173 @@ +import { createMemo, createResource, onMount, type Accessor } from "solid-js" +import type { ColorScheme } from "@opencode-ai/ui/theme/context" +import { useTheme } from "@opencode-ai/ui/theme/context" +import { usePermission } from "@/context/permission" +import { useServerSDK } from "@/context/server-sdk" +import { useServerSync } from "@/context/server-sync" +import { + monoDefault, + monoFontFamily, + monoInput, + sansDefault, + sansFontFamily, + sansInput, + terminalDefault, + terminalFontFamily, + terminalInput, + useSettings, +} from "@/context/settings" +import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { createSoundPreviewController, type ShellOption } from "./general-controller-behavior" + +export { createShellOptions, createSoundPreviewController } from "./general-controller-behavior" +export type { ShellOption, ShellSelectOption } from "./general-controller-behavior" + +export function createPermissionScopeController(sessionID: Accessor) { + const permission = usePermission() + const serverSync = useServerSync() + const directory = createMemo(() => { + const id = sessionID() + if (!id) return undefined + return serverSync().session.lineage.peek(id)?.session.directory + }) + + return { + accepting: createMemo(() => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return false + return permission.isAutoAccepting(id, dir) + }), + enabled: createMemo(() => !!directory()), + set: (checked: boolean) => { + const id = sessionID() + const dir = directory() + if (!id || !dir) return + if (checked) return permission.enableAutoAccept(id, dir) + permission.disableAutoAccept(id, dir) + }, + } +} + +export function createShellSettingsController() { + const serverSdk = useServerSDK() + const serverSync = useServerSync() + const [shells] = createResource( + async () => { + const sdk = serverSdk() + if ((await sdk.protocol) === "v1") return (await sdk.client.pty.shells()).data ?? [] + return [] as ShellOption[] + }, + { initialValue: [] as ShellOption[] }, + ) + const current = createMemo(() => serverSync().data.config.shell ?? "") + + return { + shells: () => shells.latest, + current, + select: (value: string) => { + if (value === current()) return + void serverSync().updateConfig({ shell: value }) + }, + } +} + +export function createAppearanceSettingsController() { + const settings = useSettings() + const theme = useTheme() + const themes = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) + + onMount(() => void theme.loadThemes()) + + return { + scheme: { + current: theme.colorScheme, + select: (value: ColorScheme) => theme.setColorScheme(value), + }, + theme: { + options: themes, + current: createMemo(() => themes().find((option) => option.id === theme.themeId())), + select: (option: { id: string } | null) => option && theme.setTheme(option.id), + }, + fonts: { + ui: createMemo(() => ({ + value: sansInput(settings.appearance.uiFont()), + family: sansFontFamily(settings.appearance.uiFont()), + placeholder: sansDefault, + })), + code: createMemo(() => ({ + value: monoInput(settings.appearance.font()), + family: monoFontFamily(settings.appearance.font()), + placeholder: monoDefault, + })), + terminal: createMemo(() => ({ + value: terminalInput(settings.appearance.terminalFont()), + family: terminalFontFamily(settings.appearance.terminalFont()), + placeholder: terminalDefault, + })), + setUI: (value: string) => settings.appearance.setUIFont(value), + setCode: (value: string) => settings.appearance.setFont(value), + setTerminal: (value: string) => settings.appearance.setTerminalFont(value), + }, + } +} + +const noneSound = { id: "none", label: "sound.option.none" } as const +export const soundOptions = [noneSound, ...SOUND_OPTIONS] +export type SoundSelectOption = (typeof soundOptions)[number] + +export function createSoundSettingsController() { + const settings = useSettings() + const preview = createSoundPreviewController(playSoundById) + const channel = ( + enabled: Accessor, + current: Accessor, + setEnabled: (value: boolean) => void, + set: (id: string) => void, + ) => ({ + current: createMemo(() => + enabled() ? (soundOptions.find((option) => option.id === current()) ?? noneSound) : noneSound, + ), + highlight: (option: SoundSelectOption | undefined) => { + if (!option) return + preview.play(option.id === "none" ? undefined : option.id) + }, + select: (option: SoundSelectOption | null) => { + if (!option) return + if (option.id === "none") { + setEnabled(false) + preview.stop() + return + } + setEnabled(true) + set(option.id) + preview.play(option.id) + }, + }) + + return { + agent: channel( + settings.sounds.agentEnabled, + settings.sounds.agent, + (value) => settings.sounds.setAgentEnabled(value), + (id) => settings.sounds.setAgent(id), + ), + permissions: channel( + settings.sounds.permissionsEnabled, + settings.sounds.permissions, + (value) => settings.sounds.setPermissionsEnabled(value), + (id) => settings.sounds.setPermissions(id), + ), + errors: channel( + settings.sounds.errorsEnabled, + settings.sounds.errors, + (value) => settings.sounds.setErrorsEnabled(value), + (id) => settings.sounds.setErrors(id), + ), + } +} + +export type PermissionScopeController = ReturnType +export type ShellSettingsController = ReturnType +export type AppearanceSettingsController = ReturnType +export type SoundSettingsController = ReturnType diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index 613ac96cbae8..e50bd316c9ab 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -1,182 +1,297 @@ -import { Component, Show, createMemo, createResource, onMount } from "solid-js" +import { Component, Show, createMemo, createResource } from "solid-js" import { createMediaQuery } from "@solid-primitives/media" import { ButtonV2 } from "@opencode-ai/ui/v2/button-v2" import { SelectV2 } from "@opencode-ai/ui/v2/select-v2" import { Switch } from "@opencode-ai/ui/v2/switch-v2" import { TextInputV2 } from "@opencode-ai/ui/v2/text-input-v2" -import { useTheme, type ColorScheme } from "@opencode-ai/ui/theme/context" import { useDialog } from "@opencode-ai/ui/context/dialog" import { useLanguage } from "@/context/language" -import { usePermission } from "@/context/permission" import { usePlatform } from "@/context/platform" -import { useServerSync } from "@/context/server-sync" -import { useServerSDK } from "@/context/server-sdk" import { useUpdaterAction } from "../updater-action" -import { - monoDefault, - monoFontFamily, - monoInput, - sansDefault, - sansFontFamily, - sansInput, - terminalDefault, - terminalFontFamily, - terminalInput, - useSettings, -} from "@/context/settings" -import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" +import { useSettings } from "@/context/settings" import { Link } from "../link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" +import { + createAppearanceSettingsController, + createPermissionScopeController, + createShellOptions, + createShellSettingsController, + createSoundSettingsController, + soundOptions, + type AppearanceSettingsController, + type PermissionScopeController, + type ShellSettingsController, + type SoundSettingsController, +} from "./general-controllers" import "./settings-v2.css" -let demoSoundState = { - cleanup: undefined as (() => void) | undefined, - timeout: undefined as NodeJS.Timeout | undefined, - run: 0, +const schemeOptions: ("system" | "light" | "dark")[] = ["system", "light", "dark"] +const fontSettings = { + ui: { + action: "settings-ui-font", + title: "settings.general.row.uiFont.title", + description: "settings.general.row.uiFont.description", + font: "ui", + input: "setUI", + }, + code: { + action: "settings-code-font", + title: "settings.general.row.font.title", + description: "settings.general.row.font.description", + font: "code", + input: "setCode", + }, + terminal: { + action: "settings-terminal-font", + title: "settings.general.row.terminalFont.title", + description: "settings.general.row.terminalFont.description", + font: "terminal", + input: "setTerminal", + }, +} as const +const soundSettings = { + agent: { + action: "settings-sounds-agent", + title: "settings.general.sounds.agent.title", + description: "settings.general.sounds.agent.description", + }, + permissions: { + action: "settings-sounds-permissions", + title: "settings.general.sounds.permissions.title", + description: "settings.general.sounds.permissions.description", + }, + errors: { + action: "settings-sounds-errors", + title: "settings.general.sounds.errors.title", + description: "settings.general.sounds.errors.description", + }, +} as const + +const PermissionScopeSetting: Component<{ controller: PermissionScopeController }> = (props) => { + const language = useLanguage() + return ( + +
    + +
    +
    + ) } -type ThemeOption = { - id: string - name: string +const ShellSetting: Component<{ controller: ShellSettingsController }> = (props) => { + const language = useLanguage() + const options = createMemo(() => + createShellOptions({ + shells: props.controller.shells(), + current: props.controller.current(), + }), + ) + return ( + + option.value === props.controller.current()) ?? options()[0]} + placement="bottom-end" + gutter={6} + value={(option) => option.id} + label={(option) => { + if (option.id === "auto") return language.t("settings.general.row.shell.autoDefault") + if (!option.terminalOnly) return option.name + return `${option.name} (${language.t("settings.general.row.shell.terminalOnly")})` + }} + onSelect={(option) => option && props.controller.select(option.value)} + /> + + ) +} + +const AppearanceSection: Component<{ controller: AppearanceSettingsController }> = (props) => { + const language = useLanguage() + return ( +
    +

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

    + + + option === props.controller.scheme.current())} + placement="bottom-end" + gutter={6} + label={(option) => { + if (option === "system") return language.t("theme.scheme.system") + if (option === "light") return language.t("theme.scheme.light") + return language.t("theme.scheme.dark") + }} + onSelect={(option) => option && props.controller.scheme.select(option)} + /> + + + + {language.t("settings.general.row.theme.description")}{" "} + + {language.t("common.learnMore")} + + + } + > + option.id} + label={(option) => option.name} + onSelect={props.controller.theme.select} + /> + + + + + + +
    + ) } -type ShellOption = { - path: string - name: string - acceptable: boolean +const FontSetting: Component<{ + kind: "ui" | "code" | "terminal" + fonts: AppearanceSettingsController["fonts"] +}> = (props) => { + const language = useLanguage() + const config = () => fontSettings[props.kind] + return ( + +
    + props.fonts[config().input](event.currentTarget.value)} + placeholder={props.fonts[config().font]().placeholder} + spellcheck={false} + autocorrect="off" + autocomplete="off" + autocapitalize="off" + aria-label={language.t(config().title)} + style={{ "font-family": props.fonts[config().font]().family }} + /> +
    +
    + ) } -type ShellSelectOption = { - id: string - value: string - label: string +const SoundsSection: Component<{ controller: SoundSettingsController }> = (props) => { + const language = useLanguage() + return ( +
    +

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

    + + + + + +
    + ) } -// To prevent audio from overlapping/playing very quickly when navigating the settings menus, -// delay the playback by 100ms during quick selection changes and pause existing sounds. -const stopDemoSound = () => { - demoSoundState.run += 1 - if (demoSoundState.cleanup) { - demoSoundState.cleanup() - } - clearTimeout(demoSoundState.timeout) - demoSoundState.cleanup = undefined +const SoundSetting: Component<{ + kind: "agent" | "permissions" | "errors" + channel: SoundSettingsController["agent"] +}> = (props) => { + const language = useLanguage() + const config = () => soundSettings[props.kind] + return ( + + option.id} + label={(option) => language.t(option.label)} + onHighlight={props.channel.highlight} + onSelect={props.channel.select} + placement="bottom-end" + gutter={6} + /> + + ) } -const playDemoSound = (id: string | undefined) => { - stopDemoSound() - if (!id) return - - const run = ++demoSoundState.run - demoSoundState.timeout = setTimeout(() => { - void playSoundById(id).then((cleanup) => { - if (demoSoundState.run !== run) { - cleanup?.() - return - } - demoSoundState.cleanup = cleanup - }) - }, 100) +const LanguageSetting = () => { + const language = useLanguage() + const options = createMemo(() => + language.locales.map((locale) => ({ + value: locale, + label: language.label(locale), + })), + ) + return ( + + option.value === language.locale())} + value={(option) => option.value} + label={(option) => option.label} + onSelect={(option) => option && language.setLocale(option.value)} + /> + + ) } export const SettingsGeneralV2: Component<{ sessionID?: string }> = (props) => { - const theme = useTheme() const language = useLanguage() - const permission = usePermission() const platform = usePlatform() const dialog = useDialog() const settings = useSettings() - const serverSync = useServerSync() - const serverSdk = useServerSDK() const mobile = createMediaQuery("(max-width: 767px)") - const updater = useUpdaterAction() - - const dir = createMemo(() => { - if (!props.sessionID) return undefined - return serverSync().session.lineage.peek(props.sessionID)?.session.directory - }) - const accepting = createMemo(() => { - const value = dir() - if (!value || !props.sessionID) return false - return permission.isAutoAccepting(props.sessionID, value) - }) - - const toggleAccept = (checked: boolean) => { - const value = dir() - if (!value || !props.sessionID) return - - if (checked) { - permission.enableAutoAccept(props.sessionID, value) - return - } - - permission.disableAutoAccept(props.sessionID, value) - } + const permissionScope = createPermissionScopeController(() => props.sessionID) + const shell = createShellSettingsController() + const appearance = createAppearanceSettingsController() + const sounds = createSoundSettingsController() const desktop = createMemo(() => platform.platform === "desktop") - const themeOptions = createMemo(() => theme.ids().map((id) => ({ id, name: theme.name(id) }))) - - const [shells] = createResource( - async () => { - const sdk = serverSdk() - if ((await sdk.protocol) === "v1") { - return (await sdk.client.pty.shells()).data ?? [] - } - // return (await sdk.api.pty.shells()).data - return [] as ShellOption[] - }, - { initialValue: [] as ShellOption[] }, - ) - const [pinchZoom, { mutate: setPinchZoom }] = createResource( - () => (desktop() && platform.getPinchZoomEnabled ? true : false), + () => desktop() && "getPinchZoomEnabled" in platform, () => Promise.resolve(platform.getPinchZoomEnabled?.() ?? false).catch(() => false), { initialValue: false }, ) - onMount(() => { - void theme.loadThemes() - }) - - const autoOption = { id: "auto", value: "", label: language.t("settings.general.row.shell.autoDefault") } - const currentShell = createMemo(() => serverSync().data.config.shell ?? "") - - const shellOptions = createMemo(() => { - const list = shells.latest - const current = serverSync().data.config.shell - - const nameCounts = new Map() - for (const s of list) { - nameCounts.set(s.name, (nameCounts.get(s.name) || 0) + 1) - } - - const options = [ - autoOption, - ...list.map((s) => { - const ambiguousName = (nameCounts.get(s.name) || 0) > 1 - const text = ambiguousName ? s.path : s.name - const label = s.acceptable ? text : `${text} (${language.t("settings.general.row.shell.terminalOnly")})` - return { - id: s.path, - // Prefer name over path - "bash" is much cleaner than the explicit full route even when it may change due to PATH. - value: ambiguousName ? s.path : s.name, - label, - } - }), - ] - - if (current && !options.some((o) => o.value === current)) { - options.push({ id: current, value: current, label: current }) - } - - return options - }) - const onPinchZoomChange = (checked: boolean) => { setPinchZoom(checked) const update = platform.setPinchZoomEnabled?.(checked) @@ -184,52 +299,6 @@ export const SettingsGeneralV2: Component<{ void update.catch(() => setPinchZoom(!checked)) } - const colorSchemeOptions = createMemo((): { value: ColorScheme; label: string }[] => [ - { value: "system", label: language.t("theme.scheme.system") }, - { value: "light", label: language.t("theme.scheme.light") }, - { value: "dark", label: language.t("theme.scheme.dark") }, - ]) - - const languageOptions = createMemo(() => - language.locales.map((locale) => ({ - value: locale, - label: language.label(locale), - })), - ) - - const noneSound = { id: "none", label: "sound.option.none" } as const - const soundOptions = [noneSound, ...SOUND_OPTIONS] - const mono = () => monoInput(settings.appearance.font()) - const sans = () => sansInput(settings.appearance.uiFont()) - const terminal = () => terminalInput(settings.appearance.terminalFont()) - - const soundSelectProps = ( - enabled: () => boolean, - current: () => string, - setEnabled: (value: boolean) => void, - set: (id: string) => void, - ) => ({ - options: soundOptions, - current: enabled() ? (soundOptions.find((o) => o.id === current()) ?? noneSound) : noneSound, - value: (o: (typeof soundOptions)[number]) => o.id, - label: (o: (typeof soundOptions)[number]) => language.t(o.label), - onHighlight: (option: (typeof soundOptions)[number] | undefined) => { - if (!option) return - playDemoSound(option.id === "none" ? undefined : option.id) - }, - onSelect: (option: (typeof soundOptions)[number] | null) => { - if (!option) return - if (option.id === "none") { - setEnabled(false) - stopDemoSound() - return - } - setEnabled(true) - set(option.id) - playDemoSound(option.id) - }, - }) - const InterfaceSection = () => ( settings.general.dismissNewInterfaceNotice()} /> ) const GeneralSection = () => (
    - - o.value === language.locale())} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && language.setLocale(option.value)} - /> - + - -
    - -
    -
    + - - o.value === currentShell()) ?? autoOption} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.label} - onSelect={(option) => { - if (!option) return - if (option.value === currentShell()) return - serverSync().updateConfig({ shell: option.value }) - }} - /> - + ) - const AppearanceSection = () => ( -
    -

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

    - - - - o.value === theme.colorScheme())} - placement="bottom-end" - gutter={6} - value={(o) => o.value} - label={(o) => o.label} - onSelect={(option) => option && theme.setColorScheme(option.value)} - /> - - - - {language.t("settings.general.row.theme.description")}{" "} - - {language.t("common.learnMore")} - - - } - > - o.id === theme.themeId())} - placement="bottom-end" - gutter={6} - value={(o) => o.id} - label={(o) => o.name} - onSelect={(option) => { - if (!option) return - theme.setTheme(option.id) - }} - /> - - - -
    - settings.appearance.setUIFont(event.currentTarget.value)} - placeholder={sansDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.uiFont.title")} - style={{ "font-family": sansFontFamily(settings.appearance.uiFont()) }} - /> -
    -
    - - -
    - settings.appearance.setFont(event.currentTarget.value)} - placeholder={monoDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.font.title")} - style={{ "font-family": monoFontFamily(settings.appearance.font()) }} - /> -
    -
    - - -
    - settings.appearance.setTerminalFont(event.currentTarget.value)} - placeholder={terminalDefault} - spellcheck={false} - autocorrect="off" - autocomplete="off" - autocapitalize="off" - aria-label={language.t("settings.general.row.terminalFont.title")} - style={{ "font-family": terminalFontFamily(settings.appearance.terminalFont()) }} - /> -
    -
    -
    -
    - ) - const NotificationsSection = () => (

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

    @@ -576,68 +486,6 @@ export const SettingsGeneralV2: Component<{
    ) - const SoundsSection = () => ( -
    -

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

    - - - - settings.sounds.agentEnabled(), - () => settings.sounds.agent(), - (value) => settings.sounds.setAgentEnabled(value), - (id) => settings.sounds.setAgent(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.permissionsEnabled(), - () => settings.sounds.permissions(), - (value) => settings.sounds.setPermissionsEnabled(value), - (id) => settings.sounds.setPermissions(id), - )} - placement="bottom-end" - gutter={6} - /> - - - - settings.sounds.errorsEnabled(), - () => settings.sounds.errors(), - (value) => settings.sounds.setErrorsEnabled(value), - (id) => settings.sounds.setErrors(id), - )} - placement="bottom-end" - gutter={6} - /> - - -
    - ) - const UpdatesSection = () => (

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

    @@ -659,7 +507,7 @@ export const SettingsGeneralV2: Component<{ title={language.t("settings.updates.row.check.title")} description={language.t("settings.updates.row.check.description")} > - + updater.run()}> {language.t(updater.action().label)} @@ -704,11 +552,11 @@ export const SettingsGeneralV2: Component<{ - + - + From 1ead8d84a2f98caaf527a66e86e7606292210780 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 28 Jul 2026 16:28:59 +0800 Subject: [PATCH 121/333] fix(app): localize home session suspense (#39285) --- .../app/src/pages/home/home-sessions-view.tsx | 38 +++++++++---------- packages/app/src/pages/home/home-sessions.tsx | 1 - 2 files changed, 19 insertions(+), 20 deletions(-) diff --git a/packages/app/src/pages/home/home-sessions-view.tsx b/packages/app/src/pages/home/home-sessions-view.tsx index 461322ae4382..72f2f37616ae 100644 --- a/packages/app/src/pages/home/home-sessions-view.tsx +++ b/packages/app/src/pages/home/home-sessions-view.tsx @@ -1,5 +1,5 @@ import type { Session } from "@opencode-ai/sdk/v2/client" -import { type Accessor, createMemo, For, Show } from "solid-js" +import { type Accessor, createMemo, For, Show, Suspense } 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" @@ -39,7 +39,6 @@ function isBackgroundOpen(event: MouseEvent) { export type HomeSessionsViewProps = { language: ReturnType groups: Accessor - loading: Accessor showProjectName: Accessor server: Accessor canCreateSession: Accessor @@ -81,20 +80,22 @@ export function HomeSessionsView(props: HomeSessionsViewProps) { >
    - 0 && props.canCreateSession()}> -
    - - {props.language.t("command.session.new")} - -
    -
    + + 0 && props.canCreateSession()}> +
    + + {props.language.t("command.session.new")} + +
    +
    +
    ) diff --git a/packages/app/src/pages/home/home-sessions.tsx b/packages/app/src/pages/home/home-sessions.tsx index 2e3828fd8c7a..7bda4dd3630f 100644 --- a/packages/app/src/pages/home/home-sessions.tsx +++ b/packages/app/src/pages/home/home-sessions.tsx @@ -12,7 +12,6 @@ export function HomeSessions(props: { Date: Tue, 28 Jul 2026 17:29:27 +0800 Subject: [PATCH 122/333] feat(desktop): add opt-in v2 sidecar (#39286) --- .github/workflows/publish.yml | 4 - .../desktop/electron-builder.config.test.ts | 33 +++++ packages/desktop/electron-builder.config.ts | 11 +- packages/desktop/scripts/prebuild.ts | 3 +- packages/desktop/scripts/predev.ts | 2 + packages/desktop/scripts/utils.ts | 64 ++++++--- packages/desktop/src/main/background-cli.ts | 125 ++++++++++++++++++ packages/desktop/src/main/index.ts | 75 +++++++---- packages/desktop/src/main/server.ts | 4 +- 9 files changed, 264 insertions(+), 57 deletions(-) create mode 100644 packages/desktop/src/main/background-cli.ts diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 037020c03af1..15f865b742fe 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -219,7 +219,6 @@ jobs: build-electron: needs: - - build-cli - version if: github.repository == 'anomalyco/opencode' continue-on-error: false @@ -316,10 +315,7 @@ jobs: env: OPENCODE_VERSION: ${{ needs.version.outputs.version }} OPENCODE_CHANNEL: ${{ (github.ref_name == 'beta' && 'beta') || 'prod' }} - OPENCODE_CLI_ARTIFACT: ${{ (runner.os == 'Windows' && 'opencode-cli-windows') || 'opencode-cli' }} RUST_TARGET: ${{ matrix.settings.target }} - GH_TOKEN: ${{ github.token }} - GITHUB_RUN_ID: ${{ github.run_id }} - name: Build run: bun run build diff --git a/packages/desktop/electron-builder.config.test.ts b/packages/desktop/electron-builder.config.test.ts index 4ae53fa043d2..3fb1adb6c175 100644 --- a/packages/desktop/electron-builder.config.test.ts +++ b/packages/desktop/electron-builder.config.test.ts @@ -56,3 +56,36 @@ test("keeps a hidden prod launcher for old Linux pins", async () => { expect(desktop).toContain("StartupWMClass=ai.opencode.desktop") expect(desktop).toContain("NoDisplay=true") }) + +test("bundles the CLI outside the dev app archive", async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = "dev" + const module = await import("./electron-builder.config.ts?cli-resource") + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.files).toContain("!resources/opencode-cli*") + expect(config.extraResources).toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) +}) + +for (const channel of ["beta", "prod"] as const) { + test(`does not bundle the CLI in ${channel} builds`, async () => { + const previous = process.env.OPENCODE_CHANNEL + process.env.OPENCODE_CHANNEL = channel + const module = await import(`./electron-builder.config.ts?no-cli-resource=${channel}`) + const config = module.default as Configuration + if (previous === undefined) delete process.env.OPENCODE_CHANNEL + else process.env.OPENCODE_CHANNEL = previous + + expect(config.extraResources).not.toContainEqual({ + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }) + }) +} diff --git a/packages/desktop/electron-builder.config.ts b/packages/desktop/electron-builder.config.ts index a10a757dbb8b..508c0df5e914 100644 --- a/packages/desktop/electron-builder.config.ts +++ b/packages/desktop/electron-builder.config.ts @@ -55,8 +55,17 @@ const getBase = (appId: string): Configuration => ({ extraMetadata: { desktopName: `${appId}.desktop`, }, - files: ["out/**/*", "resources/**/*"], + files: ["out/**/*", "resources/**/*", "!resources/opencode-cli*"], extraResources: [ + ...(channel === "dev" + ? [ + { + from: "resources/", + to: "", + filter: ["opencode-cli*"], + }, + ] + : []), { from: "native/", to: "native/", diff --git a/packages/desktop/scripts/prebuild.ts b/packages/desktop/scripts/prebuild.ts index 79b0e30afcd0..636850a38757 100644 --- a/packages/desktop/scripts/prebuild.ts +++ b/packages/desktop/scripts/prebuild.ts @@ -1,10 +1,11 @@ #!/usr/bin/env bun import { $ } from "bun" -import { resolveChannel } from "./utils" +import { downloadCliToResources, resolveChannel } from "./utils" const channel = resolveChannel() await $`bun ./scripts/copy-icons.ts ${channel}` await $`bun ./scripts/copy-metainfo.ts ${channel}` await $`cd ../opencode && bun script/build-node.ts` +if (channel === "dev") await downloadCliToResources() diff --git a/packages/desktop/scripts/predev.ts b/packages/desktop/scripts/predev.ts index 4a9682306de1..bfa399e4e537 100644 --- a/packages/desktop/scripts/predev.ts +++ b/packages/desktop/scripts/predev.ts @@ -1,7 +1,9 @@ import { $ } from "bun" +import { downloadCliToResources } from "./utils" await $`bun run install-electron` await $`bun ./scripts/copy-icons.ts ${process.env.OPENCODE_CHANNEL ?? "dev"}` await $`cd ../opencode && bun script/build-node.ts` +await downloadCliToResources() diff --git a/packages/desktop/scripts/utils.ts b/packages/desktop/scripts/utils.ts index 19b96b0a161f..bf728758ed14 100644 --- a/packages/desktop/scripts/utils.ts +++ b/packages/desktop/scripts/utils.ts @@ -1,4 +1,9 @@ import { $ } from "bun" +import { chmod, copyFile, mkdtemp, rm } from "node:fs/promises" +import { tmpdir } from "node:os" +import { join } from "node:path" + +const CLI_VERSION = "0.0.0-next-16350" export type Channel = "dev" | "beta" | "prod" @@ -8,36 +13,42 @@ export function resolveChannel(): Channel { return "dev" } -export const SIDECAR_BINARIES: Array<{ rustTarget: string; ocBinary: string; assetExt: string }> = [ +export const CLI_BINARIES: Array<{ rustTarget: string; package: string; os: string; cpu: string }> = [ { rustTarget: "aarch64-apple-darwin", - ocBinary: "opencode-darwin-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-arm64", + os: "darwin", + cpu: "arm64", }, { rustTarget: "x86_64-apple-darwin", - ocBinary: "opencode-darwin-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-darwin-x64-baseline", + os: "darwin", + cpu: "x64", }, { rustTarget: "aarch64-pc-windows-msvc", - ocBinary: "opencode-windows-arm64", - assetExt: "zip", + package: "@opencode-ai/cli-windows-arm64", + os: "win32", + cpu: "arm64", }, { rustTarget: "x86_64-pc-windows-msvc", - ocBinary: "opencode-windows-x64-baseline", - assetExt: "zip", + package: "@opencode-ai/cli-windows-x64-baseline", + os: "win32", + cpu: "x64", }, { rustTarget: "x86_64-unknown-linux-gnu", - ocBinary: "opencode-linux-x64-baseline", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-x64-baseline", + os: "linux", + cpu: "x64", }, { rustTarget: "aarch64-unknown-linux-gnu", - ocBinary: "opencode-linux-arm64", - assetExt: "tar.gz", + package: "@opencode-ai/cli-linux-arm64", + os: "linux", + cpu: "arm64", }, ] @@ -51,24 +62,33 @@ function nativeTarget() { throw new Error(`Unsupported platform: ${platform}/${arch}`) } -export function getCurrentSidecar(target = RUST_TARGET ?? nativeTarget()) { - const binaryConfig = SIDECAR_BINARIES.find((b) => b.rustTarget === target) - if (!binaryConfig) throw new Error(`Sidecar configuration not available for Rust target '${target}'`) +export function getCurrentCli(target = RUST_TARGET ?? nativeTarget()) { + const binaryConfig = CLI_BINARIES.find((item) => item.rustTarget === target) + if (!binaryConfig) throw new Error(`CLI configuration not available for target '${target}'`) return binaryConfig } -export async function copyBinaryToSidecarFolder(source: string) { - const dir = `resources` - await $`mkdir -p ${dir}` - const dest = windowsify(`${dir}/opencode-cli`) - await $`cp ${source} ${dest}` +export async function downloadCliToResources() { + const cli = getCurrentCli() + const directory = await mkdtemp(join(tmpdir(), "opencode-cli-")) + const dest = windowsify("resources/opencode-cli") + try { + await $`bun install --no-save --cwd ${directory} ${`${cli.package}@${CLI_VERSION}`} ${`--os=${cli.os}`} ${`--cpu=${cli.cpu}`}` + await copyFile( + join(directory, "node_modules", cli.package, "bin", cli.os === "win32" ? "opencode2.exe" : "opencode2"), + dest, + ) + } finally { + await rm(directory, { recursive: true, force: true }) + } + if (process.platform !== "win32") await chmod(dest, 0o755) if (process.platform === "win32" && process.env.GITHUB_ACTIONS === "true") { await $`pwsh -NoLogo -NoProfile -ExecutionPolicy Bypass -File ../../script/sign-windows.ps1 ${dest}` } if (process.platform === "darwin") await $`codesign --force --sign - ${dest}` - console.log(`Copied ${source} to ${dest}`) + console.log(`Copied ${cli.package} to ${dest}`) } export function windowsify(path: string) { diff --git a/packages/desktop/src/main/background-cli.ts b/packages/desktop/src/main/background-cli.ts new file mode 100644 index 000000000000..66602d51f116 --- /dev/null +++ b/packages/desktop/src/main/background-cli.ts @@ -0,0 +1,125 @@ +import { execFile } from "node:child_process" +import { existsSync } from "node:fs" +import { chmod, copyFile, mkdir, rename, rm } from "node:fs/promises" +import { dirname, join } from "node:path" +import { fileURLToPath } from "node:url" +import { promisify } from "node:util" +import { app } from "electron" + +const execFileAsync = promisify(execFile) +const root = dirname(fileURLToPath(import.meta.url)) +const stateHome = process.env.XDG_STATE_HOME +const desktopStateNames = ["ai.opencode.desktop.dev", "ai.opencode.desktop.beta", "ai.opencode.desktop"] + +type Logger = { + log(message: string, meta?: Record): void + error(message: string, meta?: Record): void +} + +export async function startBackgroundCli(logger: Logger, shellStateHome?: string) { + const bundled = app.isPackaged + ? join(process.resourcesPath, executableName()) + : join(root, "../../resources", executableName()) + logger.log("v2 CLI executable resolved", { bundled, packaged: app.isPackaged }) + const version = await run(bundled, ["--version"], logger) + const binary = app.isPackaged ? await installCli(bundled, version, logger) : bundled + + const candidates = [ + ...new Set([stateHome, shellStateHome, ...desktopStateNames.map((name) => join(app.getPath("appData"), name))]), + ].filter((candidate) => candidate === undefined || existsSync(candidate)) + const discovered = await Promise.all( + candidates.map(async (candidate) => ({ + stateHome: candidate, + url: serviceUrl(await run(binary, ["service", "status"], logger, { stateHome: candidate })), + })), + ) + const found = discovered.find((candidate) => candidate.url !== undefined) + logger.log("v2 CLI background instance checked", { + detected: Boolean(found), + ...endpoint(found?.url), + }) + + const daemonStateHome = found?.stateHome ?? stateHome + const url = await run(binary, ["service", "start"], logger, { stateHome: daemonStateHome }) + const password = await run(binary, ["service", "get", "password"], logger, { + redact: true, + stateHome: daemonStateHome, + }) + logger.log("v2 CLI background service ready", { + existing: Boolean(found), + username: "opencode", + ...endpoint(url), + }) + return { + url, + username: "opencode", + password, + } +} + +async function installCli(source: string, version: string, logger: Logger) { + const directory = join(app.getPath("userData"), "cli", version.replace(/[^a-zA-Z0-9._-]/g, "-")) + const destination = join(directory, executableName()) + if (existsSync(destination)) { + logger.log("v2 CLI staged executable reused", { path: destination, version }) + return destination + } + + const temp = destination + `.${process.pid}.tmp` + await mkdir(directory, { recursive: true }) + await copyFile(source, temp) + if (process.platform !== "win32") await chmod(temp, 0o755) + await rename(temp, destination).catch(async (error) => { + await rm(temp, { force: true }) + throw error + }) + logger.log("v2 CLI executable staged", { source, path: destination, version }) + return destination +} + +async function run( + binary: string, + args: string[], + logger: Logger, + options: { redact?: boolean; stateHome?: string } = {}, +) { + logger.log("v2 CLI command started", { binary, args }) + const env = { ...process.env } + if (options.stateHome === undefined) delete env.XDG_STATE_HOME + else env.XDG_STATE_HOME = options.stateHome + return execFileAsync(binary, args, { env, windowsHide: true }).then( + (result) => { + const stdout = result.stdout.trim() + const stderr = result.stderr.trim() + logger.log("v2 CLI command completed", { args, stdout: options.redact ? "[redacted]" : stdout, stderr }) + return stdout + }, + (error: unknown) => { + const output = error as { stdout?: string; stderr?: string } + logger.error("v2 CLI command failed", { + args, + error: error instanceof Error ? error.message : String(error), + stdout: options.redact && output.stdout ? "[redacted]" : (output.stdout?.trim() ?? ""), + stderr: output.stderr?.trim() ?? "", + }) + throw error + }, + ) +} + +function serviceUrl(status: string) { + if (URL.canParse(status)) return status + if (!status.startsWith("running ")) return + const url = status.slice("running ".length).trim() + return URL.canParse(url) ? url : undefined +} + +function endpoint(url: string | undefined) { + if (!url || !URL.canParse(url)) return {} + const parsed = new URL(url) + return { url, hostname: parsed.hostname, port: parsed.port } +} + +function executableName() { + return process.platform === "win32" ? "opencode-cli.exe" : "opencode-cli" +} diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index c7c1643092d1..67696a5470a0 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -48,6 +48,7 @@ import { registerWslIpcHandlers } from "./wsl/ipc" import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" +import { startBackgroundCli } from "./background-cli" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -60,6 +61,7 @@ const APP_IDS: Record = { prod: "ai.opencode.desktop", } const TEST_ONBOARDING = process.env.OPENCODE_TEST_ONBOARDING === "1" +const SIDECAR_VERSION = process.env.OPENCODE_SIDECAR_V2 === "1" ? "v2" : "v1" const jsCallStackFeature = "DocumentPolicyIncludeJSCallStacksInCrashReports" let logger: ReturnType @@ -198,7 +200,7 @@ const main = Effect.gen(function* () { return } - preferAppEnv(app.getPath("userData")) + const shellEnv = preferAppEnv(app.getPath("userData")) app.on("second-instance", (_event: Event, argv: string[]) => { const urls = argv.filter((arg: string) => arg.startsWith("opencode://")) @@ -312,38 +314,55 @@ const main = Effect.gen(function* () { ), ) - const port = yield* Effect.gen(function* () { - const fromEnv = process.env.OPENCODE_PORT - if (fromEnv) { - const parsed = Number.parseInt(fromEnv, 10) - if (!Number.isNaN(parsed)) return parsed - } + const loadingTask = yield* Effect.gen(function* () { + logger.log("sidecar connection started", { version: SIDECAR_VERSION }) + + ensureLoopbackNoProxy() + useEnvProxy() - const res = yield* Deferred.make() - const server = createServer() - server.on("error", (e) => Deferred.failSync(res, () => e)) - server.listen(0, "127.0.0.1", () => { - const address = server.address() - if (typeof address !== "object" || !address) { - server.close() - Deferred.failSync(res, () => new Error("Failed to get port")) - return + if (SIDECAR_VERSION === "v2") { + logger.log("spawning v2 sidecar") + const sidecar = yield* Effect.promise(() => startBackgroundCli(logger, shellEnv?.XDG_STATE_HOME)) + yield* Deferred.succeed(serverReady, { + url: sidecar.url, + username: sidecar.username, + password: sidecar.password, + }) + + if (process.platform === "win32") { + void wslServers.initialize().catch((error) => logger.error("wsl server initialization failed", error)) } - const port = address.port - server.close(() => Effect.runSync(Deferred.succeed(res, port))) - }) - return yield* Deferred.await(res) - }) - const hostname = "127.0.0.1" - const url = `http://${hostname}:${port}` - const password = randomUUID() + logger.log("loading task finished") + return + } - const loadingTask = yield* Effect.gen(function* () { - logger.log("sidecar connection started", { url }) + const port = yield* Effect.gen(function* () { + const fromEnv = process.env.OPENCODE_PORT + if (fromEnv) { + const parsed = Number.parseInt(fromEnv, 10) + if (!Number.isNaN(parsed)) return parsed + } - ensureLoopbackNoProxy() - useEnvProxy() + const res = yield* Deferred.make() + const socket = createServer() + socket.on("error", (e) => Deferred.failSync(res, () => e)) + socket.listen(0, "127.0.0.1", () => { + const address = socket.address() + if (typeof address !== "object" || !address) { + socket.close() + Deferred.failSync(res, () => new Error("Failed to get port")) + return + } + const port = address.port + socket.close(() => Effect.runSync(Deferred.succeed(res, port))) + }) + + return yield* Deferred.await(res) + }) + const hostname = "127.0.0.1" + const url = `http://${hostname}:${port}` + const password = randomUUID() logger.log("spawning sidecar", { url }) const { listener, health } = yield* Effect.promise(() => diff --git a/packages/desktop/src/main/server.ts b/packages/desktop/src/main/server.ts index c620b76ae881..ae1a98efdfaf 100644 --- a/packages/desktop/src/main/server.ts +++ b/packages/desktop/src/main/server.ts @@ -43,13 +43,15 @@ export function setDefaultServerUrl(url: string | null) { export function preferAppEnv(userDataPath: string) { const shell = process.platform === "win32" ? null : getUserShell() + const shellEnv = shell ? loadShellEnv(shell, getLogger()) : null Object.assign(process.env, { - ...(shell ? loadShellEnv(shell, getLogger()) : null), + ...shellEnv, OPENCODE_EXPERIMENTAL_ICON_DISCOVERY: "true", OPENCODE_EXPERIMENTAL_FILEWATCHER: "true", OPENCODE_CLIENT: "desktop", XDG_STATE_HOME: process.env.XDG_STATE_HOME ?? userDataPath, }) + return shellEnv } export async function spawnLocalServer( From 7c898249a23cce3ebe3ce49008b03b5e14d9f4ad Mon Sep 17 00:00:00 2001 From: Robin Andrew Date: Tue, 28 Jul 2026 12:05:57 +0100 Subject: [PATCH 123/333] fix(desktop): patch @dnd-kit/solid to preserve core scroll plugins (#38119) Co-authored-by: Brendan Allan Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- package.json | 1 + patches/@dnd-kit%2Fdom@0.5.0.patch | 40 ++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) create mode 100644 patches/@dnd-kit%2Fdom@0.5.0.patch diff --git a/package.json b/package.json index 662f9a4c3c23..e7e081a4d316 100644 --- a/package.json +++ b/package.json @@ -143,6 +143,7 @@ "@types/node": "catalog:" }, "patchedDependencies": { + "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", "@ff-labs/fff-bun@0.9.3": "patches/@ff-labs%2Ffff-bun@0.9.3.patch", "@npmcli/agent@4.0.2": "patches/@npmcli%2Fagent@4.0.2.patch", "@silvia-odwyer/photon-node@0.3.4": "patches/@silvia-odwyer%2Fphoton-node@0.3.4.patch", diff --git a/patches/@dnd-kit%2Fdom@0.5.0.patch b/patches/@dnd-kit%2Fdom@0.5.0.patch new file mode 100644 index 000000000000..7c56793f9ad5 --- /dev/null +++ b/patches/@dnd-kit%2Fdom@0.5.0.patch @@ -0,0 +1,40 @@ +--- a/index.js ++++ b/index.js +@@ -2195,6 +2195,17 @@ + sensors, + modifiers + })); ++ } ++ get plugins() { ++ return super.plugins; ++ } ++ set plugins(plugins) { ++ super.plugins = [ ++ ScrollListener, ++ Scroller, ++ StyleInjector, ++ ...plugins ++ ]; + } + }; + var _element_dec, _handle_dec, _c, _init5, _handle, _element; +--- a/index.cjs ++++ b/index.cjs +@@ -2196,6 +2196,17 @@ + sensors, + modifiers + })); ++ } ++ get plugins() { ++ return super.plugins; ++ } ++ set plugins(plugins) { ++ super.plugins = [ ++ ScrollListener, ++ Scroller, ++ StyleInjector, ++ ...plugins ++ ]; + } + }; + var _element_dec, _handle_dec, _c, _init5, _handle, _element; From be93cec9bae8e4bdaaca3bd5cc015f011d71782b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 19:07:19 +0800 Subject: [PATCH 124/333] fix(app): guard reentrant Solid cleanup (#39261) Co-authored-by: Jack --- .../test-browser/solid-router-cleanup.test.ts | 64 +++++++ patches/solid-js@1.9.10.patch | 156 ++++++++++++++++-- 2 files changed, 210 insertions(+), 10 deletions(-) create mode 100644 packages/app/test-browser/solid-router-cleanup.test.ts diff --git a/packages/app/test-browser/solid-router-cleanup.test.ts b/packages/app/test-browser/solid-router-cleanup.test.ts new file mode 100644 index 000000000000..5795fa5648c3 --- /dev/null +++ b/packages/app/test-browser/solid-router-cleanup.test.ts @@ -0,0 +1,64 @@ +import { expect, test } from "bun:test" +import { MetaProvider, Title } from "@solidjs/meta" +import { MemoryRouter, Route, createMemoryHistory, useParams } from "@solidjs/router" +import { createMemo } from "solid-js" +import { createComponent, render } from "solid-js/web" + +test("route cleanup cannot invalidate an owner list being disposed", async () => { + const host = document.createElement("div") + document.body.append(host) + const history = createMemoryHistory() + + const RepoPage = () => { + const params = useParams<{ id?: string }>() + const title = createMemo(() => params.id ?? "") + const button = document.createElement("button") + button.textContent = "Back" + button.addEventListener("click", () => history.set({ value: "/", scroll: false, replace: false })) + return [ + createComponent(Title, { + get children() { + return title() + }, + }), + button, + ] + } + + const HomePage = () => { + const button = document.createElement("button") + button.textContent = "Go" + button.addEventListener("click", () => history.set({ value: "/project", scroll: false, replace: false })) + return button + } + + const App = () => + createComponent(MetaProvider, { + get children() { + return createComponent(MemoryRouter, { + history, + get children() { + return [ + createComponent(Route, { path: "/", component: HomePage }), + createComponent(Route, { path: "/:id", component: RepoPage }), + ] + }, + }) + }, + }) + + const dispose = render(() => createComponent(App, {}), host) + const go = host.querySelector("button") + expect(go?.textContent).toBe("Go") + go?.click() + await new Promise((resolve) => setTimeout(resolve, 0)) + + const back = host.querySelector("button") + expect(back?.textContent).toBe("Back") + back?.click() + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(host.querySelector("button")?.textContent).toBe("Go") + dispose() + host.remove() +}) diff --git a/patches/solid-js@1.9.10.patch b/patches/solid-js@1.9.10.patch index e4e38c2e6840..0571d5c8c670 100644 --- a/patches/solid-js@1.9.10.patch +++ b/patches/solid-js@1.9.10.patch @@ -1,11 +1,5 @@ -diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-6fcb6b48d6947d2c b/.bun-tag-6fcb6b48d6947d2c -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 -diff --git a/Users/brendonovich/github.com/anomalyco/opencode/node_modules/solid-js/.bun-tag-b272f631c12927b0 b/.bun-tag-b272f631c12927b0 -new file mode 100644 -index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/dist/dev.cjs b/dist/dev.cjs -index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db42502b976 100644 +index 7104749..dc3eac9 100644 --- a/dist/dev.cjs +++ b/dist/dev.cjs @@ -764,6 +764,8 @@ function runComputation(node, value, time) { @@ -17,8 +11,33 @@ index 7104749486e4361e8c4ee7836a8046582cec7aa1..0501eb1ec5d13b81ecb13a5ac1a82db4 Transition.sources.add(node); node.tValue = nextValue; } else node.value = nextValue; +@@ -987,18 +989,21 @@ function cleanNode(node) { + } + } + if (node.tOwned) { +- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]); ++ const tOwned = node.tOwned; + delete node.tOwned; ++ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]); + } + if (Transition && Transition.running && node.pure) { + reset(node, true); + } else if (node.owned) { +- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]); ++ const owned = node.owned; + node.owned = null; ++ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]); + } + if (node.cleanups) { +- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i](); ++ const cleanups = node.cleanups; + node.cleanups = null; ++ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i](); + } + if (Transition && Transition.running) node.tState = 0;else node.state = 0; + delete node.sourceMap; diff --git a/dist/dev.js b/dist/dev.js -index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d3a78cca3 100644 +index ea5e4bc..a2e2d59 100644 --- a/dist/dev.js +++ b/dist/dev.js @@ -762,6 +762,8 @@ function runComputation(node, value, time) { @@ -30,8 +49,75 @@ index ea5e4bc2fd4f0b3922a73d9134439529dc81339f..4b3ec07e624d20fdd23d6941a4fdde6d Transition.sources.add(node); node.tValue = nextValue; } else node.value = nextValue; +@@ -985,18 +987,21 @@ function cleanNode(node) { + } + } + if (node.tOwned) { +- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]); ++ const tOwned = node.tOwned; + delete node.tOwned; ++ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]); + } + if (Transition && Transition.running && node.pure) { + reset(node, true); + } else if (node.owned) { +- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]); ++ const owned = node.owned; + node.owned = null; ++ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]); + } + if (node.cleanups) { +- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i](); ++ const cleanups = node.cleanups; + node.cleanups = null; ++ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i](); + } + if (Transition && Transition.running) node.tState = 0;else node.state = 0; + delete node.sourceMap; +diff --git a/dist/server.cjs b/dist/server.cjs +index e715309..188ba81 100644 +--- a/dist/server.cjs ++++ b/dist/server.cjs +@@ -127,12 +127,14 @@ function onCleanup(fn) { + } + function cleanNode(node) { + if (node.owned) { +- for (let i = 0; i < node.owned.length; i++) cleanNode(node.owned[i]); ++ const owned = node.owned; + node.owned = null; ++ for (let i = 0; i < owned.length; i++) cleanNode(owned[i]); + } + if (node.cleanups) { +- for (let i = 0; i < node.cleanups.length; i++) node.cleanups[i](); ++ const cleanups = node.cleanups; + node.cleanups = null; ++ for (let i = 0; i < cleanups.length; i++) cleanups[i](); + } + } + function catchError(fn, handler) { +diff --git a/dist/server.js b/dist/server.js +index d5f8803..320d9af 100644 +--- a/dist/server.js ++++ b/dist/server.js +@@ -125,12 +125,14 @@ function onCleanup(fn) { + } + function cleanNode(node) { + if (node.owned) { +- for (let i = 0; i < node.owned.length; i++) cleanNode(node.owned[i]); ++ const owned = node.owned; + node.owned = null; ++ for (let i = 0; i < owned.length; i++) cleanNode(owned[i]); + } + if (node.cleanups) { +- for (let i = 0; i < node.cleanups.length; i++) node.cleanups[i](); ++ const cleanups = node.cleanups; + node.cleanups = null; ++ for (let i = 0; i < cleanups.length; i++) cleanups[i](); + } + } + function catchError(fn, handler) { diff --git a/dist/solid.cjs b/dist/solid.cjs -index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd3beb2881 100644 +index 7c133a2..5ef1501 100644 --- a/dist/solid.cjs +++ b/dist/solid.cjs @@ -717,6 +717,8 @@ function runComputation(node, value, time) { @@ -43,8 +129,33 @@ index 7c133a2b254678a84fd61d719fbeffad766e1331..2f68c99f2698210cc0bac62f074cc8cd Transition.sources.add(node); node.tValue = nextValue; } else node.value = nextValue; +@@ -938,18 +940,21 @@ function cleanNode(node) { + } + } + if (node.tOwned) { +- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]); ++ const tOwned = node.tOwned; + delete node.tOwned; ++ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]); + } + if (Transition && Transition.running && node.pure) { + reset(node, true); + } else if (node.owned) { +- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]); ++ const owned = node.owned; + node.owned = null; ++ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]); + } + if (node.cleanups) { +- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i](); ++ const cleanups = node.cleanups; + node.cleanups = null; ++ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i](); + } + if (Transition && Transition.running) node.tState = 0;else node.state = 0; + } diff --git a/dist/solid.js b/dist/solid.js -index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991335a80be 100644 +index 656fd26..6e0038c 100644 --- a/dist/solid.js +++ b/dist/solid.js @@ -715,6 +715,8 @@ function runComputation(node, value, time) { @@ -56,3 +167,28 @@ index 656fd26e7e5c794aa22df19c2377ff5c0591fc29..f08e9f5a7157c3506e5b6922fe2ef991 Transition.sources.add(node); node.tValue = nextValue; } else node.value = nextValue; +@@ -936,18 +938,21 @@ function cleanNode(node) { + } + } + if (node.tOwned) { +- for (i = node.tOwned.length - 1; i >= 0; i--) cleanNode(node.tOwned[i]); ++ const tOwned = node.tOwned; + delete node.tOwned; ++ for (i = tOwned.length - 1; i >= 0; i--) cleanNode(tOwned[i]); + } + if (Transition && Transition.running && node.pure) { + reset(node, true); + } else if (node.owned) { +- for (i = node.owned.length - 1; i >= 0; i--) cleanNode(node.owned[i]); ++ const owned = node.owned; + node.owned = null; ++ for (i = owned.length - 1; i >= 0; i--) cleanNode(owned[i]); + } + if (node.cleanups) { +- for (i = node.cleanups.length - 1; i >= 0; i--) node.cleanups[i](); ++ const cleanups = node.cleanups; + node.cleanups = null; ++ for (i = cleanups.length - 1; i >= 0; i--) cleanups[i](); + } + if (Transition && Transition.running) node.tState = 0;else node.state = 0; + } From e1587588c46a52beff1de6d2735eec386aefa481 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 11:09:22 +0000 Subject: [PATCH 125/333] chore: generate --- bun.lock | 1 + 1 file changed, 1 insertion(+) diff --git a/bun.lock b/bun.lock index b4571f1314a0..084567c2e6d6 100644 --- a/bun.lock +++ b/bun.lock @@ -1083,6 +1083,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", + "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", }, "overrides": { "@opentui/core": "catalog:", From 7336cc5a7bc89f0df96c27dd9659799c428189dc Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 11:21:16 +0000 Subject: [PATCH 126/333] 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 1b85cf68b214..d1bd9c51318e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-RFek0QoEEjsgbqmTE/SxQAmPtYyzs0IPR2ugFn5Okrs=", - "aarch64-linux": "sha256-BmAxapY1YrAFn7mVq3/6A9+6Au5UIvSqBboHMkyJH3I=", - "aarch64-darwin": "sha256-Sx3bGWQqLlgoa/RudJxanjSzhFRNklckT2ffnO2I5F4=", - "x86_64-darwin": "sha256-CMOhiisHNowg06qadvgg4K+60zrynglwiT0qKYQ4NiA=" + "x86_64-linux": "sha256-wLjPbbeOF9OUNNGceYtEJNiBtF6TPjX/lnyMkdW170c=", + "aarch64-linux": "sha256-P7hrfVp3pILY/uAOdJweGg6IcXBLuU69yqko1qd1caw=", + "aarch64-darwin": "sha256-MJzCshCjkbypnW3yhmR8/zCadIopcHUfVT293N0FMY8=", + "x86_64-darwin": "sha256-6fR137gR66eI+VyvDkR2U51yoJ9BuvaK4YoMw+3E2A0=" } } From 017a5977d2107092007623e507fc5c6eb337d3b2 Mon Sep 17 00:00:00 2001 From: usrnk1 <7547651+usrnk1@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:50:58 +0200 Subject: [PATCH 127/333] feat(desktop): remove v2 vertical menu borders (#39317) --- packages/app/src/pages/home/home-projects-view.tsx | 5 ++--- packages/ui/src/v2/components/tabs-v2.css | 2 -- 2 files changed, 2 insertions(+), 5 deletions(-) diff --git a/packages/app/src/pages/home/home-projects-view.tsx b/packages/app/src/pages/home/home-projects-view.tsx index 7ec01bd75feb..d80d4a942fde 100644 --- a/packages/app/src/pages/home/home-projects-view.tsx +++ b/packages/app/src/pages/home/home-projects-view.tsx @@ -482,7 +482,6 @@ function HomeProjectRow( class="pr-16 disabled:opacity-60" classList={{ "bg-v2-background-bg-layer-01 text-v2-text-text-base": sortable.isDragSource(), - "[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)]": sortable.isDragSource(), }} data-selected={props.selected ? "" : undefined} aria-current={props.selected ? "page" : undefined} @@ -590,9 +589,9 @@ function HomeProjectNavButton(props: JSX.ButtonHTMLAttributes class={` flex h-7 min-w-0 w-full shrink-0 cursor-default items-center gap-2 rounded-[6px] bg-transparent px-1.5 text-left text-v2-text-text-muted [font-weight:440] transition-[background-color,color,box-shadow] duration-[120ms] ease-in-out - 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)] + hover:bg-v2-background-bg-layer-01 hover:text-v2-text-text-base 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 + 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:outline-none focus-visible:[box-shadow:inset_0_0_0_0.5px_var(--v2-border-border-muted)] ${local.class ?? ""} diff --git a/packages/ui/src/v2/components/tabs-v2.css b/packages/ui/src/v2/components/tabs-v2.css index 70abf78606a9..70daef139503 100644 --- a/packages/ui/src/v2/components/tabs-v2.css +++ b/packages/ui/src/v2/components/tabs-v2.css @@ -199,7 +199,6 @@ width: 100%; height: 28px; border-radius: 4px; - border: 0.5px solid transparent; box-sizing: border-box; color: var(--v2-text-text-muted); } @@ -222,5 +221,4 @@ [data-slot="tabs-v2-trigger-wrapper"]:has([data-selected]) { background-color: var(--v2-background-bg-layer-03); color: var(--v2-text-text-base); - border: 0.5px solid var(--v2-border-border-muted); } From 7edefb33471ec7c6f96731edadf6746bdab971d2 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:37:58 -0500 Subject: [PATCH 128/333] chore(mcp): upgrade client to 2.0.0 (#39369) Co-authored-by: Aiden Cline Co-authored-by: Aiden Cline --- bun.lock | 14 ++--- bunfig.toml | 2 +- package.json | 2 +- packages/opencode/package.json | 4 +- ...modelcontextprotocol%2Fclient@2.0.0.patch} | 52 +++++++++---------- 5 files changed, 37 insertions(+), 37 deletions(-) rename patches/{@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch => @modelcontextprotocol%2Fclient@2.0.0.patch} (84%) diff --git a/bun.lock b/bun.lock index 084567c2e6d6..7a8f2c16a366 100644 --- a/bun.lock +++ b/bun.lock @@ -592,7 +592,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -666,7 +666,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -1071,10 +1071,10 @@ ], "patchedDependencies": { "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", - "@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch", - "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", + "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.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", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", @@ -1830,11 +1830,11 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-YuuNm5f2TMoFQRje1UqVP8TJRjijCXMz4ckvoVpx1cUXuBEmykWQ2d8R536pek6UKcXT41T5nWc4qR1JFIbEmg=="], + "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], - "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0-beta.5", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-HKbY9XTbsDy1Y6r2I55TGE3JEapM0vg96e1MUmBIF9LGjos5gjhcIrTz1yvBPLg2aFKHjwhUAQfRdrCEnPxNew=="], + "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], - "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0-beta.5", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0-beta.5", "zod": "^4.2.0" } }, "sha512-i1E5l75rQKsgY/AKAIspgMBH1vEL7dqiK7tHr0L+raYcb0SWOziqNGJXGIG6NY4AlXDWIKGJQGB7Nqfs3oUi5g=="], + "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], diff --git a/bunfig.toml b/bunfig.toml index c506ff57c4bf..649db19f1de1 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@modelcontextprotocol/client", "@modelcontextprotocol/core", "@modelcontextprotocol/server", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index e7e081a4d316..30630fdf2fe9 100644 --- a/package.json +++ b/package.json @@ -157,6 +157,6 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@modelcontextprotocol/client@2.0.0-beta.5": "patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch" + "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 293a678e9bb5..d2155eb4ba2b 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -30,7 +30,7 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@modelcontextprotocol/server": "2.0.0-beta.5", + "@modelcontextprotocol/server": "2.0.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -81,7 +81,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/client": "2.0.0-beta.5", + "@modelcontextprotocol/client": "2.0.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch b/patches/@modelcontextprotocol%2Fclient@2.0.0.patch similarity index 84% rename from patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch rename to patches/@modelcontextprotocol%2Fclient@2.0.0.patch index 4205f158317d..833adea8a9a3 100644 --- a/patches/@modelcontextprotocol%2Fclient@2.0.0-beta.5.patch +++ b/patches/@modelcontextprotocol%2Fclient@2.0.0.patch @@ -1,8 +1,8 @@ diff --git a/dist/index.cjs b/dist/index.cjs -index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370a4553855 100644 +index 635f1c0..9214f0b 100644 --- a/dist/index.cjs +++ b/dist/index.cjs -@@ -3154,6 +3154,7 @@ var Client = class extends require_src.Protocol { +@@ -3211,6 +3211,7 @@ var Client = class extends require_src.Protocol { */ async _connectPlainLegacy(transport, options) { await super.connect(transport); @@ -10,7 +10,7 @@ index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370 if (transport.sessionId !== void 0) { const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); -@@ -3170,6 +3171,7 @@ var Client = class extends require_src.Protocol { +@@ -3227,6 +3228,7 @@ var Client = class extends require_src.Protocol { * the handshake; its completion sets the negotiated (legacy) version. */ async _legacyHandshake(transport, options) { @@ -18,7 +18,7 @@ index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370 const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions); try { const offeredVersion = legacyVersions[0]; -@@ -3208,6 +3210,7 @@ var Client = class extends require_src.Protocol { +@@ -3265,6 +3267,7 @@ var Client = class extends require_src.Protocol { await super.connect(transport); const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); @@ -26,7 +26,7 @@ index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370 return; } this._resetConnectionState(); -@@ -5211,10 +5214,32 @@ var StreamableHTTPClientTransport = class { +@@ -5294,10 +5297,32 @@ var StreamableHTTPClientTransport = class { } } async send(message, options) { @@ -61,7 +61,7 @@ index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370 const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { this._startOrAuthSse({ -@@ -5226,8 +5251,8 @@ var StreamableHTTPClientTransport = class { +@@ -5309,8 +5334,8 @@ var StreamableHTTPClientTransport = class { } const headers = await this._commonHeaders(); this._applyBodyDerivedHeaders(headers, message); @@ -71,7 +71,7 @@ index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370 if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; headers.set(name, value); -@@ -5249,8 +5274,14 @@ var StreamableHTTPClientTransport = class { +@@ -5332,8 +5357,14 @@ var StreamableHTTPClientTransport = class { signal }; const response = await (this._fetch ?? fetch)(this._url, init); @@ -87,29 +87,29 @@ index 1c43bac25a1037416fdf2ddfb4534ba0897a2e69..7e2108326f368ccbc48897195d57e370 if (response.status === 401 && this._authProvider) { if (response.headers.has("www-authenticate")) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); -@@ -5264,7 +5295,7 @@ var StreamableHTTPClientTransport = class { - fetchFn: this._fetchWithInit - }); +@@ -5351,7 +5382,7 @@ var StreamableHTTPClientTransport = class { + throw markAuthSeamEscape(error); + } await response.text?.().catch(() => {}); - return this._send(message, options, true, stepUpRetries); + return this._send(message, options, true, stepUpRetries, isSessionRetry); } await response.text?.().catch(() => {}); - if (isAuthRetry) throw new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { -@@ -5284,7 +5315,7 @@ var StreamableHTTPClientTransport = class { + if (isAuthRetry) throw markAuthSeamEscape(new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { +@@ -5371,7 +5402,7 @@ var StreamableHTTPClientTransport = class { statusText: response.statusText, text - }, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError(); + }, stepUpRetries) !== "AUTHORIZED") throw markAuthSeamEscape(new UnauthorizedError()); - return this._send(message, options, isAuthRetry, stepUpRetries + 1); + return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); } } if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { diff --git a/dist/index.mjs b/dist/index.mjs -index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a7749668c39747 100644 +index f02ce3c..0a5a649 100644 --- a/dist/index.mjs +++ b/dist/index.mjs -@@ -3151,6 +3151,7 @@ var Client = class extends Protocol { +@@ -3208,6 +3208,7 @@ var Client = class extends Protocol { */ async _connectPlainLegacy(transport, options) { await super.connect(transport); @@ -117,7 +117,7 @@ index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a77496 if (transport.sessionId !== void 0) { const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); -@@ -3167,6 +3168,7 @@ var Client = class extends Protocol { +@@ -3224,6 +3225,7 @@ var Client = class extends Protocol { * the handshake; its completion sets the negotiated (legacy) version. */ async _legacyHandshake(transport, options) { @@ -125,7 +125,7 @@ index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a77496 const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); try { const offeredVersion = legacyVersions[0]; -@@ -3205,6 +3207,7 @@ var Client = class extends Protocol { +@@ -3262,6 +3264,7 @@ var Client = class extends Protocol { await super.connect(transport); const negotiatedProtocolVersion = this._negotiatedProtocolVersion; if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); @@ -133,7 +133,7 @@ index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a77496 return; } this._resetConnectionState(); -@@ -5208,10 +5211,32 @@ var StreamableHTTPClientTransport = class { +@@ -5291,10 +5294,32 @@ var StreamableHTTPClientTransport = class { } } async send(message, options) { @@ -168,7 +168,7 @@ index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a77496 const { resumptionToken, onresumptiontoken } = options || {}; if (resumptionToken) { this._startOrAuthSse({ -@@ -5223,8 +5248,8 @@ var StreamableHTTPClientTransport = class { +@@ -5306,8 +5331,8 @@ var StreamableHTTPClientTransport = class { } const headers = await this._commonHeaders(); this._applyBodyDerivedHeaders(headers, message); @@ -178,7 +178,7 @@ index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a77496 if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; headers.set(name, value); -@@ -5246,8 +5271,14 @@ var StreamableHTTPClientTransport = class { +@@ -5329,8 +5354,14 @@ var StreamableHTTPClientTransport = class { signal }; const response = await (this._fetch ?? fetch)(this._url, init); @@ -194,19 +194,19 @@ index 77e2389913cb5c5c2b047f95d990ab2892bef923..4b5e4ff2869189d600ca644488a77496 if (response.status === 401 && this._authProvider) { if (response.headers.has("www-authenticate")) { const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); -@@ -5261,7 +5292,7 @@ var StreamableHTTPClientTransport = class { - fetchFn: this._fetchWithInit - }); +@@ -5348,7 +5379,7 @@ var StreamableHTTPClientTransport = class { + throw markAuthSeamEscape(error); + } await response.text?.().catch(() => {}); - return this._send(message, options, true, stepUpRetries); + return this._send(message, options, true, stepUpRetries, isSessionRetry); } await response.text?.().catch(() => {}); - if (isAuthRetry) throw new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { -@@ -5281,7 +5312,7 @@ var StreamableHTTPClientTransport = class { + if (isAuthRetry) throw markAuthSeamEscape(new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { +@@ -5368,7 +5399,7 @@ var StreamableHTTPClientTransport = class { statusText: response.statusText, text - }, stepUpRetries) !== "AUTHORIZED") throw new UnauthorizedError(); + }, stepUpRetries) !== "AUTHORIZED") throw markAuthSeamEscape(new UnauthorizedError()); - return this._send(message, options, isAuthRetry, stepUpRetries + 1); + return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); } From 982a9044c515482e7792039be1db9c71cb572745 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 28 Jul 2026 11:53:35 -0500 Subject: [PATCH 129/333] fix(mcp): restore legacy SDK compatibility (#39373) --- bun.lock | 83 ++- bunfig.toml | 2 +- package.json | 4 +- packages/opencode/package.json | 3 +- packages/opencode/src/cli/cmd/mcp.ts | 147 ++-- packages/opencode/src/mcp/auth.ts | 4 - packages/opencode/src/mcp/catalog.ts | 135 ++-- packages/opencode/src/mcp/index.ts | 82 +-- packages/opencode/src/mcp/oauth-callback.ts | 12 +- packages/opencode/src/mcp/oauth-provider.ts | 85 +-- .../routes/instance/httpapi/groups/mcp.ts | 1 - .../routes/instance/httpapi/handlers/mcp.ts | 2 +- packages/opencode/src/session/tools.ts | 2 +- packages/opencode/src/tool/code-mode.ts | 25 +- .../test/fixture/mcp-lifecycle-stdio.ts | 7 +- .../test/fixture/mcp-session-recovery.ts | 14 +- packages/opencode/test/mcp/catalog.test.ts | 66 +- packages/opencode/test/mcp/headers.test.ts | 25 +- packages/opencode/test/mcp/lifecycle.test.ts | 31 +- .../test/mcp/oauth-auto-connect.test.ts | 8 +- .../opencode/test/mcp/oauth-browser.test.ts | 6 +- .../opencode/test/mcp/oauth-callback.test.ts | 2 +- .../opencode/test/mcp/oauth-provider.test.ts | 41 ++ .../test/mcp/session-recovery.test.ts | 20 - .../test/tool/code-mode-integration.test.ts | 17 +- packages/opencode/test/tool/code-mode.test.ts | 2 +- packages/opencode/test/tool/registry.test.ts | 2 +- packages/sdk/js/src/v2/gen/sdk.gen.ts | 2 - packages/sdk/js/src/v2/gen/types.gen.ts | 1 - ...@modelcontextprotocol%2Fclient@2.0.0.patch | 214 ------ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 629 ++++++++++++++++++ 31 files changed, 1080 insertions(+), 594 deletions(-) delete mode 100644 patches/@modelcontextprotocol%2Fclient@2.0.0.patch create mode 100644 patches/@modelcontextprotocol%2Fsdk@1.29.0.patch diff --git a/bun.lock b/bun.lock index 7a8f2c16a366..96458854bc30 100644 --- a/bun.lock +++ b/bun.lock @@ -592,7 +592,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", @@ -666,7 +666,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@modelcontextprotocol/server": "2.0.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -1073,8 +1072,8 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", "@ai-sdk/xai@3.0.102": "patches/@ai-sdk%2Fxai@3.0.102.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "gcp-metadata@8.1.2": "patches/gcp-metadata@8.1.2.patch", - "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.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", "@ai-sdk/mistral@3.0.51": "patches/@ai-sdk%2Fmistral@3.0.51.patch", @@ -1676,6 +1675,8 @@ "@hey-api/types": ["@hey-api/types@0.1.2", "", {}, "sha512-uNNtiVAWL7XNrV/tFXx7GLY9lwaaDazx1173cGW3+UEaw4RUPsHEmiB4DSpcjNxMIcrctfz2sGKLnVx5PBG2RA=="], + "@hono/node-server": ["@hono/node-server@1.19.14", "", { "peerDependencies": { "hono": "^4" } }, "sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw=="], + "@hono/standard-validator": ["@hono/standard-validator@0.2.0", "", { "peerDependencies": { "@standard-schema/spec": "1.0.0", "hono": ">=3.9.0" } }, "sha512-pFq0UVAnjzXcDAgqFpDeVL3MOUPrlIh/kPqBDvbCYoThVhhS+Vf37VcdsakdOFFGiqoiYVxp3LifXFhGhp/rgQ=="], "@ibm/plex": ["@ibm/plex@6.4.1", "", { "dependencies": { "@ibm/telemetry-js": "^1.5.1" } }, "sha512-fnsipQywHt3zWvsnlyYKMikcVI7E2fEwpiPnIHFqlbByXVfQfANAAeJk1IV4mNnxhppUIDlhU0TzwYwL++Rn2g=="], @@ -1830,11 +1831,7 @@ "@mixmark-io/domino": ["@mixmark-io/domino@2.2.0", "", {}, "sha512-Y28PR25bHXUg88kCV7nivXrP2Nj2RueZ3/l/jdx6J9f8J4nsEGcgX0Qe6lt7Pa+J79+kPiJU3LguR6O/6zrLOw=="], - "@modelcontextprotocol/client": ["@modelcontextprotocol/client@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "jose": "^6.1.3", "pkce-challenge": "^5.0.0", "zod": "^4.2.0" } }, "sha512-8f1OghQ2rjzIOfqgUCP+8GiUWqRs89njoWLNqAe8kWmDePv3s1fZXseej+QXemssEuuOvLLmLO/kqM3IQHtISw=="], - - "@modelcontextprotocol/core": ["@modelcontextprotocol/core@2.0.0", "", { "dependencies": { "zod": "^4.2.0" } }, "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA=="], - - "@modelcontextprotocol/server": ["@modelcontextprotocol/server@2.0.0", "", { "dependencies": { "@modelcontextprotocol/core": "2.0.0", "zod": "^4.2.0" } }, "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw=="], + "@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.29.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ=="], "@motionone/animation": ["@motionone/animation@10.18.0", "", { "dependencies": { "@motionone/easing": "^10.18.0", "@motionone/types": "^10.17.1", "@motionone/utils": "^10.18.0", "tslib": "^2.3.1" } }, "sha512-9z2p5GFGCm0gBsZbi8rVMOAJCtw1WqBTIPw3ozk06gDvZInBPIsQcHgYogEJ4yuHJ+akuW8g1SEIOpTOvYs8hw=="], @@ -3394,7 +3391,7 @@ "content-disposition": ["content-disposition@0.5.4", "", { "dependencies": { "safe-buffer": "5.2.1" } }, "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ=="], - "content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], @@ -3406,6 +3403,8 @@ "core-util-is": ["core-util-is@1.0.3", "", {}, "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ=="], + "cors": ["cors@2.8.6", "", { "dependencies": { "object-assign": "^4", "vary": "^1" } }, "sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw=="], + "crc": ["crc@3.8.0", "", { "dependencies": { "buffer": "^5.1.0" } }, "sha512-iX3mfgcTMIq3ZKLIsVFAbv7+Mc10kxabAGQb8HvjA1o3T1PIYprbakQ65d3I+2HGHt6nSKkM9PYjgoJO2KcFBQ=="], "crc-32": ["crc-32@1.2.2", "", { "bin": { "crc32": "bin/crc32.njs" } }, "sha512-ROmzCKrTnOwybPcJApAA6WBWij23HVfGVNKqqrZpuyZOHqK2CwHSvpGuyt/UNNvaIjEd8X5IFGp4Mh+Ie1IHJQ=="], @@ -3710,6 +3709,8 @@ "express": ["express@4.22.2", "", { "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", "body-parser": "~1.20.5", "content-disposition": "~0.5.4", "content-type": "~1.0.4", "cookie": "~0.7.1", "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", "finalhandler": "~1.3.1", "fresh": "~0.5.2", "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "~2.4.1", "parseurl": "~1.3.3", "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", "qs": "~6.15.1", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", "send": "~0.19.0", "serve-static": "~1.16.2", "setprototypeof": "1.2.0", "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" } }, "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q=="], + "express-rate-limit": ["express-rate-limit@8.5.2", "", { "dependencies": { "ip-address": "^10.2.0" }, "peerDependencies": { "express": ">= 4.11" } }, "sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A=="], + "expressive-code": ["expressive-code@0.41.7", "", { "dependencies": { "@expressive-code/core": "^0.41.7", "@expressive-code/plugin-frames": "^0.41.7", "@expressive-code/plugin-shiki": "^0.41.7", "@expressive-code/plugin-text-markers": "^0.41.7" } }, "sha512-2wZjC8OQ3TaVEMcBtYY4Va3lo6J+Ai9jf3d4dbhURMJcU4Pbqe6EcHe424MIZI0VHUA1bR6xdpoHYi3yxokWqA=="], "exsolve": ["exsolve@1.0.8", "", {}, "sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA=="], @@ -4094,6 +4095,8 @@ "is-plain-obj": ["is-plain-obj@4.1.0", "", {}, "sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg=="], + "is-promise": ["is-promise@4.0.0", "", {}, "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ=="], + "is-property": ["is-property@1.0.2", "", {}, "sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g=="], "is-regex": ["is-regex@1.2.1", "", { "dependencies": { "call-bound": "^1.0.2", "gopd": "^1.2.0", "has-tostringtag": "^1.0.2", "hasown": "^2.0.2" } }, "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g=="], @@ -5002,6 +5005,8 @@ "rou3": ["rou3@0.7.12", "", {}, "sha512-iFE4hLDuloSWcD7mjdCDhx2bKcIsYbtOTpfH5MHHLSKMOUyjqQXTeZVa289uuwEGEKFoE/BAPbhaU4B774nceg=="], + "router": ["router@2.2.0", "", { "dependencies": { "debug": "^4.4.0", "depd": "^2.0.0", "is-promise": "^4.0.0", "parseurl": "^1.3.3", "path-to-regexp": "^8.0.0" } }, "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ=="], + "run-applescript": ["run-applescript@7.1.0", "", {}, "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q=="], "run-parallel": ["run-parallel@1.2.0", "", { "dependencies": { "queue-microtask": "^1.2.2" } }, "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA=="], @@ -5950,13 +5955,15 @@ "@mdx-js/mdx/source-map": ["source-map@0.7.6", "", {}, "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ=="], - "@modelcontextprotocol/client/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], + "@modelcontextprotocol/sdk/express": ["express@5.2.1", "", { "dependencies": { "accepts": "^2.0.0", "body-parser": "^2.2.1", "content-disposition": "^1.0.0", "content-type": "^1.0.5", "cookie": "^0.7.1", "cookie-signature": "^1.2.1", "debug": "^4.4.0", "depd": "^2.0.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "finalhandler": "^2.1.0", "fresh": "^2.0.0", "http-errors": "^2.0.0", "merge-descriptors": "^2.0.0", "mime-types": "^3.0.0", "on-finished": "^2.4.1", "once": "^1.4.0", "parseurl": "^1.3.3", "proxy-addr": "^2.0.7", "qs": "^6.14.0", "range-parser": "^1.2.1", "router": "^2.2.0", "send": "^1.1.0", "serve-static": "^2.2.0", "statuses": "^2.0.1", "type-is": "^2.0.1", "vary": "^1.1.2" } }, "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw=="], - "@modelcontextprotocol/client/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], - "@modelcontextprotocol/core/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="], - "@modelcontextprotocol/server/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], + "@modelcontextprotocol/sdk/raw-body": ["raw-body@3.0.2", "", { "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" } }, "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA=="], + + "@modelcontextprotocol/sdk/zod": ["zod@4.4.3", "", {}, "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ=="], "@npmcli/config/ini": ["ini@6.0.0", "", {}, "sha512-IBTdIkzZNOpqm7q3dRqJvMaldXjDHWkEDfrwGEQTs5eaQMWV+djAhR+wahyNNMAa+qpbDUhBMVt4ZKNwpPm7xQ=="], @@ -6232,8 +6239,6 @@ "babel-plugin-module-resolver/glob": ["glob@9.3.5", "", { "dependencies": { "fs.realpath": "^1.0.0", "minimatch": "^8.0.2", "minipass": "^4.2.4", "path-scurry": "^1.6.1" } }, "sha512-e1LleDykUz2Iu+MTYdkSsuWX8lvAjAcs0Xef0lNIu0S2wOAzuTxCJtcd9S3cijlwYF18EsU3rzb8jPVobxDh9Q=="], - "body-parser/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "body-parser/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "body-parser/iconv-lite": ["iconv-lite@0.4.24", "", { "dependencies": { "safer-buffer": ">= 2.1.2 < 3" } }, "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA=="], @@ -6316,8 +6321,6 @@ "execa/is-stream": ["is-stream@3.0.0", "", {}, "sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA=="], - "express/content-type": ["content-type@1.0.5", "", {}, "sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA=="], - "express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], "express/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], @@ -6466,6 +6469,8 @@ "roarr/sprintf-js": ["sprintf-js@1.1.3", "", {}, "sha512-Oo+0REFV59/rz3gfJNKQiBlwfHaSESl1pcGyABQsnnIfWOFt6JNj5gCog2U6MLZ//IGYD+nA8nI+mTShREReaA=="], + "router/path-to-regexp": ["path-to-regexp@8.4.2", "", {}, "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA=="], + "send/debug": ["debug@2.6.9", "", { "dependencies": { "ms": "2.0.0" } }, "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA=="], "send/mime": ["mime@1.6.0", "", { "bin": { "mime": "cli.js" } }, "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg=="], @@ -6794,28 +6799,58 @@ "@malept/flatpak-bundler/fs-extra/jsonfile": ["jsonfile@6.2.1", "", { "dependencies": { "universalify": "^2.0.0" }, "optionalDependencies": { "graceful-fs": "^4.1.6" } }, "sha512-zwOTdL3rFQ/lRdBnntKVOX6k5cKJwEc1HdilT71BWEu7J41gXIB2MRp+vxduPSwZJPWBxEzv4yH1wYLJGUHX4Q=="], + "@modelcontextprotocol/sdk/express/accepts": ["accepts@2.0.0", "", { "dependencies": { "mime-types": "^3.0.0", "negotiator": "^1.0.0" } }, "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng=="], + + "@modelcontextprotocol/sdk/express/body-parser": ["body-parser@2.2.2", "", { "dependencies": { "bytes": "^3.1.2", "content-type": "^1.0.5", "debug": "^4.4.3", "http-errors": "^2.0.0", "iconv-lite": "^0.7.0", "on-finished": "^2.4.1", "qs": "^6.14.1", "raw-body": "^3.0.1", "type-is": "^2.0.1" } }, "sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA=="], + + "@modelcontextprotocol/sdk/express/content-disposition": ["content-disposition@1.1.0", "", {}, "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g=="], + + "@modelcontextprotocol/sdk/express/cookie": ["cookie@0.7.2", "", {}, "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w=="], + + "@modelcontextprotocol/sdk/express/cookie-signature": ["cookie-signature@1.2.2", "", {}, "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg=="], + + "@modelcontextprotocol/sdk/express/finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="], + + "@modelcontextprotocol/sdk/express/fresh": ["fresh@2.0.0", "", {}, "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A=="], + + "@modelcontextprotocol/sdk/express/merge-descriptors": ["merge-descriptors@2.0.0", "", {}, "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g=="], + + "@modelcontextprotocol/sdk/express/send": ["send@1.2.1", "", { "dependencies": { "debug": "^4.4.3", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "etag": "^1.8.1", "fresh": "^2.0.0", "http-errors": "^2.0.1", "mime-types": "^3.0.2", "ms": "^2.1.3", "on-finished": "^2.4.1", "range-parser": "^1.2.1", "statuses": "^2.0.2" } }, "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ=="], + + "@modelcontextprotocol/sdk/express/serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + + "@modelcontextprotocol/sdk/express/type-is": ["type-is@2.1.0", "", { "dependencies": { "content-type": "^2.0.0", "media-typer": "^1.1.0", "mime-types": "^3.0.0" } }, "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA=="], + "@octokit/auth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-app/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/auth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-app/@octokit/request-error/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], "@octokit/auth-oauth-app/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-app/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/auth-oauth-app/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-oauth-app/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-oauth-device/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-device/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/auth-oauth-device/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-oauth-device/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-oauth-user/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], "@octokit/auth-oauth-user/@octokit/request/@octokit/request-error": ["@octokit/request-error@7.1.0", "", { "dependencies": { "@octokit/types": "^16.0.0" } }, "sha512-KMQIfq5sOPpkQYajXHwnhjCC0slzCNScLHs9JafXc4RAJI+9f+jNDlBNaIMTvazOPLgb4BnlhGJOTbnN0wIjPw=="], + "@octokit/auth-oauth-user/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/auth-oauth-user/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@24.2.0", "", {}, "sha512-9sIH3nSUttelJSXUrmGzl7QUBFul0/mB8HRYl3fOlgHbIWG+WnYDXU3v/2zMtAvuzZ/ed00Ei6on975FhBfzrg=="], @@ -6828,10 +6863,14 @@ "@octokit/graphql/@octokit/request/@octokit/types": ["@octokit/types@16.0.0", "", { "dependencies": { "@octokit/openapi-types": "^27.0.0" } }, "sha512-sKq+9r1Mm4efXW1FCk7hFSeJo4QKreL/tTbR0rz/qx/r1Oa2VV83LTA/H/MuCOX7uCIJmQVRKBcbmWoySjAnSg=="], + "@octokit/graphql/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/graphql/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@26.0.0", "", {}, "sha512-7AtcfKtpo77j7Ts73b4OWhOZHTKo/gGY8bB3bNBQz4H+GRSWqx2yvj8TXRsbdTE0eRmYmXOEY66jM7mJ7LzfsA=="], "@octokit/oauth-methods/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/oauth-methods/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/oauth-methods/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-paginate-rest/@octokit/core/@octokit/auth-token": ["@octokit/auth-token@6.0.0", "", {}, "sha512-P4YJBPdPSpWTQ1NU4XYdvHvXJJDxM6YwpS0FZHRgP7YFkdVxsWcpWGy/NVqlAA7PcPCnMacXlRm1y2PFZRWL/w=="], @@ -7340,6 +7379,10 @@ "@jsx-email/cli/vite/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="], + "@modelcontextprotocol/sdk/express/type-is/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + + "@modelcontextprotocol/sdk/express/type-is/media-typer": ["media-typer@1.1.0", "", {}, "sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw=="], + "@octokit/auth-app/@octokit/request-error/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/auth-app/@octokit/request/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], @@ -7348,14 +7391,20 @@ "@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/plugin-paginate-rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/plugin-paginate-rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/plugin-rest-endpoint-methods/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@octokit/rest/@octokit/core/@octokit/request/@octokit/endpoint": ["@octokit/endpoint@11.0.3", "", { "dependencies": { "@octokit/types": "^16.0.0", "universal-user-agent": "^7.0.2" } }, "sha512-FWFlNxghg4HrXkD3ifYbS/IdL/mDHjh9QcsNyhQjN8dplUoZbejsdpmuqdA76nxj2xoWPs7p8uX2SNr9rYu0Ag=="], + "@octokit/rest/@octokit/core/@octokit/request/content-type": ["content-type@2.0.0", "", {}, "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ=="], + "@octokit/rest/@octokit/core/@octokit/types/@octokit/openapi-types": ["@octokit/openapi-types@27.0.0", "", {}, "sha512-whrdktVs1h6gtR+09+QsNk2+FO+49j6ga1c55YZudfEG+oKJVvJLQi3zkOm5JjiUXAagWK2tI2kTGKJ2Ys7MGA=="], "@opencode-ai/core/@ai-sdk/openai/@ai-sdk/provider-utils/@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], diff --git a/bunfig.toml b/bunfig.toml index 649db19f1de1..c506ff57c4bf 100644 --- a/bunfig.toml +++ b/bunfig.toml @@ -2,7 +2,7 @@ exact = true # Only install newly resolved package versions published at least 3 days ago. minimumReleaseAge = 259200 -minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@modelcontextprotocol/client", "@modelcontextprotocol/core", "@modelcontextprotocol/server", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] +minimumReleaseAgeExcludes = ["@ai-sdk/amazon-bedrock", "@ai-sdk/anthropic", "@opentui/core", "@opentui/core-darwin-arm64", "@opentui/core-darwin-x64", "@opentui/core-linux-arm64", "@opentui/core-linux-arm64-musl", "@opentui/core-linux-x64", "@opentui/core-linux-x64-musl", "@opentui/core-win32-arm64", "@opentui/core-win32-x64", "@opentui/keymap", "@opentui/solid", "opentui-spinner", "gitlab-ai-provider", "opencode-gitlab-auth", "@ff-labs/fff-node", "@ff-labs/fff-bun", "@ff-labs/fff-bin-darwin-arm64", "@ff-labs/fff-bin-darwin-x64", "@ff-labs/fff-bin-linux-arm64-gnu", "@ff-labs/fff-bin-linux-arm64-musl", "@ff-labs/fff-bin-linux-x64-gnu", "@ff-labs/fff-bin-linux-x64-musl", "@ff-labs/fff-bin-win32-arm64", "@ff-labs/fff-bin-win32-x64", "@pierre/diffs", "@pierre/theming", "app-builder-lib", "dmg-builder", "electron-builder", "electron-publish"] [test] root = "./do-not-run-tests-from-root" diff --git a/package.json b/package.json index 30630fdf2fe9..e479eb74062c 100644 --- a/package.json +++ b/package.json @@ -155,8 +155,8 @@ "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", + "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", - "@modelcontextprotocol/client@2.0.0": "patches/@modelcontextprotocol%2Fclient@2.0.0.patch" + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" } } diff --git a/packages/opencode/package.json b/packages/opencode/package.json index d2155eb4ba2b..8f7af65a8f99 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -30,7 +30,6 @@ }, "devDependencies": { "@babel/core": "7.28.4", - "@modelcontextprotocol/server": "2.0.0", "@octokit/webhooks-types": "7.6.1", "@opencode-ai/core": "workspace:*", "@opencode-ai/http-recorder": "workspace:*", @@ -81,7 +80,7 @@ "@effect/platform-node": "catalog:", "@ff-labs/fff-bun": "0.9.4", "@gitlab/opencode-gitlab-auth": "1.3.3", - "@modelcontextprotocol/client": "2.0.0", + "@modelcontextprotocol/sdk": "1.29.0", "@octokit/graphql": "9.0.2", "@octokit/rest": "catalog:", "@openauthjs/openauth": "catalog:", diff --git a/packages/opencode/src/cli/cmd/mcp.ts b/packages/opencode/src/cli/cmd/mcp.ts index c9f74e2bc039..c2d2ee2f3b73 100644 --- a/packages/opencode/src/cli/cmd/mcp.ts +++ b/packages/opencode/src/cli/cmd/mcp.ts @@ -2,10 +2,13 @@ import { cmd } from "./cmd" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { effectCmd } from "../effect-cmd" import { Cause } from "effect" -import { Client, StreamableHTTPClientTransport, UnauthorizedError } from "@modelcontextprotocol/client" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" import * as prompts from "@clack/prompts" import { UI } from "../ui" -import { CLIENT_OPTIONS, MCP } from "../../mcp" +import { MCP } from "../../mcp" import { McpAuth } from "../../mcp/auth" import { McpOAuthProvider } from "../../mcp/oauth-provider" import { Config } from "@/config/config" @@ -728,53 +731,107 @@ export const McpDebugCommand = effectCmd({ const spinner = prompts.spinner() spinner.start("Testing connection...") - const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined - let authorizationUrl: URL | undefined - const authProvider = new McpOAuthProvider( - serverName, - serverConfig.url, - { - clientId: oauthConfig?.clientId, - clientSecret: oauthConfig?.clientSecret, - scope: oauthConfig?.scope, - callbackPort: oauthConfig?.callbackPort, - redirectUri: oauthConfig?.redirectUri, - }, - { - onRedirect: async (url) => { - authorizationUrl = url + // Test basic HTTP connectivity first + try { + const response = await fetch(serverConfig.url, { + method: "POST", + headers: { + ...serverConfig.headers, + "Content-Type": "application/json", + Accept: "application/json, text/event-stream", }, - }, - auth, - ) - const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { - authProvider, - requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, - }) - const client = new Client({ name: "opencode-debug", version: InstallationVersion }, CLIENT_OPTIONS) + body: JSON.stringify({ + jsonrpc: "2.0", + method: "initialize", + params: { + protocolVersion: LATEST_PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: "opencode-debug", version: InstallationVersion }, + }, + id: 1, + }), + }) - try { - await client.connect(transport) - spinner.stop("SDK connection successful") - prompts.log.success( - `Connected using MCP ${client.getNegotiatedProtocolVersion() ?? "unknown"} (${client.getProtocolEra() ?? "unknown"})`, - ) - const serverInfo = client.getServerVersion() - if (serverInfo) prompts.log.info(`Server info: ${JSON.stringify(serverInfo)}`) - } catch (error) { - if (error instanceof UnauthorizedError) { - spinner.stop("OAuth required") - prompts.log.info(`OAuth flow triggered: ${error.message}`) - if (authorizationUrl) prompts.log.info(`Authorization URL: ${authorizationUrl}`) - const clientInfo = await authProvider.clientInformation() - if (clientInfo) prompts.log.info(`Client ID available: ${clientInfo.client_id}`) - if (!clientInfo) prompts.log.info("No client ID - dynamic registration will be attempted") + spinner.stop(`HTTP response: ${response.status} ${response.statusText}`) + + // Check for WWW-Authenticate header + const wwwAuth = response.headers.get("www-authenticate") + if (wwwAuth) { + prompts.log.info(`WWW-Authenticate: ${wwwAuth}`) + } + + if (response.status === 401) { + prompts.log.info("Initial unauthenticated check returned 401, so this server requires OAuth") + + // Try to discover OAuth metadata + const oauthConfig = typeof serverConfig.oauth === "object" ? serverConfig.oauth : undefined + const authProvider = new McpOAuthProvider( + serverName, + serverConfig.url, + { + clientId: oauthConfig?.clientId, + clientSecret: oauthConfig?.clientSecret, + scope: oauthConfig?.scope, + redirectUri: oauthConfig?.redirectUri, + }, + { + onRedirect: async () => {}, + }, + auth, + ) + + prompts.log.info("Testing OAuth flow (without completing authorization)...") + + // Try creating transport with auth provider to trigger discovery + const transport = new StreamableHTTPClientTransport(new URL(serverConfig.url), { + authProvider, + requestInit: serverConfig.headers ? { headers: serverConfig.headers } : undefined, + }) + + try { + const client = new Client({ + name: "opencode-debug", + version: InstallationVersion, + }) + await client.connect(transport) + prompts.log.success("Connection successful (already authenticated)") + await client.close() + } catch (error) { + if (error instanceof UnauthorizedError) { + prompts.log.info(`OAuth flow triggered: ${error.message}`) + + // Check if dynamic registration would be attempted + const clientInfo = await authProvider.clientInformation() + if (clientInfo) { + prompts.log.info(`Client ID available: ${clientInfo.client_id}`) + } else { + prompts.log.info("No client ID - dynamic registration will be attempted") + } + } else { + prompts.log.error(`Connection error: ${error instanceof Error ? error.message : String(error)}`) + } + } + } else if (response.status >= 200 && response.status < 300) { + prompts.log.success("Server responded successfully (no auth required or already authenticated)") + const body = await response.text() + try { + const json = JSON.parse(body) + if (json.result?.serverInfo) { + prompts.log.info(`Server info: ${JSON.stringify(json.result.serverInfo)}`) + } + } catch { + // Not JSON, ignore + } } else { - spinner.stop("Connection failed", 1) - prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) + prompts.log.warn(`Unexpected status: ${response.status}`) + const body = await response.text().catch(() => "") + if (body) { + prompts.log.info(`Response body: ${body.substring(0, 500)}`) + } } - } finally { - await client.close().catch(() => {}) + } catch (error) { + spinner.stop("Connection failed", 1) + prompts.log.error(`Error: ${error instanceof Error ? error.message : String(error)}`) } prompts.outro("Debug complete") diff --git a/packages/opencode/src/mcp/auth.ts b/packages/opencode/src/mcp/auth.ts index 543b4fb0e33f..808aa3029625 100644 --- a/packages/opencode/src/mcp/auth.ts +++ b/packages/opencode/src/mcp/auth.ts @@ -11,7 +11,6 @@ export const Tokens = Schema.Struct({ refreshToken: Schema.mutableKey(Schema.optional(Schema.String)), expiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), scope: Schema.mutableKey(Schema.optional(Schema.String)), - issuer: Schema.mutableKey(Schema.optional(Schema.String)), }) export type Tokens = Schema.Schema.Type @@ -20,9 +19,6 @@ export const ClientInfo = Schema.Struct({ clientSecret: Schema.mutableKey(Schema.optional(Schema.String)), clientIdIssuedAt: Schema.mutableKey(Schema.optional(Schema.Number)), clientSecretExpiresAt: Schema.mutableKey(Schema.optional(Schema.Number)), - redirectUris: Schema.mutableKey(Schema.optional(Schema.Array(Schema.String))), - issuer: Schema.mutableKey(Schema.optional(Schema.String)), - configPreRegistered: Schema.mutableKey(Schema.optional(Schema.Boolean)), }) export type ClientInfo = Schema.Schema.Type diff --git a/packages/opencode/src/mcp/catalog.ts b/packages/opencode/src/mcp/catalog.ts index 226cd6bb2584..3f985eeb94dc 100644 --- a/packages/opencode/src/mcp/catalog.ts +++ b/packages/opencode/src/mcp/catalog.ts @@ -1,57 +1,77 @@ -import { Client, type CallToolResult, type Tool as MCPToolDef } from "@modelcontextprotocol/client" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { + CallToolResultSchema, + ListToolsResultSchema, + ToolSchema, + type Tool as MCPToolDef, +} from "@modelcontextprotocol/sdk/types.js" import { dynamicTool, jsonSchema, type JSONSchema7, type Tool } from "ai" import { Effect } from "effect" const DEFAULT_TIMEOUT = 30_000 +const MAX_LIST_PAGES = 1_000 -export interface McpTool { - readonly def: MCPToolDef - readonly client: Client - readonly timeout?: number -} +const TolerantListToolsResultSchema = ListToolsResultSchema.extend({ + tools: ToolSchema.omit({ outputSchema: true }).array(), +}) -export async function callTool( - tool: McpTool, - args: Record, - signal?: AbortSignal, -): Promise { - const result = await tool.client.callTool( - { name: tool.def.name, arguments: args }, - { - resetTimeoutOnProgress: true, - signal, - timeout: tool.timeout, - // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. - onprogress: () => {}, - }, - ) - if (result.isError) - throw new Error( - result.content - .flatMap((item) => (item.type === "text" ? [item.text] : [])) - .filter((text) => text.trim()) - .join("\n\n") || "MCP tool returned an error", - ) - return result +export async function paginate( + list: (cursor?: string) => Promise, + items: (result: R) => T[], +) { + const result: T[] = [] + const cursors = new Set() + let cursor: string | undefined + + for (let page = 0; page < MAX_LIST_PAGES; page++) { + const page = await list(cursor) + result.push(...items(page)) + if (page.nextCursor === undefined) return result + if (cursors.has(page.nextCursor)) throw new Error(`MCP list returned duplicate cursor: ${page.nextCursor}`) + cursors.add(page.nextCursor) + cursor = page.nextCursor + } + + throw new Error(`MCP list exceeded ${MAX_LIST_PAGES} pages`) } export function defs(client: Client, timeout?: number) { return listTools(client, timeout ?? DEFAULT_TIMEOUT).pipe(Effect.catch(() => Effect.void)) } -export function convertTool(tool: McpTool): Tool { +export function convertTool(mcpTool: MCPToolDef, client: Client, timeout?: number): Tool { const inputSchema: JSONSchema7 = { - ...(tool.def.inputSchema as JSONSchema7), + ...(mcpTool.inputSchema as JSONSchema7), type: "object", - properties: (tool.def.inputSchema.properties ?? {}) as JSONSchema7["properties"], + properties: (mcpTool.inputSchema.properties ?? {}) as JSONSchema7["properties"], additionalProperties: false, } return dynamicTool({ - description: tool.def.description ?? "", + description: mcpTool.description ?? "", inputSchema: jsonSchema(inputSchema), execute: async (args: unknown, options) => { - const result = await callTool(tool, (args || {}) as Record, options.abortSignal) + const result = await client.callTool( + { + name: mcpTool.name, + arguments: (args || {}) as Record, + }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + signal: options.abortSignal, + timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (result.isError) + throw new Error( + result.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) if (result.content.length > 0 || result.structuredContent === undefined || result.structuredContent === null) return result return { @@ -98,26 +118,53 @@ export const sanitize = (value: string) => value.replace(/[^a-zA-Z0-9_-]/g, "_") export const toolName = (clientName: string, name: string) => sanitize(clientName) + "_" + sanitize(name) -export async function prompts(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.prompts) return [] - return (await client.listPrompts(undefined, { timeout })).prompts +export function prompts(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.prompts) return Promise.resolve([]) + return paginate( + (cursor) => client.listPrompts(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.prompts, + ) } -export async function resources(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.resources) return [] - return (await client.listResources(undefined, { timeout })).resources +export function resources(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResources(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resources, + ) } -export async function resourceTemplates(client: Client, timeout?: number) { - if (!client.getServerCapabilities()?.resources) return [] - return (await client.listResourceTemplates(undefined, { timeout })).resourceTemplates +export function resourceTemplates(client: Client, timeout?: number) { + if (!client.getServerCapabilities()?.resources) return Promise.resolve([]) + return paginate( + (cursor) => client.listResourceTemplates(cursor === undefined ? undefined : { cursor }, { timeout }), + (result) => result.resourceTemplates, + ) } function listTools(client: Client, timeout: number) { return Effect.tryPromise({ - try: async () => (await client.listTools(undefined, { timeout })).tools, + try: () => + paginate( + async (cursor) => { + const params = cursor === undefined ? undefined : { cursor } + try { + return await client.listTools(params, { timeout }) + } catch (error) { + if (!(error instanceof Error) || !isOutputSchemaValidationError(error)) throw error + return client.request({ method: "tools/list", params }, TolerantListToolsResultSchema, { timeout }) + } + }, + (result) => result.tools, + ), catch: (error) => (error instanceof Error ? error : new Error(String(error))), }) } +function isOutputSchemaValidationError(error: Error) { + return /can't resolve reference|resolves to more than one schema|outputSchema|schema.*reference|reference.*schema/i.test( + error.message, + ) +} + export * as McpCatalog from "./catalog" diff --git a/packages/opencode/src/mcp/index.ts b/packages/opencode/src/mcp/index.ts index 939c4420472f..05f12fa2ee45 100644 --- a/packages/opencode/src/mcp/index.ts +++ b/packages/opencode/src/mcp/index.ts @@ -3,18 +3,18 @@ import { pathToFileURL } from "node:url" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { ConfigV1 } from "@opencode-ai/core/v1/config/config" import { serviceUse } from "@opencode-ai/core/effect/service-use" +import { Client, type ClientOptions } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { SSEClientTransport } from "@modelcontextprotocol/sdk/client/sse.js" +import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js" +import { UnauthorizedError } from "@modelcontextprotocol/sdk/client/auth.js" import { - Client, - type ClientOptions, - StreamableHTTPClientTransport, - SSEClientTransport, - UnauthorizedError, - RegistrationRejectedError, - SdkHttpError, + ListRootsRequestSchema, type LoggingMessageNotification, + LoggingMessageNotificationSchema, type Tool as MCPToolDef, -} from "@modelcontextprotocol/client" -import { StdioClientTransport } from "@modelcontextprotocol/client/stdio" + ToolListChangedNotificationSchema, +} from "@modelcontextprotocol/sdk/types.js" import { Config } from "@/config/config" import { ConfigMCPV1 } from "@opencode-ai/core/v1/config/mcp" import { NamedError } from "@opencode-ai/core/util/error" @@ -36,7 +36,7 @@ import { McpEvent } from "@opencode-ai/schema/mcp-event" import { McpBrowser } from "./browser" const DEFAULT_TIMEOUT = 30_000 -export const CLIENT_OPTIONS = { +const CLIENT_OPTIONS = { capabilities: { // https://github.com/anomalyco/opencode/issues/11948 // sampling: {}, @@ -47,8 +47,6 @@ export const CLIENT_OPTIONS = { // https://github.com/anomalyco/opencode/issues/28567 // tasks: {}, }, - versionNegotiation: { mode: "auto" }, - listMaxPages: 1_000, } satisfies ClientOptions export const Resource = Schema.Struct({ @@ -72,19 +70,13 @@ export class NotFoundError extends Schema.TaggedErrorClass()("MCP name: Schema.String, }) {} -type MCPClient = Client & { onToolsChanged?: (error: Error | null) => void } +type MCPClient = Client function createClient(directory: string) { - const client: MCPClient = new Client( - { name: "opencode", version: InstallationVersion }, - { - ...CLIENT_OPTIONS, - listChanged: { - tools: { autoRefresh: false, onChanged: (error) => client.onToolsChanged?.(error) }, - }, - }, + const client = new Client({ name: "opencode", version: InstallationVersion }, CLIENT_OPTIONS) + client.setRequestHandler(ListRootsRequestSchema, () => + Promise.resolve({ roots: [{ uri: pathToFileURL(directory).href }] }), ) - client.setRequestHandler("roots/list", async () => ({ roots: [{ uri: pathToFileURL(directory).href }] })) return client } @@ -162,7 +154,12 @@ export interface ServerInstructions { } /** An MCP tool in its native shape; consumers adapt it to their own tool format. */ -export type McpTool = McpCatalog.McpTool +export interface McpTool { + /** Shared cached definition; consumers must copy rather than mutate it. */ + readonly def: MCPToolDef + readonly client: MCPClient + readonly timeout?: number +} export interface Interface { readonly status: () => Effect.Effect> @@ -193,11 +190,7 @@ export interface Interface { mcpName: string, onAuthorization?: (authorizationUrl: string) => void, ) => Effect.Effect - readonly finishAuth: ( - mcpName: string, - authorizationCode: string, - iss?: string, - ) => Effect.Effect + readonly finishAuth: (mcpName: string, authorizationCode: string) => Effect.Effect readonly removeAuth: (mcpName: string) => Effect.Effect readonly supportsOAuth: (mcpName: string) => Effect.Effect readonly hasStoredTokens: (mcpName: string) => Effect.Effect @@ -298,18 +291,11 @@ const layer = Layer.effect( Effect.map((client) => ({ client, transportName: name })), Effect.catch((error) => { const lastError = error instanceof Error ? error : new Error(String(error)) - const registrationRejected = - error instanceof RegistrationRejectedError || - lastError.message.includes("registration") || - lastError.message.includes("client_id") const isAuthError = - error instanceof UnauthorizedError || - registrationRejected || - (authProvider && error instanceof SdkHttpError && error.status === 401) || - (authProvider && lastError.message.includes("OAuth")) + error instanceof UnauthorizedError || (authProvider && lastError.message.includes("OAuth")) if (isAuthError) { - if (registrationRejected) { + if (lastError.message.includes("registration") || lastError.message.includes("client_id")) { lastStatus = { status: "needs_client_registration" as const, error: "Server does not support dynamic client registration. Please provide clientId in config.", @@ -468,16 +454,12 @@ const layer = Layer.effect( ) } - client.setNotificationHandler("notifications/message", (notification) => + client.setNotificationHandler(LoggingMessageNotificationSchema, (notification) => bridge.promise(serverLog(name, notification.params)), ) if (!client.getServerCapabilities()?.tools) return - client.onToolsChanged = async (error) => { - if (error) { - await bridge.promise(Effect.logWarning("failed to refresh MCP tools", { server: name, error: error.message })) - return - } + client.setNotificationHandler(ToolListChangedNotificationSchema, async () => { if (s.clients[name] !== client || s.status[name]?.status !== "connected") return const listed = await bridge.promise(McpCatalog.defs(client, timeout)) @@ -486,7 +468,7 @@ const layer = Layer.effect( s.defs[name] = listed await bridge.promise(events.publish(ToolsChanged, { server: name }).pipe(Effect.ignore)) - } + }) } function serverLog(name: string, params: LoggingMessageNotification["params"]) { @@ -922,7 +904,7 @@ const layer = Layer.effect( }), ) - const callback = yield* Effect.promise(() => callbackPromise) + const code = yield* Effect.promise(() => callbackPromise) const storedState = yield* auth.getOAuthState(mcpName) if (storedState !== result.oauthState) { @@ -930,20 +912,16 @@ const layer = Layer.effect( throw new Error("OAuth state mismatch - potential CSRF attack") } yield* auth.clearOAuthState(mcpName) - return yield* finishAuth(mcpName, callback.code, callback.iss) + return yield* finishAuth(mcpName, code) }) - const finishAuth = Effect.fn("MCP.finishAuth")(function* ( - mcpName: string, - authorizationCode: string, - iss?: string, - ) { + const finishAuth = Effect.fn("MCP.finishAuth")(function* (mcpName: string, authorizationCode: string) { yield* requireMcpConfig(mcpName) const pending = pendingOAuthTransports.get(mcpName) if (!pending) throw new Error(`No pending OAuth flow for MCP server: ${mcpName}`) const error = yield* Effect.tryPromise({ - try: () => pending.transport.finishAuth(authorizationCode, iss), + try: () => pending.transport.finishAuth(authorizationCode), catch: (error) => error, }).pipe( Effect.match({ diff --git a/packages/opencode/src/mcp/oauth-callback.ts b/packages/opencode/src/mcp/oauth-callback.ts index 71f6ec95399a..84007902b8c0 100644 --- a/packages/opencode/src/mcp/oauth-callback.ts +++ b/packages/opencode/src/mcp/oauth-callback.ts @@ -9,13 +9,8 @@ const OAUTH_CALLBACK_HOST = "127.0.0.1" let currentPort = OAUTH_CALLBACK_PORT let currentPath = OAUTH_CALLBACK_PATH -export interface AuthorizationCallback { - code: string - iss?: string -} - interface PendingAuth { - resolve: (callback: AuthorizationCallback) => void + resolve: (code: string) => void reject: (error: Error) => void timeout: ReturnType } @@ -54,7 +49,6 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). } const code = url.searchParams.get("code") - const iss = url.searchParams.get("iss") ?? undefined const state = url.searchParams.get("state") const error = url.searchParams.get("error") const errorDescription = url.searchParams.get("error_description") @@ -101,7 +95,7 @@ function handleRequest(req: import("http").IncomingMessage, res: import("http"). clearTimeout(pending.timeout) pendingAuths.delete(state) cleanupStateIndex(state) - pending.resolve({ code, iss }) + pending.resolve(code) res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" }) res.end(OauthCallbackPage.success({ provider: "MCP" })) @@ -136,7 +130,7 @@ export async function ensureRunning(redirectUri?: string): Promise { }) } -export function waitForCallback(oauthState: string, mcpName?: string): Promise { +export function waitForCallback(oauthState: string, mcpName?: string): Promise { if (mcpName) mcpNameToState.set(mcpName, oauthState) return new Promise((resolve, reject) => { const timeout = setTimeout(() => { diff --git a/packages/opencode/src/mcp/oauth-provider.ts b/packages/opencode/src/mcp/oauth-provider.ts index a3f99a55f5ba..596bfe1d551f 100644 --- a/packages/opencode/src/mcp/oauth-provider.ts +++ b/packages/opencode/src/mcp/oauth-provider.ts @@ -1,9 +1,10 @@ +import type { OAuthClientProvider } from "@modelcontextprotocol/sdk/client/auth.js" import type { - OAuthClientProvider, OAuthClientMetadata, - StoredOAuthTokens, - StoredOAuthClientInformation, -} from "@modelcontextprotocol/client" + OAuthTokens, + OAuthClientInformation, + OAuthClientInformationFull, +} from "@modelcontextprotocol/sdk/shared/auth.js" import { Effect } from "effect" import { McpAuth } from "./auth" @@ -22,14 +23,6 @@ export interface McpOAuthCallbacks { onRedirect: (url: URL) => void | Promise } -function registrationMetadata(info: StoredOAuthClientInformation) { - return { - clientIdIssuedAt: "client_id_issued_at" in info ? info.client_id_issued_at : undefined, - clientSecretExpiresAt: "client_secret_expires_at" in info ? info.client_secret_expires_at : undefined, - redirectUris: "redirect_uris" in info ? info.redirect_uris : undefined, - } -} - export class McpOAuthProvider implements OAuthClientProvider { constructor( protected mcpName: string, @@ -59,21 +52,18 @@ export class McpOAuthProvider implements OAuthClientProvider { } } - async clientInformation(): Promise { - const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) + async clientInformation(): Promise { if (this.config.clientId) { - const issuer = entry?.clientInfo?.clientId === this.config.clientId ? entry.clientInfo.issuer : undefined return { client_id: this.config.clientId, client_secret: this.config.clientSecret, - ...(issuer !== undefined ? { issuer } : {}), } } // Check stored client info (from dynamic registration) // Use getForUrl to validate credentials are for the current server URL + const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (entry?.clientInfo) { - if (entry.clientInfo.configPreRegistered) return undefined // Check if client secret has expired if (entry.clientInfo.clientSecretExpiresAt && entry.clientInfo.clientSecretExpiresAt < Date.now() / 1000) { return undefined @@ -81,14 +71,6 @@ export class McpOAuthProvider implements OAuthClientProvider { return { client_id: entry.clientInfo.clientId, client_secret: entry.clientInfo.clientSecret, - ...(entry.clientInfo.clientIdIssuedAt !== undefined - ? { client_id_issued_at: entry.clientInfo.clientIdIssuedAt } - : {}), - ...(entry.clientInfo.clientSecretExpiresAt !== undefined - ? { client_secret_expires_at: entry.clientInfo.clientSecretExpiresAt } - : {}), - redirect_uris: entry.clientInfo.redirectUris ? [...entry.clientInfo.redirectUris] : [this.redirectUrl], - ...(entry.clientInfo.issuer !== undefined ? { issuer: entry.clientInfo.issuer } : {}), } } @@ -96,36 +78,22 @@ export class McpOAuthProvider implements OAuthClientProvider { return undefined } - async saveClientInformation(info: StoredOAuthClientInformation): Promise { - if (this.config.clientId && info.client_id === this.config.clientId) { - await Effect.runPromise( - this.auth.updateClientInfo( - this.mcpName, - { clientId: info.client_id, issuer: info.issuer, configPreRegistered: true }, - this.serverUrl, - ), - ) - return - } - - const metadata = registrationMetadata(info) + async saveClientInformation(info: OAuthClientInformationFull): Promise { await Effect.runPromise( this.auth.updateClientInfo( this.mcpName, { clientId: info.client_id, clientSecret: info.client_secret, - clientIdIssuedAt: metadata.clientIdIssuedAt, - clientSecretExpiresAt: metadata.clientSecretExpiresAt, - redirectUris: metadata.redirectUris ? [...metadata.redirectUris] : [this.redirectUrl], - issuer: info.issuer, + clientIdIssuedAt: info.client_id_issued_at, + clientSecretExpiresAt: info.client_secret_expires_at, }, this.serverUrl, ), ) } - async tokens(): Promise { + async tokens(): Promise { // Use getForUrl to validate tokens are for the current server URL const entry = await Effect.runPromise(this.auth.getForUrl(this.mcpName, this.serverUrl)) if (!entry?.tokens) return undefined @@ -138,20 +106,18 @@ export class McpOAuthProvider implements OAuthClientProvider { ? Math.max(0, Math.floor(entry.tokens.expiresAt - Date.now() / 1000)) : undefined, scope: entry.tokens.scope, - issuer: entry.tokens.issuer, } } - async saveTokens(tokens: StoredOAuthTokens): Promise { + async saveTokens(tokens: OAuthTokens): Promise { await Effect.runPromise( this.auth.updateTokens( this.mcpName, { accessToken: tokens.access_token, refreshToken: tokens.refresh_token, - expiresAt: tokens.expires_in !== undefined ? Date.now() / 1000 + tokens.expires_in : undefined, + expiresAt: tokens.expires_in ? Date.now() / 1000 + tokens.expires_in : undefined, scope: tokens.scope, - issuer: tokens.issuer, }, this.serverUrl, ), @@ -215,10 +181,10 @@ export class McpOAuthProvider implements OAuthClientProvider { } export class McpOAuthPendingProvider extends McpOAuthProvider { - private pendingClientInfo?: StoredOAuthClientInformation - private pendingTokens?: StoredOAuthTokens + private pendingClientInfo?: OAuthClientInformationFull + private pendingTokens?: OAuthTokens - override async clientInformation(): Promise { + override async clientInformation(): Promise { if (!this.config.clientId) return this.pendingClientInfo return { client_id: this.config.clientId, @@ -226,15 +192,15 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { } } - override async saveClientInformation(info: StoredOAuthClientInformation): Promise { + override async saveClientInformation(info: OAuthClientInformationFull): Promise { this.pendingClientInfo = info } - override async tokens(): Promise { + override async tokens(): Promise { return this.pendingTokens } - override async saveTokens(tokens: StoredOAuthTokens): Promise { + override async saveTokens(tokens: OAuthTokens): Promise { this.pendingTokens = tokens } @@ -245,7 +211,6 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { async commit(): Promise { if (!this.pendingTokens) return - const pendingMetadata = this.pendingClientInfo ? registrationMetadata(this.pendingClientInfo) : undefined await Effect.runPromise( this.auth.set( this.mcpName, @@ -253,22 +218,16 @@ export class McpOAuthPendingProvider extends McpOAuthProvider { tokens: { accessToken: this.pendingTokens.access_token, refreshToken: this.pendingTokens.refresh_token, - expiresAt: - this.pendingTokens.expires_in !== undefined - ? Date.now() / 1000 + this.pendingTokens.expires_in - : undefined, + expiresAt: this.pendingTokens.expires_in ? Date.now() / 1000 + this.pendingTokens.expires_in : undefined, scope: this.pendingTokens.scope, - issuer: this.pendingTokens.issuer, }, clientInfo: this.pendingClientInfo && !this.config.clientId ? { clientId: this.pendingClientInfo.client_id, clientSecret: this.pendingClientInfo.client_secret, - clientIdIssuedAt: pendingMetadata?.clientIdIssuedAt, - clientSecretExpiresAt: pendingMetadata?.clientSecretExpiresAt, - redirectUris: pendingMetadata?.redirectUris ? [...pendingMetadata.redirectUris] : [this.redirectUrl], - issuer: this.pendingClientInfo.issuer, + clientIdIssuedAt: this.pendingClientInfo.client_id_issued_at, + clientSecretExpiresAt: this.pendingClientInfo.client_secret_expires_at, } : undefined, }, diff --git a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts index ca56d10b4e41..a6fb064d73e4 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/groups/mcp.ts @@ -20,7 +20,6 @@ export const AuthStartResponse = Schema.Struct({ }) export const AuthCallbackPayload = Schema.Struct({ code: Schema.String, - iss: Schema.optional(Schema.String), }) export const AuthRemoveResponse = Schema.Struct({ success: Schema.Literal(true), diff --git a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts index 3a367a84e790..cdf0cc1e70eb 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/handlers/mcp.ts @@ -38,7 +38,7 @@ export const mcpHandlers = HttpApiBuilder.group(InstanceHttpApi, "mcp", (handler payload: typeof AuthCallbackPayload.Type }) { return yield* mcp - .finishAuth(ctx.params.name, ctx.payload.code, ctx.payload.iss) + .finishAuth(ctx.params.name, ctx.payload.code) .pipe( Effect.catchTag("MCP.NotFoundError", (error) => Effect.fail( diff --git a/packages/opencode/src/session/tools.ts b/packages/opencode/src/session/tools.ts index d93fb66f2d09..0f401c7562fa 100644 --- a/packages/opencode/src/session/tools.ts +++ b/packages/opencode/src/session/tools.ts @@ -388,7 +388,7 @@ export const resolve = Effect.fn("SessionTools.resolve")(function* (input: { if (flags.experimentalCodeMode) return tools for (const [key, entry] of Object.entries(yield* mcp.tools())) { - const item = McpCatalog.convertTool(entry) + const item = McpCatalog.convertTool(entry.def, entry.client, entry.timeout) const execute = item.execute if (!execute) continue diff --git a/packages/opencode/src/tool/code-mode.ts b/packages/opencode/src/tool/code-mode.ts index a046b4093d89..332d4b43f150 100644 --- a/packages/opencode/src/tool/code-mode.ts +++ b/packages/opencode/src/tool/code-mode.ts @@ -1,5 +1,5 @@ import * as Tool from "./tool" -import { type CallToolResult } from "@modelcontextprotocol/client" +import { CallToolResultSchema, type CallToolResult } from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Schema } from "effect" import { CodeMode, Tool as SandboxTool, toolError } from "@opencode-ai/codemode" import { MCP } from "@/mcp" @@ -145,7 +145,28 @@ const invokeChildTool = Effect.fn("CodeMode.invokeChildTool")(function* (input: ) const result: CallToolResult = yield* Effect.gen(function* () { yield* input.ctx.ask({ permission: input.entry.key, metadata: {}, patterns: ["*"], always: ["*"] }) - return yield* Effect.promise(() => McpCatalog.callTool(input.entry.tool, input.args, input.ctx.abort)) + // Deliberately mirrors McpCatalog.convertTool's transport call so the MCP service stays free of tool-loop concerns. + return yield* Effect.promise(async () => { + const raw = await input.entry.tool.client.callTool( + { name: input.entry.tool.def.name, arguments: input.args }, + CallToolResultSchema, + { + resetTimeoutOnProgress: true, + signal: input.ctx.abort, + timeout: input.entry.tool.timeout, + // The MCP SDK only sends a progress token when this hook is present, enabling timeout resets. + onprogress: () => {}, + }, + ) + if (raw.isError) + throw new Error( + raw.content + .flatMap((item) => (item.type === "text" ? [item.text] : [])) + .filter((text) => text.trim()) + .join("\n\n") || "MCP tool returned an error", + ) + return raw + }) }).pipe( Effect.withSpan("Tool.execute", { attributes: { diff --git a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts index 6260c7e3d4dc..b01ed921cfd4 100644 --- a/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts +++ b/packages/opencode/test/fixture/mcp-lifecycle-stdio.ts @@ -1,5 +1,6 @@ -import { Server } from "@modelcontextprotocol/server" -import { StdioServerTransport } from "@modelcontextprotocol/server/stdio" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" if (process.argv.includes("--hang")) { const pidFile = process.env.MCP_LIFECYCLE_PID_FILE @@ -10,7 +11,7 @@ if (process.argv.includes("--hang")) { const server = new Server({ name: "mcp-lifecycle-stdio", version: "1.0.0" }, { capabilities: { tools: {} } }) -server.setRequestHandler("tools/list", () => +server.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [ { diff --git a/packages/opencode/test/fixture/mcp-session-recovery.ts b/packages/opencode/test/fixture/mcp-session-recovery.ts index 381c187f0043..c20fb5aa5876 100644 --- a/packages/opencode/test/fixture/mcp-session-recovery.ts +++ b/packages/opencode/test/fixture/mcp-session-recovery.ts @@ -1,11 +1,10 @@ -import { Client, LATEST_PROTOCOL_VERSION, StreamableHTTPClientTransport } from "@modelcontextprotocol/client" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" +import { LATEST_PROTOCOL_VERSION } from "@modelcontextprotocol/sdk/types.js" const posts: Array<{ method: string; session: string | null }> = [] -const concurrent = process.env.MCP_RECOVERY_CONCURRENT === "1" let initializeCount = 0 let pingCount = 0 -let replacementStarted!: () => void -const replacement = new Promise((resolve) => (replacementStarted = resolve)) const server = Bun.serve({ port: 0, async fetch(request) { @@ -18,7 +17,6 @@ const server = Bun.serve({ if (message.method === "initialize") { initializeCount++ - if (initializeCount === 2) replacementStarted() return Response.json( { jsonrpc: "2.0", @@ -36,8 +34,7 @@ const server = Bun.serve({ if (message.method === "notifications/initialized") return new Response(null, { status: 202 }) pingCount++ - if (concurrent && pingCount === 2) await replacement - if (pingCount <= (concurrent ? 2 : 1)) return new Response("Session not found", { status: 404 }) + if (pingCount === 1) return new Response("Session not found", { status: 404 }) return Response.json({ jsonrpc: "2.0", id: message.id, result: {} }) }, }) @@ -45,8 +42,7 @@ const client = new Client({ name: "test", version: "1" }) try { await client.connect(new StreamableHTTPClientTransport(server.url)) - if (concurrent) await Promise.all([client.ping(), client.ping()]) - else await client.ping() + await client.ping() process.stdout.write(JSON.stringify(posts)) } finally { await client.close() diff --git a/packages/opencode/test/mcp/catalog.test.ts b/packages/opencode/test/mcp/catalog.test.ts index 3d77cb22b754..7b0d6403bb16 100644 --- a/packages/opencode/test/mcp/catalog.test.ts +++ b/packages/opencode/test/mcp/catalog.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test" -import { Client, InMemoryTransport } from "@modelcontextprotocol/client" -import { Server } from "@modelcontextprotocol/server" +import { Client } from "@modelcontextprotocol/sdk/client/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { CallToolRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { McpCatalog } from "@/mcp/catalog" import { Effect } from "effect" @@ -28,10 +30,7 @@ describe("McpCatalog.convertTool", () => { test("preserves content when structuredContent is also present", async () => { const content = [{ type: "image" as const, mimeType: "image/png", data: "AAAA" }] const structuredContent = { image: { mimeType: "image/png", data: "AAAA" } } - const converted = McpCatalog.convertTool({ - def: mcpTool(), - client: clientReturning({ content, structuredContent }), - }) + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content, structuredContent })) const output = await converted.execute?.({}, options) @@ -40,10 +39,7 @@ describe("McpCatalog.convertTool", () => { test("falls back to structuredContent only when content is absent", async () => { const structuredContent = { results: [{ title: "one" }] } - const converted = McpCatalog.convertTool({ - def: mcpTool(), - client: clientReturning({ content: [], structuredContent }), - }) + const converted = McpCatalog.convertTool(mcpTool(), clientReturning({ content: [], structuredContent })) const output = await converted.execute?.({}, options) @@ -54,52 +50,18 @@ describe("McpCatalog.convertTool", () => { }) }) -describe("McpCatalog.callTool", () => { - test("forwards the request options", async () => { - const controller = new AbortController() - let request: unknown - let options: unknown - const client = { - callTool: async (input: unknown, config: unknown) => { - request = input - options = config - return { content: [] } - }, - } as unknown as Client - - await McpCatalog.callTool({ def: mcpTool(), client, timeout: 123 }, { value: true }, controller.signal) - - expect(request).toEqual({ name: "screenshot", arguments: { value: true } }) - expect(options).toMatchObject({ resetTimeoutOnProgress: true, signal: controller.signal, timeout: 123 }) - expect(typeof (options as { onprogress?: unknown }).onprogress).toBe("function") - }) - - test("throws text returned by an MCP tool error", async () => { - const client = clientReturning({ - isError: true, - content: [ - { type: "image", data: "AAAA", mimeType: "image/png" }, - { type: "text", text: "first" }, - { type: "text", text: "second" }, - ], - }) - - await expect(McpCatalog.callTool({ def: mcpTool(), client }, {})).rejects.toThrow("first\n\nsecond") - }) -}) - test("preserves output schema validation across paginated tool discovery", async () => { const server = new Server({ name: "pagination", version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler("tools/list", ({ params }) => + server.setRequestHandler(ListToolsRequestSchema, ({ params }) => Promise.resolve( params?.cursor === "page-2" ? { tools: [ { name: "second", - inputSchema: { type: "object" as const }, + inputSchema: { type: "object" }, outputSchema: { - type: "object" as const, + type: "object", properties: { value: { type: "number" } }, required: ["value"], }, @@ -110,9 +72,9 @@ test("preserves output schema validation across paginated tool discovery", async tools: [ { name: "first", - inputSchema: { type: "object" as const }, + inputSchema: { type: "object" }, outputSchema: { - type: "object" as const, + type: "object", properties: { value: { type: "string" } }, required: ["value"], }, @@ -122,7 +84,7 @@ test("preserves output schema validation across paginated tool discovery", async }, ), ) - server.setRequestHandler("tools/call", ({ params }) => + server.setRequestHandler(CallToolRequestSchema, ({ params }) => Promise.resolve({ content: [], structuredContent: { value: params.name === "first" ? 42 : 1 }, @@ -136,7 +98,9 @@ test("preserves output schema validation across paginated tool discovery", async try { const tools = await Effect.runPromise(McpCatalog.defs(client)) expect(tools?.map((tool) => tool.name)).toEqual(["first", "second"]) - await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow(/output schema/i) + await expect(client.callTool({ name: "first", arguments: {} })).rejects.toThrow( + "Structured content does not match the tool's output schema", + ) } finally { await Promise.all([client.close(), server.close()]) } diff --git a/packages/opencode/test/mcp/headers.test.ts b/packages/opencode/test/mcp/headers.test.ts index 323aea2e478f..31cfc20d51c6 100644 --- a/packages/opencode/test/mcp/headers.test.ts +++ b/packages/opencode/test/mcp/headers.test.ts @@ -1,5 +1,7 @@ import { describe, expect } from "bun:test" -import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Effect } from "effect" import { testEffect } from "../lib/effect" @@ -11,7 +13,7 @@ const serve = Effect.acquireRelease( Effect.promise(async () => { const requests: Headers[] = [] const protocol = new Server({ name: "headers", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -36,11 +38,6 @@ const serve = Effect.acquireRelease( (server) => Effect.promise(server.close), ) -const serveUnauthorized = Effect.acquireRelease( - Effect.sync(() => Bun.serve({ port: 0, fetch: () => new Response("Unauthorized", { status: 401 }) })), - (server) => Effect.sync(() => server.stop(true)), -) - describe("mcp.headers", () => { it.instance("headers are passed to transports when oauth is enabled (default)", () => Effect.gen(function* () { @@ -102,18 +99,4 @@ describe("mcp.headers", () => { } }), ) - - it.instance("reports 401 as failed when oauth is explicitly disabled", () => - Effect.gen(function* () { - const server = yield* serveUnauthorized - const mcp = yield* MCP.Service - const result = yield* mcp.add("unauthorized-no-oauth", { - type: "remote", - url: server.url.toString(), - oauth: false, - }) - - expect(result.status).toMatchObject({ "unauthorized-no-oauth": { status: "failed" } }) - }), - ) }) diff --git a/packages/opencode/test/mcp/lifecycle.test.ts b/packages/opencode/test/mcp/lifecycle.test.ts index b0018987797f..80c8fd22f886 100644 --- a/packages/opencode/test/mcp/lifecycle.test.ts +++ b/packages/opencode/test/mcp/lifecycle.test.ts @@ -1,12 +1,18 @@ import path from "node:path" import { pathToFileURL } from "node:url" import { expect } from "bun:test" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" import { - Server, - WebStandardStreamableHTTPServerTransport, + GetPromptRequestSchema, + ListPromptsRequestSchema, + ListResourcesRequestSchema, + ListResourceTemplatesRequestSchema, + ListToolsRequestSchema, + ReadResourceRequestSchema, type ServerCapabilities, type Tool, -} from "@modelcontextprotocol/server" +} from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Cause, Effect, Exit } from "effect" import type { MCP as MCPNS } from "../../src/mcp/index" @@ -60,35 +66,35 @@ function lifecycleServer(input?: { capabilities?: ServerCapabilities; instructio }) if (capabilities.tools) { - protocol.setRequestHandler("tools/list", (request) => { + protocol.setRequestHandler(ListToolsRequestSchema, (request) => { if (state.listToolsError) throw new Error(state.listToolsError) const page = state.toolPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ tools: page?.items ?? state.tools, nextCursor: page?.nextCursor }) }) } if (capabilities.prompts) { - protocol.setRequestHandler("prompts/list", (request) => { + protocol.setRequestHandler(ListPromptsRequestSchema, (request) => { const page = state.promptPages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ prompts: page?.items ?? state.prompts, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler("prompts/get", async () => { + protocol.setRequestHandler(GetPromptRequestSchema, async () => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { messages: [{ role: "user", content: { type: "text", text: "prompt result" } }] } }) } if (capabilities.resources) { - protocol.setRequestHandler("resources/list", (request) => { + protocol.setRequestHandler(ListResourcesRequestSchema, (request) => { const page = state.resourcePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resources: page?.items ?? state.resources, nextCursor: page?.nextCursor }) }) - protocol.setRequestHandler("resources/templates/list", (request) => { + protocol.setRequestHandler(ListResourceTemplatesRequestSchema, (request) => { const page = state.resourceTemplatePages?.[request.params?.cursor ?? "initial"] return Promise.resolve({ resourceTemplates: page?.items ?? state.resourceTemplates, nextCursor: page?.nextCursor, }) }) - protocol.setRequestHandler("resources/read", async (request) => { + protocol.setRequestHandler(ReadResourceRequestSchema, async (request) => { if (state.requestDelay) await Bun.sleep(state.requestDelay) return { contents: [{ uri: request.params.uri, text: "resource result" }] } }) @@ -139,7 +145,7 @@ function hangingLifecycleServer() { return Effect.acquireRelease( Effect.promise(async () => { const protocol = new Server({ name: "mcp-lifecycle-hanging", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, @@ -278,7 +284,7 @@ it.instance("follows cursors when listing tools, prompts, resources, and templat }), ) -it.instance("accepts empty cursors and terminates on repeated cursors", () => +it.instance("accepts empty cursors and rejects repeated cursors", () => Effect.gen(function* () { const empty = yield* lifecycleServer({ capabilities: { prompts: {} } }) empty.state.promptPages = { @@ -295,8 +301,7 @@ it.instance("accepts empty cursors and terminates on repeated cursors", () => const result = yield* mcp.add("looping-cursor", remote(looping.url)) expect(Object.keys(yield* mcp.prompts())).toEqual(["empty-cursor:prompt-one", "empty-cursor:prompt-two"]) - expect(statusName(result.status, "looping-cursor")).toBe("connected") - expect(Object.keys(yield* mcp.tools())).toEqual([]) + expect(statusName(result.status, "looping-cursor")).toBe("failed") }), ) diff --git a/packages/opencode/test/mcp/oauth-auto-connect.test.ts b/packages/opencode/test/mcp/oauth-auto-connect.test.ts index 735dfaf8bedd..5f8889068c33 100644 --- a/packages/opencode/test/mcp/oauth-auto-connect.test.ts +++ b/packages/opencode/test/mcp/oauth-auto-connect.test.ts @@ -1,5 +1,7 @@ import { expect } from "bun:test" -import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListResourcesRequestSchema, ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { CrossSpawnSpawner } from "@opencode-ai/core/cross-spawn-spawner" import { FSUtil } from "@opencode-ai/core/fs-util" @@ -38,13 +40,13 @@ function serveOAuthMcp(options: OAuthMcpOptions = {}) { let requiresAuth = true if (capabilities === "tools") { - protocol.setRequestHandler("tools/list", () => { + protocol.setRequestHandler(ListToolsRequestSchema, () => { listToolsCalls++ return Promise.resolve({ tools: [{ name: "test_tool", inputSchema: { type: "object" } }] }) }) } if (capabilities === "resources") { - protocol.setRequestHandler("resources/list", () => + protocol.setRequestHandler(ListResourcesRequestSchema, () => Promise.resolve({ resources: [{ name: "docs", uri: "docs://readme" }] }), ) } diff --git a/packages/opencode/test/mcp/oauth-browser.test.ts b/packages/opencode/test/mcp/oauth-browser.test.ts index 507b59b691d4..9573805a9a14 100644 --- a/packages/opencode/test/mcp/oauth-browser.test.ts +++ b/packages/opencode/test/mcp/oauth-browser.test.ts @@ -1,5 +1,7 @@ import { expect } from "bun:test" -import { Server, WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { WebStandardStreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/webStandardStreamableHttp.js" +import { ListToolsRequestSchema } from "@modelcontextprotocol/sdk/types.js" import { LayerNode } from "@opencode-ai/core/effect/layer-node" import { Deferred, Effect, Layer, Option } from "effect" import { Config } from "../../src/config/config" @@ -39,7 +41,7 @@ const serveOAuthMcp = Effect.acquireRelease( Effect.promise(async () => { const requests: Array<{ pathname: string; headers: Headers }> = [] const protocol = new Server({ name: "oauth-browser", version: "1.0.0" }, { capabilities: { tools: {} } }) - protocol.setRequestHandler("tools/list", () => Promise.resolve({ tools: [] })) + protocol.setRequestHandler(ListToolsRequestSchema, () => Promise.resolve({ tools: [] })) const transport = new WebStandardStreamableHTTPServerTransport({ sessionIdGenerator: () => crypto.randomUUID(), enableJsonResponse: true, diff --git a/packages/opencode/test/mcp/oauth-callback.test.ts b/packages/opencode/test/mcp/oauth-callback.test.ts index b7db18034fe1..1666a37142b0 100644 --- a/packages/opencode/test/mcp/oauth-callback.test.ts +++ b/packages/opencode/test/mcp/oauth-callback.test.ts @@ -74,7 +74,7 @@ describe("McpOAuthCallback.ensureRunning", () => { const response = await fetch(`${redirectUri}?code=code&state=success`) expect(response.status).toBe(200) - expect(await callback).toEqual({ code: "code", iss: undefined }) + expect(await callback).toBe("code") expect(McpOAuthCallback.isRunning()).toBe(false) }) diff --git a/packages/opencode/test/mcp/oauth-provider.test.ts b/packages/opencode/test/mcp/oauth-provider.test.ts index 64c2cb668774..249c49e8f91d 100644 --- a/packages/opencode/test/mcp/oauth-provider.test.ts +++ b/packages/opencode/test/mcp/oauth-provider.test.ts @@ -1,4 +1,5 @@ import { test, expect, describe } from "bun:test" +import { determineScope } from "@modelcontextprotocol/sdk/client/auth.js" import { McpOAuthProvider, OAUTH_CALLBACK_PORT, OAUTH_CALLBACK_PATH } from "../../src/mcp/oauth-provider" import type { McpAuth } from "../../src/mcp/auth" @@ -59,3 +60,43 @@ describe("McpOAuthProvider.clientMetadata", () => { expect(provider.clientMetadata.token_endpoint_auth_method).toBe("none") }) }) + +describe("MCP OAuth scope selection", () => { + test("adds offline_access when the authorization server and client support refresh tokens", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read", "offline_access"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read offline_access") + }) + + test("does not add unsupported authorization server scopes", () => { + expect( + determineScope({ + resourceMetadata: { + resource: "https://mcp.example.com/mcp", + scopes_supported: ["resource.read"], + }, + authServerMetadata: { + issuer: "https://auth.example.com", + authorization_endpoint: "https://auth.example.com/authorize", + token_endpoint: "https://auth.example.com/token", + response_types_supported: ["code"], + scopes_supported: ["resource.read"], + }, + clientMetadata: makeProvider({}).clientMetadata, + }), + ).toBe("resource.read") + }) +}) diff --git a/packages/opencode/test/mcp/session-recovery.test.ts b/packages/opencode/test/mcp/session-recovery.test.ts index f7c5787a4e94..658650822007 100644 --- a/packages/opencode/test/mcp/session-recovery.test.ts +++ b/packages/opencode/test/mcp/session-recovery.test.ts @@ -24,24 +24,4 @@ describe("mcp session recovery", () => { { method: "ping", session: "replacement" }, ]) }) - - test("retries a concurrent stale response after recovery completes", async () => { - const child = Bun.spawn([process.execPath, path.join(import.meta.dir, "../fixture/mcp-session-recovery.ts")], { - cwd: path.join(import.meta.dir, "../.."), - env: { ...process.env, MCP_RECOVERY_CONCURRENT: "1" }, - stdout: "pipe", - stderr: "pipe", - }) - const [code, stdout, stderr] = await Promise.all([ - child.exited, - Bun.readableStreamToText(child.stdout), - Bun.readableStreamToText(child.stderr), - ]) - - expect(code, stderr).toBe(0) - const posts = JSON.parse(stdout) as Array<{ method: string; session: string | null }> - expect(posts.filter((post) => post.method === "initialize").map((post) => post.session)).toEqual([null, null]) - expect(posts.filter((post) => post.method === "ping" && post.session === "expired")).toHaveLength(2) - expect(posts.filter((post) => post.method === "ping" && post.session === "replacement")).toHaveLength(2) - }) }) diff --git a/packages/opencode/test/tool/code-mode-integration.test.ts b/packages/opencode/test/tool/code-mode-integration.test.ts index 32cb420681a9..671acd896222 100644 --- a/packages/opencode/test/tool/code-mode-integration.test.ts +++ b/packages/opencode/test/tool/code-mode-integration.test.ts @@ -8,14 +8,15 @@ import { Session } from "@/session/session" import { Tool } from "@/tool/tool" import * as Truncate from "@/tool/truncate" import { MessageID, SessionID } from "@/session/schema" -import { Server } from "@modelcontextprotocol/server" +import { Server } from "@modelcontextprotocol/sdk/server/index.js" +import { InMemoryTransport } from "@modelcontextprotocol/sdk/inMemory.js" +import type { Client } from "@modelcontextprotocol/sdk/client/index.js" import { - InMemoryTransport, + CallToolRequestSchema, LATEST_PROTOCOL_VERSION, - type CallToolResult, - type Client, + ListToolsRequestSchema, type Tool as MCPToolDef, -} from "@modelcontextprotocol/client" +} from "@modelcontextprotocol/sdk/types.js" import { Cause, Effect, Exit, Layer } from "effect" const PNG = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYPhfDwAChwGA60e6kgAAAABJRU5ErkJggg==" @@ -99,7 +100,7 @@ const TOOL_DEFS: MCPToolDef[] = [ }, ] as MCPToolDef[] -function handleCall(name: string, args: Record): CallToolResult { +function handleCall(name: string, args: Record) { switch (name) { case "get_text": return { content: [{ type: "text", text: `hello ${args.name}` }] } @@ -121,8 +122,8 @@ let description: string async function buildTool() { const server = new Server({ name: SERVER, version: "1.0.0" }, { capabilities: { tools: {} } }) - server.setRequestHandler("tools/list", async () => ({ tools: TOOL_DEFS })) - server.setRequestHandler("tools/call", async (req) => + server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: TOOL_DEFS })) + server.setRequestHandler(CallToolRequestSchema, async (req) => handleCall(req.params.name, (req.params.arguments ?? {}) as Record), ) diff --git a/packages/opencode/test/tool/code-mode.test.ts b/packages/opencode/test/tool/code-mode.test.ts index cc32d2a5f3ed..34b3faa610d7 100644 --- a/packages/opencode/test/tool/code-mode.test.ts +++ b/packages/opencode/test/tool/code-mode.test.ts @@ -1,6 +1,6 @@ import { describe, expect, test } from "bun:test" import { CODE_MODE_TOOL, CodeModeTool, Parameters, describeCatalog } from "@/tool/code-mode" -import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" import type { PermissionV1 } from "@opencode-ai/core/v1/permission" import { Agent } from "@/agent/agent" import { MCP } from "@/mcp" diff --git a/packages/opencode/test/tool/registry.test.ts b/packages/opencode/test/tool/registry.test.ts index c0810a6d7ed9..c8c5fac59559 100644 --- a/packages/opencode/test/tool/registry.test.ts +++ b/packages/opencode/test/tool/registry.test.ts @@ -20,7 +20,7 @@ import { RuntimeFlags } from "@/effect/runtime-flags" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { MCP } from "@/mcp" -import type { Tool as MCPToolDef } from "@modelcontextprotocol/client" +import type { Tool as MCPToolDef } from "@modelcontextprotocol/sdk/types.js" const configLayer = TestConfig.layer({ directories: () => InstanceState.directory.pipe(Effect.map((dir) => [path.join(dir, ".opencode")])), diff --git a/packages/sdk/js/src/v2/gen/sdk.gen.ts b/packages/sdk/js/src/v2/gen/sdk.gen.ts index babdbc9c517e..9ed0084aac84 100644 --- a/packages/sdk/js/src/v2/gen/sdk.gen.ts +++ b/packages/sdk/js/src/v2/gen/sdk.gen.ts @@ -2328,7 +2328,6 @@ export class Auth2 extends HeyApiClient { directory?: string workspace?: string code?: string - iss?: string }, options?: Options, ) { @@ -2341,7 +2340,6 @@ export class Auth2 extends HeyApiClient { { in: "query", key: "directory" }, { in: "query", key: "workspace" }, { in: "body", key: "code" }, - { in: "body", key: "iss" }, ], }, ], diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index f0db3236eabb..42d224780d32 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -8563,7 +8563,6 @@ export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartRespo export type McpAuthCallbackData = { body?: { code: string - iss?: string } path: { name: string diff --git a/patches/@modelcontextprotocol%2Fclient@2.0.0.patch b/patches/@modelcontextprotocol%2Fclient@2.0.0.patch deleted file mode 100644 index 833adea8a9a3..000000000000 --- a/patches/@modelcontextprotocol%2Fclient@2.0.0.patch +++ /dev/null @@ -1,214 +0,0 @@ -diff --git a/dist/index.cjs b/dist/index.cjs -index 635f1c0..9214f0b 100644 ---- a/dist/index.cjs -+++ b/dist/index.cjs -@@ -3211,6 +3211,7 @@ var Client = class extends require_src.Protocol { - */ - async _connectPlainLegacy(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - if (transport.sessionId !== void 0) { - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); -@@ -3227,6 +3228,7 @@ var Client = class extends require_src.Protocol { - * the handshake; its completion sets the negotiated (legacy) version. - */ - async _legacyHandshake(transport, options) { -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - const legacyVersions = require_src.legacyProtocolVersions(this._supportedProtocolVersions); - try { - const offeredVersion = legacyVersions[0]; -@@ -3265,6 +3267,7 @@ var Client = class extends require_src.Protocol { - await super.connect(transport); - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); -+ if (negotiatedProtocolVersion !== void 0 && !require_src.isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); - return; - } - this._resetConnectionState(); -@@ -5294,10 +5297,32 @@ var StreamableHTTPClientTransport = class { - } - } - async send(message, options) { -- return this._send(message, options, false); -+ return this._send(message, options, false, 0, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) return this._sessionRecovery; -+ if (!this.onsessionexpired) return false; -+ if (this._sessionId !== expiredSessionId) return true; -+ this._sessionId = void 0; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); -+ try { -+ return await this._sessionRecovery; -+ } catch (error) { -+ this._sessionId = void 0; -+ await this.close(); -+ throw error; -+ } finally { -+ this._sessionRecovery = void 0; -+ } - } -- async _send(message, options, isAuthRetry, stepUpRetries = 0) { -+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { - try { -+ const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); -+ const isInitialized = Array.isArray(message) ? message.some((m) => require_src.isInitializedNotification(m)) : require_src.isInitializedNotification(message); -+ if (this._sessionRecovery && !isHandshake && !isInitialized) { -+ await this._sessionRecovery; -+ options?.requestSignal?.throwIfAborted(); -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - this._startOrAuthSse({ -@@ -5309,8 +5334,8 @@ var StreamableHTTPClientTransport = class { - } - const headers = await this._commonHeaders(); - this._applyBodyDerivedHeaders(headers, message); -- const isHandshake = Array.isArray(message) ? message.some((m) => require_src.isInitializeRequest(m)) : require_src.isInitializeRequest(message); - if (isHandshake) headers.delete("mcp-session-id"); -+ const requestSessionId = headers.get("mcp-session-id") || void 0; - if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { - if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; - headers.set(name, value); -@@ -5332,8 +5357,14 @@ var StreamableHTTPClientTransport = class { - signal - }; - const response = await (this._fetch ?? fetch)(this._url, init); -- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; -+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; - if (!response.ok) { -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { -+ if (await this._recoverSession(requestSessionId)) { -+ options?.requestSignal?.throwIfAborted(); -+ return this._send(message, options, isAuthRetry, stepUpRetries, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - if (response.headers.has("www-authenticate")) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); -@@ -5351,7 +5382,7 @@ var StreamableHTTPClientTransport = class { - throw markAuthSeamEscape(error); - } - await response.text?.().catch(() => {}); -- return this._send(message, options, true, stepUpRetries); -+ return this._send(message, options, true, stepUpRetries, isSessionRetry); - } - await response.text?.().catch(() => {}); - if (isAuthRetry) throw markAuthSeamEscape(new require_src.SdkHttpError(require_src.SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { -@@ -5371,7 +5402,7 @@ var StreamableHTTPClientTransport = class { - statusText: response.statusText, - text - }, stepUpRetries) !== "AUTHORIZED") throw markAuthSeamEscape(new UnauthorizedError()); -- return this._send(message, options, isAuthRetry, stepUpRetries + 1); -+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); - } - } - if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { -diff --git a/dist/index.mjs b/dist/index.mjs -index f02ce3c..0a5a649 100644 ---- a/dist/index.mjs -+++ b/dist/index.mjs -@@ -3208,6 +3208,7 @@ var Client = class extends Protocol { - */ - async _connectPlainLegacy(transport, options) { - await super.connect(transport); -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - if (transport.sessionId !== void 0) { - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0) transport.setProtocolVersion?.(negotiatedProtocolVersion); -@@ -3224,6 +3225,7 @@ var Client = class extends Protocol { - * the handshake; its completion sets the negotiated (legacy) version. - */ - async _legacyHandshake(transport, options) { -+ transport.onsessionexpired = () => this._legacyHandshake(transport, options); - const legacyVersions = legacyProtocolVersions(this._supportedProtocolVersions); - try { - const offeredVersion = legacyVersions[0]; -@@ -3262,6 +3264,7 @@ var Client = class extends Protocol { - await super.connect(transport); - const negotiatedProtocolVersion = this._negotiatedProtocolVersion; - if (negotiatedProtocolVersion !== void 0 && transport.setProtocolVersion) transport.setProtocolVersion(negotiatedProtocolVersion); -+ if (negotiatedProtocolVersion !== void 0 && !isModernProtocolVersion(negotiatedProtocolVersion)) transport.onsessionexpired = () => this._legacyHandshake(transport, options); - return; - } - this._resetConnectionState(); -@@ -5291,10 +5294,32 @@ var StreamableHTTPClientTransport = class { - } - } - async send(message, options) { -- return this._send(message, options, false); -+ return this._send(message, options, false, 0, false); -+ } -+ async _recoverSession(expiredSessionId) { -+ if (this._sessionRecovery) return this._sessionRecovery; -+ if (!this.onsessionexpired) return false; -+ if (this._sessionId !== expiredSessionId) return true; -+ this._sessionId = void 0; -+ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired()).then(() => true); -+ try { -+ return await this._sessionRecovery; -+ } catch (error) { -+ this._sessionId = void 0; -+ await this.close(); -+ throw error; -+ } finally { -+ this._sessionRecovery = void 0; -+ } - } -- async _send(message, options, isAuthRetry, stepUpRetries = 0) { -+ async _send(message, options, isAuthRetry, stepUpRetries = 0, isSessionRetry = false) { - try { -+ const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); -+ const isInitialized = Array.isArray(message) ? message.some((m) => isInitializedNotification(m)) : isInitializedNotification(message); -+ if (this._sessionRecovery && !isHandshake && !isInitialized) { -+ await this._sessionRecovery; -+ options?.requestSignal?.throwIfAborted(); -+ } - const { resumptionToken, onresumptiontoken } = options || {}; - if (resumptionToken) { - this._startOrAuthSse({ -@@ -5306,8 +5331,8 @@ var StreamableHTTPClientTransport = class { - } - const headers = await this._commonHeaders(); - this._applyBodyDerivedHeaders(headers, message); -- const isHandshake = Array.isArray(message) ? message.some((m) => isInitializeRequest(m)) : isInitializeRequest(message); - if (isHandshake) headers.delete("mcp-session-id"); -+ const requestSessionId = headers.get("mcp-session-id") || void 0; - if (options?.headers !== void 0) for (const [name, value] of Object.entries(options.headers)) { - if (RESERVED_REQUEST_HEADER_NAMES.has(name.toLowerCase())) continue; - headers.set(name, value); -@@ -5329,8 +5354,14 @@ var StreamableHTTPClientTransport = class { - signal - }; - const response = await (this._fetch ?? fetch)(this._url, init); -- if (isHandshake && response.ok) this._sessionId = response.headers.get("mcp-session-id") || void 0; -+ if (isHandshake && response.ok && (requestSessionId === void 0 || this._sessionId === requestSessionId)) this._sessionId = response.headers.get("mcp-session-id") || void 0; - if (!response.ok) { -+ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitialized) { -+ if (await this._recoverSession(requestSessionId)) { -+ options?.requestSignal?.throwIfAborted(); -+ return this._send(message, options, isAuthRetry, stepUpRetries, true); -+ } -+ } - if (response.status === 401 && this._authProvider) { - if (response.headers.has("www-authenticate")) { - const { resourceMetadataUrl, scope } = extractWWWAuthenticateParams(response); -@@ -5348,7 +5379,7 @@ var StreamableHTTPClientTransport = class { - throw markAuthSeamEscape(error); - } - await response.text?.().catch(() => {}); -- return this._send(message, options, true, stepUpRetries); -+ return this._send(message, options, true, stepUpRetries, isSessionRetry); - } - await response.text?.().catch(() => {}); - if (isAuthRetry) throw markAuthSeamEscape(new SdkHttpError(SdkErrorCode.ClientHttpAuthentication, "Server returned 401 after re-authentication", { -@@ -5368,7 +5399,7 @@ var StreamableHTTPClientTransport = class { - statusText: response.statusText, - text - }, stepUpRetries) !== "AUTHORIZED") throw markAuthSeamEscape(new UnauthorizedError()); -- return this._send(message, options, isAuthRetry, stepUpRetries + 1); -+ return this._send(message, options, isAuthRetry, stepUpRetries + 1, isSessionRetry); - } - } - if (response.status === 400 && typeof text === "string" && this._isModernEnvelopedRequest(message)) try { diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch new file mode 100644 index 000000000000..13b8000a0139 --- /dev/null +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -0,0 +1,629 @@ +diff --git a/dist/cjs/client/index.d.ts b/dist/cjs/client/index.d.ts +index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +--- a/dist/cjs/client/index.d.ts ++++ b/dist/cjs/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ +diff --git a/dist/esm/client/index.d.ts b/dist/esm/client/index.d.ts +index 1822bf749aec71d2bb295083d832114ee187bb67..58b859a7b32222fb5cb9f2011fdc5d010f3d05fb 100644 +--- a/dist/esm/client/index.d.ts ++++ b/dist/esm/client/index.d.ts +@@ -428,6 +428,8 @@ export declare class Client>; ++ callTool(params: CallToolRequest['params'], resultSchema: T, options?: RequestOptions): Promise>; + callTool(params: CallToolRequest['params'], resultSchema?: typeof CallToolResultSchema | typeof CompatibilityCallToolResultSchema, options?: RequestOptions): Promise<{ + [x: string]: unknown; + content: ({ +diff --git a/dist/cjs/client/index.js b/dist/cjs/client/index.js +index 6ac1da14dc7f6211ae70f7711c124b76098816d8..adb5b7bd45514a406a0f7e40b64631c101584c84 100644 +--- a/dist/cjs/client/index.js ++++ b/dist/cjs/client/index.js +@@ -288,41 +288,16 @@ class Client extends protocol_js_1.Protocol { + } + async connect(transport, options) { + await super.connect(transport); ++ transport.onsessionexpired = async () => { ++ await this._initialize(transport); ++ }; + // When transport sessionId is already set this means we are trying to reconnect. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } + try { +- const result = await this.request({ +- method: 'initialize', +- params: { +- protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, +- capabilities: this._capabilities, +- clientInfo: this._clientInfo +- } +- }, types_js_1.InitializeResultSchema, options); +- if (result === undefined) { +- throw new Error(`Server sent invalid initialize result: ${result}`); +- } +- if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { +- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); +- } +- this._serverCapabilities = result.capabilities; +- this._serverVersion = result.serverInfo; +- // HTTP transports must set the protocol version in each header after initialization. +- if (transport.setProtocolVersion) { +- transport.setProtocolVersion(result.protocolVersion); +- } +- this._instructions = result.instructions; +- await this.notification({ +- method: 'notifications/initialized' +- }); +- // Set up list changed handlers now that we know server capabilities +- if (this._pendingListChangedConfig) { +- this._setupListChangedHandlers(this._pendingListChangedConfig); +- this._pendingListChangedConfig = undefined; +- } ++ await this._initialize(transport, options); + } + catch (error) { + // Disconnect if initialization fails. +@@ -330,6 +305,37 @@ class Client extends protocol_js_1.Protocol { + throw error; + } + } ++ async _initialize(transport, options) { ++ const result = await this.request({ ++ method: 'initialize', ++ params: { ++ protocolVersion: types_js_1.LATEST_PROTOCOL_VERSION, ++ capabilities: this._capabilities, ++ clientInfo: this._clientInfo ++ } ++ }, types_js_1.InitializeResultSchema, options); ++ if (result === undefined) { ++ throw new Error(`Server sent invalid initialize result: ${result}`); ++ } ++ if (!types_js_1.SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { ++ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); ++ } ++ this._serverCapabilities = result.capabilities; ++ this._serverVersion = result.serverInfo; ++ // HTTP transports must set the protocol version in each header after initialization. ++ if (transport.setProtocolVersion) { ++ transport.setProtocolVersion(result.protocolVersion); ++ } ++ this._instructions = result.instructions; ++ await this.notification({ ++ method: 'notifications/initialized' ++ }); ++ // Set up list changed handlers now that we know server capabilities ++ if (this._pendingListChangedConfig) { ++ this._setupListChangedHandlers(this._pendingListChangedConfig); ++ this._pendingListChangedConfig = undefined; ++ } ++ } + /** + * After initialization has completed, this will be populated with the server's reported capabilities. + */ +@@ -541,9 +547,11 @@ class Client extends protocol_js_1.Protocol { + * Called after listTools() to pre-compile validators for better performance. + */ +- cacheToolMetadata(tools) { +- this._cachedToolOutputValidators.clear(); +- this._cachedKnownTaskTools.clear(); +- this._cachedRequiredTaskTools.clear(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { +@@ -569,7 +577,7 @@ class Client extends protocol_js_1.Protocol { + async listTools(params, options) { + const result = await this.request({ method: 'tools/list', params }, types_js_1.ListToolsResultSchema, options); + // Cache the tools and their output schemas for future validation +- this.cacheToolMetadata(result.tools); ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); + return result; + } + /** +diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp.js +index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 +--- a/dist/cjs/client/streamableHttp.js ++++ b/dist/cjs/client/streamableHttp.js +@@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { + this.onclose?.(); + } + async send(message, options) { ++ return this._send(message, options, false); ++ } ++ async _recoverSession(expiredSessionId) { ++ if (this._sessionRecovery) { ++ await this._sessionRecovery; ++ return true; ++ } ++ if (this._sessionId !== expiredSessionId) ++ return true; ++ this._sessionId = undefined; ++ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); + try { ++ await this._sessionRecovery; ++ } ++ catch (error) { ++ this._sessionId = undefined; ++ await this.close(); ++ throw error; ++ } ++ finally { ++ this._sessionRecovery = undefined; ++ } ++ return true; ++ } ++ async _send(message, options, isSessionRetry) { ++ try { ++ if (this._sessionRecovery && !(0, types_js_1.isInitializeRequest)(message) && !(0, types_js_1.isInitializedNotification)(message)) { ++ await this._sessionRecovery; ++ if (options?.isRequestActive?.() === false) { ++ throw new Error('Request is no longer active'); ++ } ++ } + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { + // If we have at last event ID, we need to reconnect the SSE stream +@@ -298,6 +329,7 @@ class StreamableHTTPClientTransport { + return; + } + const headers = await this._commonHeaders(); ++ const requestSessionId = headers.get('mcp-session-id') ?? undefined; + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json, text/event-stream'); + const init = { +@@ -310,11 +342,20 @@ class StreamableHTTPClientTransport { + const response = await (this._fetch ?? fetch)(this._url, init); + // Handle session ID received during initialization + const sessionId = response.headers.get('mcp-session-id'); +- if (sessionId) { ++ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { + this._sessionId = sessionId; + } + if (!response.ok) { + const text = await response.text().catch(() => null); ++ if (response.status === 404 && requestSessionId && !isSessionRetry && !(0, types_js_1.isInitializedNotification)(message)) { ++ const recovered = await this._recoverSession(requestSessionId); ++ if (options?.isRequestActive?.() === false) { ++ throw new Error('Request is no longer active'); ++ } ++ if (recovered) { ++ return this._send(message, options, true); ++ } ++ } + if (response.status === 401 && this._authProvider) { + // Prevent infinite recursion when server returns 401 after successful auth + if (this._hasCompletedAuthFlow) { +@@ -335,7 +376,7 @@ class StreamableHTTPClientTransport { + // Mark that we completed auth flow + this._hasCompletedAuthFlow = true; + // Purposely _not_ awaited, so we don't call onerror twice +- return this.send(message); ++ return this._send(message, options, isSessionRetry); + } + if (response.status === 403 && this._authProvider) { + const { resourceMetadataUrl, scope, error } = (0, auth_js_1.extractWWWAuthenticateParams)(response); +@@ -362,7 +403,7 @@ class StreamableHTTPClientTransport { + if (result !== 'AUTHORIZED') { + throw new auth_js_1.UnauthorizedError(); + } +- return this.send(message); ++ return this._send(message, options, isSessionRetry); + } + } + throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); +diff --git a/dist/cjs/shared/protocol.js b/dist/cjs/shared/protocol.js +index 3617e787f0ba70447c99501aee7aa67584d89758..4a96d6a0328fa348b96f3869ab7e0bb77538182b 100644 +--- a/dist/cjs/shared/protocol.js ++++ b/dist/cjs/shared/protocol.js +@@ -744,7 +744,12 @@ class Protocol { + } + else { + // No related task - send through transport normally +- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { ++ this._transport.send(jsonrpcRequest, { ++ relatedRequestId, ++ resumptionToken, ++ onresumptiontoken, ++ isRequestActive: () => this._responseHandlers.has(messageId) ++ }).catch(error => { + this._cleanupTimeout(messageId); + reject(error); + }); +diff --git a/dist/cjs/client/auth.d.ts b/dist/cjs/client/auth.d.ts +index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 +--- a/dist/cjs/client/auth.d.ts ++++ b/dist/cjs/client/auth.d.ts +@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++export declare function determineScope(options: { ++ requestedScope?: string; ++ resourceMetadata?: OAuthProtectedResourceMetadata; ++ authServerMetadata?: AuthorizationServerMetadata; ++ clientMetadata: OAuthClientMetadata; ++}): string | undefined; + /** + * Orchestrates the full auth flow with a server. + * +diff --git a/dist/cjs/client/auth.js b/dist/cjs/client/auth.js +index c2e4fa91d26f5336889f6afa416147db75fc4872..178d7cfd96412d53bc14bbc13a8f76c11f727ee7 100644 +--- a/dist/cjs/client/auth.js ++++ b/dist/cjs/client/auth.js +@@ -7,6 +7,7 @@ exports.UnauthorizedError = void 0; + exports.selectClientAuthMethod = selectClientAuthMethod; + exports.parseErrorResponse = parseErrorResponse; + exports.auth = auth; ++exports.determineScope = determineScope; + exports.isHttpsUrl = isHttpsUrl; + exports.selectResourceURL = selectResourceURL; + exports.extractWWWAuthenticateParams = extractWWWAuthenticateParams; +@@ -186,6 +187,19 @@ async function auth(provider, options) { + throw error; + } + } ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { ++ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; ++ if (effectiveScope && ++ authServerMetadata?.scopes_supported?.includes('offline_access') && ++ !effectiveScope.split(' ').includes('offline_access') && ++ clientMetadata.grant_types?.includes('refresh_token')) { ++ effectiveScope = `${effectiveScope} offline_access`; ++ } ++ return effectiveScope; ++} + async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); +@@ -241,12 +255,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + }); + } + const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); +- // Apply scope selection strategy (SEP-835): +- // 1. WWW-Authenticate scope (passed via `scope` param) +- // 2. PRM scopes_supported +- // 3. Client metadata scope (user-configured fallback) +- // The resolved scope is used consistently for both DCR and the authorization request. +- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; ++ const resolvedScope = determineScope({ ++ requestedScope: scope, ++ resourceMetadata, ++ authServerMetadata: metadata, ++ clientMetadata: provider.clientMetadata ++ }); + // Handle client registration if needed + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { +@@ -741,7 +755,7 @@ async function startAuthorization(authorizationServerUrl, { metadata, clientInfo + if (scope) { + authorizationUrl.searchParams.set('scope', scope); + } +- if (scope?.includes('offline_access')) { ++ if (scope?.split(' ').includes('offline_access')) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess +diff --git a/dist/esm/client/auth.d.ts b/dist/esm/client/auth.d.ts +index f4363ce7c94fbddf0e1d5943b1b26682bdbaa40e..e7dd57096e4f056bcd735d5081433beea1b32f04 100644 +--- a/dist/esm/client/auth.d.ts ++++ b/dist/esm/client/auth.d.ts +@@ -205,6 +205,15 @@ export declare function parseErrorResponse(input: Response | string): Promise; ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++export declare function determineScope(options: { ++ requestedScope?: string; ++ resourceMetadata?: OAuthProtectedResourceMetadata; ++ authServerMetadata?: AuthorizationServerMetadata; ++ clientMetadata: OAuthClientMetadata; ++}): string | undefined; + /** + * Orchestrates the full auth flow with a server. + * +diff --git a/dist/esm/client/auth.js b/dist/esm/client/auth.js +index e183040fc2bba22ca1ccc784984f3310854403b7..d367661e580ee61a96654f7af78b2af61dcad98b 100644 +--- a/dist/esm/client/auth.js ++++ b/dist/esm/client/auth.js +@@ -161,6 +161,19 @@ export async function auth(provider, options) { + throw error; + } + } ++/** ++ * Selects scopes per the MCP spec and augments them for refresh token support. ++ */ ++export function determineScope({ requestedScope, resourceMetadata, authServerMetadata, clientMetadata }) { ++ let effectiveScope = requestedScope || resourceMetadata?.scopes_supported?.join(' ') || clientMetadata.scope; ++ if (effectiveScope && ++ authServerMetadata?.scopes_supported?.includes('offline_access') && ++ !effectiveScope.split(' ').includes('offline_access') && ++ clientMetadata.grant_types?.includes('refresh_token')) { ++ effectiveScope = `${effectiveScope} offline_access`; ++ } ++ return effectiveScope; ++} + async function authInternal(provider, { serverUrl, authorizationCode, scope, resourceMetadataUrl, fetchFn }) { + // Check if the provider has cached discovery state to skip discovery + const cachedState = await provider.discoveryState?.(); +@@ -216,12 +229,12 @@ async function authInternal(provider, { serverUrl, authorizationCode, scope, res + }); + } + const resource = await selectResourceURL(serverUrl, provider, resourceMetadata); +- // Apply scope selection strategy (SEP-835): +- // 1. WWW-Authenticate scope (passed via `scope` param) +- // 2. PRM scopes_supported +- // 3. Client metadata scope (user-configured fallback) +- // The resolved scope is used consistently for both DCR and the authorization request. +- const resolvedScope = scope || resourceMetadata?.scopes_supported?.join(' ') || provider.clientMetadata.scope; ++ const resolvedScope = determineScope({ ++ requestedScope: scope, ++ resourceMetadata, ++ authServerMetadata: metadata, ++ clientMetadata: provider.clientMetadata ++ }); + // Handle client registration if needed + let clientInformation = await Promise.resolve(provider.clientInformation()); + if (!clientInformation) { +@@ -716,7 +729,7 @@ export async function startAuthorization(authorizationServerUrl, { metadata, cli + if (scope) { + authorizationUrl.searchParams.set('scope', scope); + } +- if (scope?.includes('offline_access')) { ++ if (scope?.split(' ').includes('offline_access')) { + // if the request includes the OIDC-only "offline_access" scope, + // we need to set the prompt to "consent" to ensure the user is prompted to grant offline access + // https://openid.net/specs/openid-connect-core-1_0.html#OfflineAccess +diff --git a/dist/esm/client/index.js b/dist/esm/client/index.js +index 49b12c6cd918c457420fef7ad5528a9443d1a191..2afe2e22e960f26c9d516ef135d89f8eb9e4caff 100644 +--- a/dist/esm/client/index.js ++++ b/dist/esm/client/index.js +@@ -284,41 +284,16 @@ export class Client extends Protocol { + } + async connect(transport, options) { + await super.connect(transport); ++ transport.onsessionexpired = async () => { ++ await this._initialize(transport); ++ }; + // When transport sessionId is already set this means we are trying to reconnect. + // In this case we don't need to initialize again. + if (transport.sessionId !== undefined) { + return; + } + try { +- const result = await this.request({ +- method: 'initialize', +- params: { +- protocolVersion: LATEST_PROTOCOL_VERSION, +- capabilities: this._capabilities, +- clientInfo: this._clientInfo +- } +- }, InitializeResultSchema, options); +- if (result === undefined) { +- throw new Error(`Server sent invalid initialize result: ${result}`); +- } +- if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { +- throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); +- } +- this._serverCapabilities = result.capabilities; +- this._serverVersion = result.serverInfo; +- // HTTP transports must set the protocol version in each header after initialization. +- if (transport.setProtocolVersion) { +- transport.setProtocolVersion(result.protocolVersion); +- } +- this._instructions = result.instructions; +- await this.notification({ +- method: 'notifications/initialized' +- }); +- // Set up list changed handlers now that we know server capabilities +- if (this._pendingListChangedConfig) { +- this._setupListChangedHandlers(this._pendingListChangedConfig); +- this._pendingListChangedConfig = undefined; +- } ++ await this._initialize(transport, options); + } + catch (error) { + // Disconnect if initialization fails. +@@ -326,6 +301,37 @@ export class Client extends Protocol { + throw error; + } + } ++ async _initialize(transport, options) { ++ const result = await this.request({ ++ method: 'initialize', ++ params: { ++ protocolVersion: LATEST_PROTOCOL_VERSION, ++ capabilities: this._capabilities, ++ clientInfo: this._clientInfo ++ } ++ }, InitializeResultSchema, options); ++ if (result === undefined) { ++ throw new Error(`Server sent invalid initialize result: ${result}`); ++ } ++ if (!SUPPORTED_PROTOCOL_VERSIONS.includes(result.protocolVersion)) { ++ throw new Error(`Server's protocol version is not supported: ${result.protocolVersion}`); ++ } ++ this._serverCapabilities = result.capabilities; ++ this._serverVersion = result.serverInfo; ++ // HTTP transports must set the protocol version in each header after initialization. ++ if (transport.setProtocolVersion) { ++ transport.setProtocolVersion(result.protocolVersion); ++ } ++ this._instructions = result.instructions; ++ await this.notification({ ++ method: 'notifications/initialized' ++ }); ++ // Set up list changed handlers now that we know server capabilities ++ if (this._pendingListChangedConfig) { ++ this._setupListChangedHandlers(this._pendingListChangedConfig); ++ this._pendingListChangedConfig = undefined; ++ } ++ } + /** + * After initialization has completed, this will be populated with the server's reported capabilities. + */ +@@ -537,9 +543,11 @@ export class Client extends Protocol { + * Called after listTools() to pre-compile validators for better performance. + */ +- cacheToolMetadata(tools) { +- this._cachedToolOutputValidators.clear(); +- this._cachedKnownTaskTools.clear(); +- this._cachedRequiredTaskTools.clear(); ++ cacheToolMetadata(tools, reset = true) { ++ if (reset) { ++ this._cachedToolOutputValidators.clear(); ++ this._cachedKnownTaskTools.clear(); ++ this._cachedRequiredTaskTools.clear(); ++ } + for (const tool of tools) { + // If the tool has an outputSchema, create and cache the validator + if (tool.outputSchema) { +@@ -565,7 +573,7 @@ export class Client extends Protocol { + async listTools(params, options) { + const result = await this.request({ method: 'tools/list', params }, ListToolsResultSchema, options); + // Cache the tools and their output schemas for future validation +- this.cacheToolMetadata(result.tools); ++ this.cacheToolMetadata(result.tools, params?.cursor === undefined); + return result; + } + /** +diff --git a/dist/esm/client/streamableHttp.js b/dist/esm/client/streamableHttp.js +index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da884fcc627a 100644 +--- a/dist/esm/client/streamableHttp.js ++++ b/dist/esm/client/streamableHttp.js +@@ -1,5 +1,5 @@ + import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; +-import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; ++import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; + import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; + import { EventSourceParserStream } from 'eventsource-parser/stream'; + // Default reconnection options for StreamableHTTP connections +@@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { + this.onclose?.(); + } + async send(message, options) { ++ return this._send(message, options, false); ++ } ++ async _recoverSession(expiredSessionId) { ++ if (this._sessionRecovery) { ++ await this._sessionRecovery; ++ return true; ++ } ++ if (this._sessionId !== expiredSessionId) ++ return true; ++ this._sessionId = undefined; ++ this._sessionRecovery = Promise.resolve().then(() => this.onsessionexpired?.()); + try { ++ await this._sessionRecovery; ++ } ++ catch (error) { ++ this._sessionId = undefined; ++ await this.close(); ++ throw error; ++ } ++ finally { ++ this._sessionRecovery = undefined; ++ } ++ return true; ++ } ++ async _send(message, options, isSessionRetry) { ++ try { ++ if (this._sessionRecovery && !isInitializeRequest(message) && !isInitializedNotification(message)) { ++ await this._sessionRecovery; ++ if (options?.isRequestActive?.() === false) { ++ throw new Error('Request is no longer active'); ++ } ++ } + const { resumptionToken, onresumptiontoken } = options || {}; + if (resumptionToken) { + // If we have at last event ID, we need to reconnect the SSE stream +@@ -294,6 +325,7 @@ export class StreamableHTTPClientTransport { + return; + } + const headers = await this._commonHeaders(); ++ const requestSessionId = headers.get('mcp-session-id') ?? undefined; + headers.set('content-type', 'application/json'); + headers.set('accept', 'application/json, text/event-stream'); + const init = { +@@ -306,11 +338,20 @@ export class StreamableHTTPClientTransport { + const response = await (this._fetch ?? fetch)(this._url, init); + // Handle session ID received during initialization + const sessionId = response.headers.get('mcp-session-id'); +- if (sessionId) { ++ if (sessionId && (requestSessionId === undefined || this._sessionId === requestSessionId)) { + this._sessionId = sessionId; + } + if (!response.ok) { + const text = await response.text().catch(() => null); ++ if (response.status === 404 && requestSessionId && !isSessionRetry && !isInitializedNotification(message)) { ++ const recovered = await this._recoverSession(requestSessionId); ++ if (options?.isRequestActive?.() === false) { ++ throw new Error('Request is no longer active'); ++ } ++ if (recovered) { ++ return this._send(message, options, true); ++ } ++ } + if (response.status === 401 && this._authProvider) { + // Prevent infinite recursion when server returns 401 after successful auth + if (this._hasCompletedAuthFlow) { +@@ -331,7 +372,7 @@ export class StreamableHTTPClientTransport { + // Mark that we completed auth flow + this._hasCompletedAuthFlow = true; + // Purposely _not_ awaited, so we don't call onerror twice +- return this.send(message); ++ return this._send(message, options, isSessionRetry); + } + if (response.status === 403 && this._authProvider) { + const { resourceMetadataUrl, scope, error } = extractWWWAuthenticateParams(response); +@@ -358,7 +399,7 @@ export class StreamableHTTPClientTransport { + if (result !== 'AUTHORIZED') { + throw new UnauthorizedError(); + } +- return this.send(message); ++ return this._send(message, options, isSessionRetry); + } + } + throw new StreamableHTTPError(response.status, `Error POSTing to endpoint: ${text}`); +diff --git a/dist/esm/shared/protocol.js b/dist/esm/shared/protocol.js +index bfa2b7120a0f50c569364ea5264e6f811076f44f..abd8dfd707c155f71dae7aeeeeaf7547368ac749 100644 +--- a/dist/esm/shared/protocol.js ++++ b/dist/esm/shared/protocol.js +@@ -740,7 +740,12 @@ export class Protocol { + } + else { + // No related task - send through transport normally +- this._transport.send(jsonrpcRequest, { relatedRequestId, resumptionToken, onresumptiontoken }).catch(error => { ++ this._transport.send(jsonrpcRequest, { ++ relatedRequestId, ++ resumptionToken, ++ onresumptiontoken, ++ isRequestActive: () => this._responseHandlers.has(messageId) ++ }).catch(error => { + this._cleanupTimeout(messageId); + reject(error); + }); From d8a9d8a76d621101376cc9334ae298f0a12c0771 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 16:55:30 +0000 Subject: [PATCH 130/333] chore: generate --- packages/sdk/openapi.json | 3 --- 1 file changed, 3 deletions(-) diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index 6150b75e64c6..b300754bc859 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -3360,9 +3360,6 @@ "properties": { "code": { "type": "string" - }, - "iss": { - "type": "string" } }, "required": ["code"], From f28d72d15e00f51f7e30f9cbf08b810f292bb3ee Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Tue, 28 Jul 2026 17:12:42 +0000 Subject: [PATCH 131/333] 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 d1bd9c51318e..62788beafdf3 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-wLjPbbeOF9OUNNGceYtEJNiBtF6TPjX/lnyMkdW170c=", - "aarch64-linux": "sha256-P7hrfVp3pILY/uAOdJweGg6IcXBLuU69yqko1qd1caw=", - "aarch64-darwin": "sha256-MJzCshCjkbypnW3yhmR8/zCadIopcHUfVT293N0FMY8=", - "x86_64-darwin": "sha256-6fR137gR66eI+VyvDkR2U51yoJ9BuvaK4YoMw+3E2A0=" + "x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=", + "aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=", + "aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=", + "x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM=" } } From a45c2b917e657e50881117e8c3f85f4bff06e47d Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 28 Jul 2026 18:14:59 +0000 Subject: [PATCH 132/333] sync release versions for v1.18.9 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 96458854bc30..19f5a97cb27d 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.8", + "version": "1.18.9", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.8", + "version": "1.18.9", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.8", + "version": "1.18.9", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -841,7 +841,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +854,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +888,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +907,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +949,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1027,7 +1027,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 73840b2dbf77..7199ad942546 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.8", + "version": "1.18.9", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 45271e869c99..d64acf4840d8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 51df6e53a576..83cfeaac0c08 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.8", + "version": "1.18.9", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index 446c3d0854ae..b8fd9a04c66e 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f5a1bbb72a4b..f922ab9ef2fa 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index 937c0cf5386f..aadfa42be10d 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.8", + "version": "1.18.9", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 531b0ec651e1..1a551ed0a5ea 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.8", + "version": "1.18.9", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index a556e236bb0f..6d90a394cb4f 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index c1635e632384..2325bcc01eb5 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.8", + "version": "1.18.9", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 2f3b90ae9ba1..1a5baf91b096 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index a3e9ea8814ef..6ae8d912a1e5 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.8", + "version": "1.18.9", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index 507ad6769ea3..d26bea7f85ae 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.8", + "version": "1.18.9", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 7a4c65783f60..d298dd1dee0f 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index e7f851616160..6acd31710e7b 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.8", + "version": "1.18.9", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index 1eaf4333fc25..cca67e0c3096 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.8", + "version": "1.18.9", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 6d3a826e59a1..69e1ea92ebdb 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.8", + "version": "1.18.9", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 8f7af65a8f99..b7ee6973d8d2 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.8", + "version": "1.18.9", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index fefab272f5fe..2607c317d696 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index 8e8c19d73b86..f92dca0c4d20 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 4532b2e37bc8..89d7360a79d0 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index 879f254df122..eb67c17d1905 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 5636890bb27e..7ec9f938b7a9 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index aceecc49d5d7..8e69fd7193df 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 73de8bbced2a..45e6acf9763f 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index af0f57d18754..5d34525aa17b 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 8207147917cd..9900a378491a 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.8", + "version": "1.18.9", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index e5e7a15a1cce..05c4ffbf63bc 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.8", + "version": "1.18.9", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index 963e9dfc5f5a..bdc41cdd677b 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.8", + "version": "1.18.9", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index a5948bb436eb..33c79c001d49 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.8", + "version": "1.18.9", "publisher": "sst-dev", "repository": { "type": "git", From f256a4c538a2b13f9800fa9a846cc03ffad2b0ca Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 29 Jul 2026 09:24:31 +0800 Subject: [PATCH 133/333] fix(app): preserve agent picker for existing users (#39300) --- packages/app/src/context/settings.test.ts | 17 +++++++++++ packages/app/src/context/settings.tsx | 30 ++++++++++++++++++-- packages/desktop/src/renderer/onboarding.tsx | 1 + 3 files changed, 45 insertions(+), 3 deletions(-) diff --git a/packages/app/src/context/settings.test.ts b/packages/app/src/context/settings.test.ts index 3f94f22ec3ff..51b35eacbba7 100644 --- a/packages/app/src/context/settings.test.ts +++ b/packages/app/src/context/settings.test.ts @@ -1,6 +1,7 @@ import { describe, expect, test } from "bun:test" import { hasExistingWebState, + initialAgentVisibility, isAppUpgrade, layoutTransitionState, maximumSunsetTimeout, @@ -11,6 +12,22 @@ import { shouldEnableNewLayout, } from "./settings" +describe("agent visibility", () => { + test("shows the picker for existing profiles and hides it for first-time installs", () => { + expect(initialAgentVisibility(undefined, true)).toBe(true) + expect(initialAgentVisibility(undefined, false)).toBe(false) + }) + + test("shows the picker when updating from a recent release", () => { + expect(initialAgentVisibility(undefined, false, "1.18.8")).toBe(true) + }) + + test("preserves the preference after initialization", () => { + expect(initialAgentVisibility(true, true, "1.18.8")).toBeUndefined() + expect(initialAgentVisibility(true, false)).toBeUndefined() + }) +}) + describe("layout transition", () => { test("blank profiles default to the new layout", () => { expect(newLayoutDesignsDefault).toBe(true) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index fe8b4e3c03f7..23dbb531743f 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -1,5 +1,5 @@ import { createStore, reconcile } from "solid-js/store" -import { createEffect, createMemo, createSignal, onCleanup } from "solid-js" +import { batch, createEffect, createMemo, createSignal, onCleanup } from "solid-js" import { createSimpleContext } from "@opencode-ai/ui/context" import { persisted } from "@/utils/persist" import { usePlatform } from "@/context/platform" @@ -36,6 +36,7 @@ export interface Settings { mobileTitlebarPosition: "top" | "bottom" newLayoutDesigns?: boolean layoutTransitionEligible?: boolean + agentVisibilityInitialized?: boolean newInterfaceNoticeDismissed?: boolean shouldDisplayTabsToast?: boolean } @@ -93,6 +94,15 @@ export function hasExistingWebState(settings: Promise | string | null, p return settings !== null || previousVersion !== undefined } +export function initialAgentVisibility( + initialized: boolean | undefined, + existing: boolean, + previousVersion?: string, +) { + if (initialized === true) return + return existing || previousVersion !== undefined +} + export function shouldEnableNewLayout(previous: string | undefined, current: string | undefined) { if (!current) return false const currentComparison = compareVersions(current, newLayoutDesignsUpgradeCutoff) @@ -271,6 +281,18 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont ) }) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) + const initializeAgentVisibility = (existing: boolean) => { + const initial = initialAgentVisibility( + store.general?.agentVisibilityInitialized, + existing, + launchState.previous, + ) + if (initial === undefined) return + batch(() => { + setStore("general", "showCustomAgents", initial) + setStore("general", "agentVisibilityInitialized", true) + }) + } if (sunset && !oldInterfaceRetired()) { const timeout = { current: undefined as ReturnType | undefined } @@ -299,8 +321,9 @@ 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)) + const existing = hasExistingWebState(settingsInit, launchState.previous) + if (!layoutTransitionClassified()) setStore("general", "layoutTransitionEligible", existing) + initializeAgentVisibility(existing) }) createEffect(() => { @@ -426,6 +449,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont if (typeof current === "boolean") return setStore("general", "layoutTransitionEligible", eligible) }, + initializeAgentVisibility, layoutTransitionAvailable: createMemo(() => ready() && layoutTransition().available), newInterfaceNoticeVisible: createMemo(() => ready() && layoutTransition().notice), dismissNewInterfaceNotice() { diff --git a/packages/desktop/src/renderer/onboarding.tsx b/packages/desktop/src/renderer/onboarding.tsx index 76c7ff683ad0..c5d7ff53896a 100644 --- a/packages/desktop/src/renderer/onboarding.tsx +++ b/packages/desktop/src/renderer/onboarding.tsx @@ -17,6 +17,7 @@ export function DesktopFirstLaunchOnboarding(props: { initialUrl: string; onLoad ) const existingInstall = await window.api.isOldLayoutEligible() settings.general.setOldLayoutEligible(existingInstall) + settings.general.initializeAgentVisibility(existingInstall) if (!server.isLocal()) return const pending = await window.api.isFirstLaunchOnboardingPending() From 8cbea4fbb7f2a8ccf59f44922ef7c1ff5f22e377 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Jul 2026 01:26:18 +0000 Subject: [PATCH 134/333] chore: generate --- packages/app/src/context/settings.tsx | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/packages/app/src/context/settings.tsx b/packages/app/src/context/settings.tsx index 23dbb531743f..1a118b654479 100644 --- a/packages/app/src/context/settings.tsx +++ b/packages/app/src/context/settings.tsx @@ -94,11 +94,7 @@ export function hasExistingWebState(settings: Promise | string | null, p return settings !== null || previousVersion !== undefined } -export function initialAgentVisibility( - initialized: boolean | undefined, - existing: boolean, - previousVersion?: string, -) { +export function initialAgentVisibility(initialized: boolean | undefined, existing: boolean, previousVersion?: string) { if (initialized === true) return return existing || previousVersion !== undefined } @@ -282,11 +278,7 @@ export const { use: useSettings, provider: SettingsProvider } = createSimpleCont }) const visible = (preference: () => boolean) => createMemo(() => !newLayoutDesigns() || preference()) const initializeAgentVisibility = (existing: boolean) => { - const initial = initialAgentVisibility( - store.general?.agentVisibilityInitialized, - existing, - launchState.previous, - ) + const initial = initialAgentVisibility(store.general?.agentVisibilityInitialized, existing, launchState.previous) if (initial === undefined) return batch(() => { setStore("general", "showCustomAgents", initial) From e8b09927889ba4b5b7fc74bbab5b864d205406ca Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Wed, 29 Jul 2026 13:16:30 +0800 Subject: [PATCH 135/333] fix(app): defer model variant selector (#39445) --- packages/app/src/components/prompt-input-v2.tsx | 1 + packages/session-ui/src/v2/components/prompt-input/index.tsx | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 44d9d48d386b..634e90f78ff1 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -55,6 +55,7 @@ export function PromptInputV2Composer(props: PromptInputV2ComposerProps) { controller={props.controller} borderUnderlay={props.borderUnderlay} class={props.class} + variantControlVisible={!props.controller.model.loading} attachKeybind={command.keybindParts("file.attach")} attachShortcut={command.keybind("file.attach")} modelControl={ diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index 9b011f03e9ef..c09b5a1b5f9a 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -39,6 +39,7 @@ export type PromptInputV2Props = { borderUnderlay?: boolean class?: string modelControl?: JSX.Element + variantControlVisible?: boolean attachKeybind?: string[] attachShortcut?: string } @@ -232,7 +233,7 @@ export function PromptInputV2(props: PromptInputV2Props) { > {props.modelControl}
    - + {(control) => ( 1}> Date: Wed, 29 Jul 2026 10:34:37 +0200 Subject: [PATCH 136/333] feat(desktop): refine tab states (#39472) --- .../app/src/components/titlebar-tab-nav.css | 74 ++++++++++++++----- .../app/src/components/titlebar-tab-nav.tsx | 12 +-- 2 files changed, 62 insertions(+), 24 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 08bed7cf26b5..95fe212a5d41 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -9,53 +9,91 @@ justify-content: center; } +[data-titlebar-tab] { + --tab-base: var(--v2-background-bg-deep); + --tab-overlay: transparent; + background: + linear-gradient(var(--tab-overlay), var(--tab-overlay)), + var(--tab-base); +} + +[data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( + [data-dragging="true"] + ):not([data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-hover); +} + +[data-titlebar-tab]:is([data-state="pressed"], [data-dragging="true"], [data-editing="true"]) { + --tab-base: var(--v2-background-bg-layer-02); + --tab-overlay: var(--v2-overlay-simple-overlay-pressed); +} + +[data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { + background: + linear-gradient(var(--tab-overlay), var(--tab-overlay)), + var(--tab-base); +} + [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { display: none; } [data-titlebar-tab-list] { - gap: 13.5px; + gap: 6px; } [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-03); position: relative; } +[data-color-scheme="dark"] [data-titlebar-tab-slot] { + --tab-separator: var(--v2-background-bg-layer-02); +} + [data-titlebar-tab-slot]:not(:first-child):not([data-active="true"])::before { content: ""; position: absolute; top: 8px; - left: -6.75px; + left: -3.75px; width: 1.5px; height: 12px; border-radius: 9999px; - background: var(--v2-background-bg-layer-02); + background: var(--tab-separator); } [data-titlebar-tab-slot][data-active="true"] + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab] [data-slot="tab-close"]::before { - content: ""; - position: absolute; - top: 0; - height: 28px; - width: 16px; - pointer-events: none; - background: linear-gradient(to right, transparent, var(--tab-bg)); +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + + [data-titlebar-tab-slot]::before { display: none; } -[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-close"]::before { - display: block; - right: 0; +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]) [data-slot="tab-link"] { + --tab-title-fade-offset: 4px; + -webkit-mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to right, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); } -[data-titlebar-tab][data-title-overflow="true"]:hover:not([data-editing="true"]) [data-slot="tab-close"]::before, -[data-titlebar-tab][data-title-overflow="true"][data-active="true"]:not([data-editing="true"]) - [data-slot="tab-close"]::before { - right: 100%; +[data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) + [data-slot="tab-link"] { + --tab-title-fade-offset: 24px; } [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 35f600c9f608..3823cf10b8d2 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -180,11 +180,11 @@ export function TabNavItem(props: { data-slot="titlebar-tab-item" data-title-overflow={titleOverflowing()} data-editing={editing()} - class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] bg-[var(--tab-bg)] px-1.5 [container-type:inline-size] [--tab-bg:var(--v2-background-bg-deep)] hover:[--tab-bg:var(--v2-background-bg-layer-02)] has-[>a:focus-visible]:[--tab-bg:var(--v2-background-bg-layer-02)] data-[active='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[dragging='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[pressed='true']:[--tab-bg:var(--v2-background-bg-layer-02)] data-[editing='true']:[--tab-bg:var(--v2-background-bg-layer-02)]" + class="group relative flex h-7 w-full min-w-0 select-none flex-row items-center gap-1.5 overflow-hidden whitespace-nowrap rounded-[6px] px-1.5 [container-type:inline-size]" classList={{ invisible: props.hidden }} data-active={props.active} data-dragging={props.dragging} - data-pressed={props.pressed} + data-state={props.active || props.pressed ? "pressed" : undefined} onMouseDown={(event) => { if (event.button !== MIDDLE_MOUSE_BUTTON) return event.preventDefault() @@ -276,7 +276,7 @@ export function TabNavItem(props: { /> -
    +
    { if (event.button !== MIDDLE_MOUSE_BUTTON) return @@ -381,7 +381,7 @@ export function DraftTabItem(props: { {props.title} -
    +
    Date: Wed, 29 Jul 2026 08:36:03 +0000 Subject: [PATCH 137/333] chore: generate --- packages/app/src/components/titlebar-tab-nav.css | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 95fe212a5d41..13dfbe04fc75 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -12,9 +12,7 @@ [data-titlebar-tab] { --tab-base: var(--v2-background-bg-deep); --tab-overlay: transparent; - background: - linear-gradient(var(--tab-overlay), var(--tab-overlay)), - var(--tab-base); + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); } [data-titlebar-tab]:is(:hover, :has(> [data-slot="tab-link"]:focus-visible)):not([data-state="pressed"]):not( @@ -30,9 +28,7 @@ } [data-titlebar-tab]:is(:hover, [data-state="pressed"]) [data-slot="tab-close"] { - background: - linear-gradient(var(--tab-overlay), var(--tab-overlay)), - var(--tab-base); + background: linear-gradient(var(--tab-overlay), var(--tab-overlay)), var(--tab-base); } [data-titlebar-tab][data-editing="true"] [data-slot="tab-close"] { @@ -68,8 +64,7 @@ } [data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover)::before, -[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) - + [data-titlebar-tab-slot]::before { +[data-titlebar-tab-slot]:not([data-active="true"]):has([data-titlebar-tab]:hover) + [data-titlebar-tab-slot]::before { display: none; } From 02f50a6e803eb039af28f12efa45d0212efa2e38 Mon Sep 17 00:00:00 2001 From: Vladimir Glafirov Date: Wed, 29 Jul 2026 17:45:40 +0000 Subject: [PATCH 138/333] chore: bump gitlab-ai-provider to 6.12.1 (#39490) --- bun.lock | 6 +++--- packages/core/package.json | 2 +- packages/opencode/package.json | 2 +- 3 files changed, 5 insertions(+), 5 deletions(-) diff --git a/bun.lock b/bun.lock index 19f5a97cb27d..aaf43212b70e 100644 --- a/bun.lock +++ b/bun.lock @@ -339,7 +339,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -632,7 +632,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", @@ -3855,7 +3855,7 @@ "github-slugger": ["github-slugger@2.0.0", "", {}, "sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw=="], - "gitlab-ai-provider": ["gitlab-ai-provider@6.12.0", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-uGan7MQaNfmV5SH9wiMJkZ7D5QxM4KVPRcjIHtPCM5icZjgXl5mrHMjr3/e7TAHa9OjEJKT0a2FxzPgwRtN3PQ=="], + "gitlab-ai-provider": ["gitlab-ai-provider@6.12.1", "", { "dependencies": { "@anthropic-ai/sdk": "^0.71.0", "@anycable/core": "^0.9.2", "graphql-request": "^6.1.0", "isomorphic-ws": "^5.0.0", "openai": "^6.16.0", "socket.io-client": "^4.8.1", "vscode-jsonrpc": "^8.2.1", "zod": "^3.25.76" }, "peerDependencies": { "@ai-sdk/provider": ">=3.0.0", "@ai-sdk/provider-utils": ">=4.0.0" } }, "sha512-Qn5iHqvjG8yktI5MWaUgdRR94l7O4WtYW0CAbhsCh1Tj0Fei/DeprOYPVyf4Nht1Ix6U2PXSYM32QOHI6Z2TDw=="], "glob": ["glob@13.0.5", "", { "dependencies": { "minimatch": "^10.2.1", "minipass": "^7.1.2", "path-scurry": "^2.0.0" } }, "sha512-BzXxZg24Ibra1pbQ/zE7Kys4Ua1ks7Bn6pKLkVPZ9FZe4JQS6/Q7ef3LG1H+k7lUf5l4T3PLSyYyYJVYUvfgTw=="], diff --git a/packages/core/package.json b/packages/core/package.json index 2325bcc01eb5..cc1f5d6c6157 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -108,7 +108,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index b7ee6973d8d2..105f307319cc 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -120,7 +120,7 @@ "drizzle-orm": "catalog:", "effect": "catalog:", "fuzzysort": "3.1.0", - "gitlab-ai-provider": "6.12.0", + "gitlab-ai-provider": "6.12.1", "glob": "13.0.5", "google-auth-library": "10.5.0", "gray-matter": "4.0.3", From a6f7fe739c691e8b086c50390cf3205f0b5d431e Mon Sep 17 00:00:00 2001 From: Lakshya Agarwal Date: Wed, 29 Jul 2026 13:52:12 -0400 Subject: [PATCH 139/333] docs(ecosystem): add opencode-tavily plugin (#38709) --- packages/web/src/content/docs/ecosystem.mdx | 1 + 1 file changed, 1 insertion(+) diff --git a/packages/web/src/content/docs/ecosystem.mdx b/packages/web/src/content/docs/ecosystem.mdx index 2fc3b654869b..ce4f3100afb5 100644 --- a/packages/web/src/content/docs/ecosystem.mdx +++ b/packages/web/src/content/docs/ecosystem.mdx @@ -54,6 +54,7 @@ You can also check out [awesome-opencode](https://github.com/awesome-opencode/aw | [opencode-firecrawl](https://github.com/firecrawl/opencode-firecrawl) | Web scraping, crawling, and search via the Firecrawl CLI | | [opencode-jfrog-plugin](https://github.com/jfrog/opencode-jfrog-plugin) | JFrog Plugin for seamless integration of Opencode users to JFrog platform | | [opencode-goal-plugin](https://github.com/willytop8/OpenCode-goal-plugin) | Session-scoped `/goal` workflow that keeps objectives in context and auto-continues until complete | +| [opencode-tavily](https://github.com/tavily-ai/opencode-tavily) | Web search, extraction, crawling, and deep research via the Tavily CLI | --- From 1e17856ba4b5b052650c8115060852f3f023844e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 29 Jul 2026 18:06:26 +0000 Subject: [PATCH 140/333] 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 62788beafdf3..59ba15e5b23d 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-/ZtluLolVq1ZFAYgbDScze9GZR0NF9baRQXvfEoOnkI=", - "aarch64-linux": "sha256-kdh7Dpira7cx9lvtozFsjLrDqMBrUMcgxkhfsqrutws=", - "aarch64-darwin": "sha256-chPeMCX+8hvOQ32hTANIn7FmONsSv9t3dKTWTaHerjs=", - "x86_64-darwin": "sha256-rMc0dDZB+YbXrjYwXTIt7QvHUxSp//wDJQ5hUdR+LYM=" + "x86_64-linux": "sha256-Z/YgK5sf0L9MG7hMIb+wj3ias6iNo8L4oFHYNtRHreM=", + "aarch64-linux": "sha256-MoYZmeh6RcDUN7a132wexFNwkHz+Q9Ijs+qiacuKJ90=", + "aarch64-darwin": "sha256-OHo3x7UcJzrPMAF9Ij/9Rhamd+dfpb5QuxP89cy7Rw4=", + "x86_64-darwin": "sha256-ElzZVqkQPYTAd0nvLCB5uel9xtOqMK07QFW8MDr174E=" } } From 341c64cc97859719e401dd5edfacb6cca4a36970 Mon Sep 17 00:00:00 2001 From: Deven Navani <4031146+devennavani@users.noreply.github.com> Date: Wed, 29 Jul 2026 21:06:19 -0700 Subject: [PATCH 141/333] feat: discover Modal models (#39066) --- packages/opencode/src/plugin/index.ts | 2 + packages/opencode/src/plugin/modal/modal.ts | 17 ++ packages/opencode/src/plugin/modal/models.ts | 133 ++++++++++++ .../opencode/test/plugin/modal-models.test.ts | 198 ++++++++++++++++++ 4 files changed, 350 insertions(+) create mode 100644 packages/opencode/src/plugin/modal/modal.ts create mode 100644 packages/opencode/src/plugin/modal/models.ts create mode 100644 packages/opencode/test/plugin/modal-models.test.ts diff --git a/packages/opencode/src/plugin/index.ts b/packages/opencode/src/plugin/index.ts index 1558c707c3f7..d68e8b34f94f 100644 --- a/packages/opencode/src/plugin/index.ts +++ b/packages/opencode/src/plugin/index.ts @@ -13,6 +13,7 @@ import { CodexAuthPlugin } from "./openai/codex" import { Session } from "@/session/session" import { NamedError } from "@opencode-ai/core/util/error" import { CopilotAuthPlugin } from "./github-copilot/copilot" +import { ModalPlugin } from "./modal/modal" import { gitlabAuthPlugin as GitlabAuthPlugin } from "opencode-gitlab-auth" import { PoeAuthPlugin } from "opencode-poe-auth" import { CloudflareAIGatewayAuthPlugin, CloudflareWorkersAuthPlugin } from "./cloudflare" @@ -70,6 +71,7 @@ function internalPlugins(flags: RuntimeFlags.Info): PluginInstance[] { experimentalWebSockets: experimentalWebSocketsEnabled({ enabled: flags.experimentalWebSockets }), }), CopilotAuthPlugin, + ModalPlugin, GitlabAuthPlugin, PoeAuthPlugin, CloudflareWorkersAuthPlugin, diff --git a/packages/opencode/src/plugin/modal/modal.ts b/packages/opencode/src/plugin/modal/modal.ts new file mode 100644 index 000000000000..942166e29091 --- /dev/null +++ b/packages/opencode/src/plugin/modal/modal.ts @@ -0,0 +1,17 @@ +import type { Hooks } from "@opencode-ai/plugin" +import { ModalModels } from "./models" + +export async function ModalPlugin(): Promise { + return { + provider: { + id: "modal", + async models(provider, ctx) { + const apiKey = ctx.auth?.type === "api" ? ctx.auth.key : process.env.MODAL_PROXY_TOKEN + const baseURL = Object.values(provider.models)[0]?.api.url + if (!apiKey || !baseURL) return {} + + return ModalModels.get(baseURL, apiKey, provider.models).catch(() => ({})) + }, + }, + } +} diff --git a/packages/opencode/src/plugin/modal/models.ts b/packages/opencode/src/plugin/modal/models.ts new file mode 100644 index 000000000000..2f9d2b7e4974 --- /dev/null +++ b/packages/opencode/src/plugin/modal/models.ts @@ -0,0 +1,133 @@ +import type { Model } from "@opencode-ai/sdk/v2" +import { Schema } from "effect" + +const reasoningOption = Schema.Struct({ + type: Schema.Literal("effort"), + values: Schema.Array(Schema.NullOr(Schema.String)), +}) + +const response = Schema.Struct({ + data: Schema.Array( + Schema.Struct({ + id: Schema.String, + base_model_id: Schema.optional(Schema.String), + hugging_face_id: Schema.optional(Schema.String), + name: Schema.optional(Schema.String), + input_modalities: Schema.optional(Schema.Array(Schema.String)), + output_modalities: Schema.optional(Schema.Array(Schema.String)), + context_length: Schema.optional(Schema.Number), + max_output_length: Schema.optional(Schema.Number), + pricing: Schema.optional( + Schema.Struct({ + prompt: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + completion: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + input_cache_read: Schema.optional(Schema.Union([Schema.String, Schema.Number])), + }), + ), + supported_sampling_parameters: Schema.optional(Schema.Array(Schema.String)), + supported_features: Schema.optional(Schema.Array(Schema.String)), + reasoning_options: Schema.optional(Schema.Array(reasoningOption)), + interleaved: Schema.optional( + Schema.Union([ + Schema.Boolean, + Schema.Struct({ + field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + }), + ]), + ), + }), + ), +}) + +const decode = Schema.decodeUnknownSync(response) + +function price(value: string | number | undefined, fallback: number) { + if (value === undefined) return fallback + const parsed = Number(value) + return Number.isFinite(parsed) ? parsed * 1_000_000 : fallback +} + +export async function get(baseURL: string, apiKey: string, existing: Record) { + const data = await fetch(`${baseURL.replace(/\/+$/, "")}/models`, { + headers: { + Authorization: `Bearer ${apiKey}`, + }, + signal: AbortSignal.timeout(3_000), + }).then(async (res) => { + if (!res.ok) throw new Error(`Failed to fetch Modal models: ${res.status}`) + return decode(await res.json()) + }) + + return Object.fromEntries( + data.data.map((item) => { + const template = existing[item.base_model_id ?? item.hugging_face_id ?? item.id] + const model: Model = { + id: item.id, + providerID: "modal", + name: item.name ?? template?.name ?? item.id, + family: template?.family, + api: { + id: item.id, + url: baseURL, + npm: template?.api.npm ?? "@ai-sdk/openai-compatible", + }, + status: template?.status ?? "active", + headers: { ...template?.headers }, + options: { ...template?.options }, + cost: { + input: price(item.pricing?.prompt, template?.cost.input ?? 0), + output: price(item.pricing?.completion, template?.cost.output ?? 0), + cache: { + read: price(item.pricing?.input_cache_read, template?.cost.cache.read ?? 0), + write: template?.cost.cache.write ?? 0, + }, + }, + limit: { + context: item.context_length ?? template?.limit.context ?? 0, + input: template?.limit.input, + output: item.max_output_length ?? template?.limit.output ?? 0, + }, + capabilities: { + temperature: + item.supported_sampling_parameters?.includes("temperature") ?? template?.capabilities.temperature ?? false, + reasoning: item.supported_features?.includes("reasoning") ?? template?.capabilities.reasoning ?? false, + attachment: + item.input_modalities?.some((modality) => modality !== "text") ?? + template?.capabilities.attachment ?? + false, + toolcall: item.supported_features?.includes("tools") ?? template?.capabilities.toolcall ?? true, + input: { + text: item.input_modalities?.includes("text") ?? template?.capabilities.input.text ?? true, + audio: item.input_modalities?.includes("audio") ?? template?.capabilities.input.audio ?? false, + image: item.input_modalities?.includes("image") ?? template?.capabilities.input.image ?? false, + video: item.input_modalities?.includes("video") ?? template?.capabilities.input.video ?? false, + pdf: item.input_modalities?.includes("pdf") ?? template?.capabilities.input.pdf ?? false, + }, + output: { + text: item.output_modalities?.includes("text") ?? template?.capabilities.output.text ?? true, + audio: item.output_modalities?.includes("audio") ?? template?.capabilities.output.audio ?? false, + image: item.output_modalities?.includes("image") ?? template?.capabilities.output.image ?? false, + video: item.output_modalities?.includes("video") ?? template?.capabilities.output.video ?? false, + pdf: item.output_modalities?.includes("pdf") ?? template?.capabilities.output.pdf ?? false, + }, + interleaved: item.interleaved ?? template?.capabilities.interleaved ?? false, + }, + release_date: template?.release_date ?? "", + } + model.variants = + item.reasoning_options === undefined + ? template?.variants + : Object.fromEntries( + item.reasoning_options.flatMap((option) => + option.values.map((value) => { + const effort = value ?? "none" + return [effort, { reasoningEffort: effort }] + }), + ), + ) + return [item.id, model] + }), + ) +} + +export * as ModalModels from "./models" diff --git a/packages/opencode/test/plugin/modal-models.test.ts b/packages/opencode/test/plugin/modal-models.test.ts new file mode 100644 index 000000000000..1b37b31747c6 --- /dev/null +++ b/packages/opencode/test/plugin/modal-models.test.ts @@ -0,0 +1,198 @@ +import { expect, test } from "bun:test" +import type { Model, Provider } from "@opencode-ai/sdk/v2" +import { ModalPlugin } from "@/plugin/modal/modal" + +const BASE_MODEL_ID = "thinkingmachines/Inkling-NVFP4" +const RUNTIME_MODEL_ID = "workspace--inkling.us-west.modal.direct" +const FALLBACK_RUNTIME_MODEL_ID = "workspace--inkling-fallback.us-west.modal.direct" + +function makeProvider(baseURL: string): Provider { + const template: Model = { + id: BASE_MODEL_ID, + providerID: "modal", + name: "Inkling", + family: "ling", + api: { + id: BASE_MODEL_ID, + url: baseURL, + npm: "@ai-sdk/openai-compatible", + }, + status: "active", + headers: {}, + options: {}, + cost: { + input: 1, + output: 4, + cache: { + read: 0.2, + write: 0, + }, + }, + limit: { + context: 128_000, + output: 8_192, + }, + capabilities: { + temperature: true, + reasoning: true, + attachment: true, + toolcall: true, + input: { + text: true, + audio: true, + image: true, + video: false, + pdf: false, + }, + output: { + text: true, + audio: false, + image: false, + video: false, + pdf: false, + }, + interleaved: { + field: "reasoning_content", + }, + }, + release_date: "2026-07-15", + variants: { + fallback: { + reasoningEffort: "fallback", + }, + }, + } + + return { + id: "modal", + name: "Modal", + source: "api", + env: ["MODAL_PROXY_TOKEN"], + options: {}, + models: { + [template.id]: template, + }, + } +} + +test("discovers Modal workspace models", async () => { + const requests: Array<{ authorization: string | null; path: string }> = [] + using server = Bun.serve({ + port: 0, + fetch(request) { + requests.push({ + authorization: request.headers.get("authorization"), + path: new URL(request.url).pathname, + }) + return Response.json({ + data: [ + { + id: RUNTIME_MODEL_ID, + base_model_id: BASE_MODEL_ID, + name: "Thinking Machines: Inkling", + input_modalities: ["text", "image", "audio"], + output_modalities: ["text"], + context_length: 1_048_576, + max_output_length: 262_144, + pricing: { + prompt: "0.0000012", + completion: "0.000005", + input_cache_read: "0.00000027", + }, + supported_sampling_parameters: ["temperature"], + supported_features: ["tools", "reasoning"], + reasoning_options: [ + { + type: "effort", + values: ["none", "low", "medium", "high", "xhigh", "max"], + }, + ], + interleaved: { + field: "reasoning_content", + }, + }, + { + id: FALLBACK_RUNTIME_MODEL_ID, + base_model_id: BASE_MODEL_ID, + }, + ], + }) + }, + }) + const provider = makeProvider(`${server.url}v1`) + + const plugin = await ModalPlugin() + const models = await plugin.provider!.models!(provider, { + auth: { + type: "api", + key: "test-token", + }, + }) + const model = models[RUNTIME_MODEL_ID] + + expect(requests).toEqual([ + { + authorization: "Bearer test-token", + path: "/v1/models", + }, + ]) + expect(Object.keys(models)).toEqual([RUNTIME_MODEL_ID, FALLBACK_RUNTIME_MODEL_ID]) + expect(model.api).toEqual({ + id: RUNTIME_MODEL_ID, + url: `${server.url}v1`, + npm: "@ai-sdk/openai-compatible", + }) + expect(model.family).toBe("ling") + expect(model.capabilities.interleaved).toEqual({ field: "reasoning_content" }) + expect(model.capabilities.input).toEqual({ + text: true, + audio: true, + image: true, + video: false, + pdf: false, + }) + expect(model.cost).toEqual({ + input: 1.2, + output: 5, + cache: { + read: 0.27, + write: 0, + }, + }) + expect(model.limit).toEqual({ + context: 1_048_576, + output: 262_144, + }) + expect(model.variants).toEqual({ + none: { reasoningEffort: "none" }, + low: { reasoningEffort: "low" }, + medium: { reasoningEffort: "medium" }, + high: { reasoningEffort: "high" }, + xhigh: { reasoningEffort: "xhigh" }, + max: { reasoningEffort: "max" }, + }) + expect(models[FALLBACK_RUNTIME_MODEL_ID].variants).toEqual({ + fallback: { + reasoningEffort: "fallback", + }, + }) +}) + +test("hides Modal models when discovery fails", async () => { + using server = Bun.serve({ + port: 0, + fetch() { + return new Response(null, { status: 503 }) + }, + }) + + const plugin = await ModalPlugin() + const models = await plugin.provider!.models!(makeProvider(`${server.url}v1`), { + auth: { + type: "api", + key: "test-token", + }, + }) + + expect(models).toEqual({}) +}) From f7204902192624481e526b1852cdedaadb914e24 Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 30 Jul 2026 00:40:47 -0400 Subject: [PATCH 142/333] fix(console): add public fetch compatibility flag --- infra/console.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/infra/console.ts b/infra/console.ts index 5fbf35de007d..79556f5e0c7c 100644 --- a/infra/console.ts +++ b/infra/console.ts @@ -288,8 +288,12 @@ new sst.cloudflare.x.SolidStart("Console", { server: { placement: { region: "aws:us-east-2" }, transform: { - worker: { - tailConsumers: [{ service: logProcessor.nodes.worker.scriptName }], + worker: (args) => { + args.compatibilityFlags = $resolve(args.compatibilityFlags).apply((flags) => [ + ...(flags ?? []), + "global_fetch_strictly_public", + ]) + args.tailConsumers = [{ service: logProcessor.nodes.worker.scriptName }] }, }, }, From 73009f0b09d866f2efe1ccc9fad91a7430345f7d Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:26:16 +0530 Subject: [PATCH 143/333] feat(app): update arrow buttons in review tab top bar (#39469) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- .../src/v2/components/session-review-v2.tsx | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index abbe90d11266..bfd6778c541e 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -165,11 +165,13 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const prev = () => { - return props.files[fileIndex() - 1] + if (!canCycle()) return + return props.files[(fileIndex() - 1 + props.files.length) % props.files.length] } const next = () => { - return props.files[fileIndex() + 1] + if (!canCycle()) return + return props.files[(fileIndex() + 1) % props.files.length] } const canCycle = () => props.files.length > 0 @@ -183,15 +185,15 @@ export function SessionReviewV2(props: SessionReviewV2Props) { props.onSelectFile(file) } - // The prev/next tooltips advertise < and >; keep the keys working while the + // Keep the advertised arrow keys working while the // pane is mounted, but never while typing in an input or comment editor. makeEventListener(document, "keydown", (event) => { if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return - if (event.key !== "<" && event.key !== ">") return + if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return const target = event.target if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return if (!props.hasDiffs || !canCycle()) return - const file = event.key === "<" ? prev() : next() + const file = event.key === "ArrowLeft" ? prev() : next() if (!file) return event.preventDefault() cycle(file) @@ -219,7 +221,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { value={ <> {i18n.t("ui.sessionReviewV2.previousFile")} - + } > @@ -239,7 +241,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { value={ <> {i18n.t("ui.sessionReviewV2.nextFile")} - "]} variant="neutral" /> + } > From a9eda2e99e8ade65f735aadb184685d704dc18ea Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:27:19 +0530 Subject: [PATCH 144/333] feat(app): migrate to solid-sonner (#39519) Co-authored-by: Brendan Allan --- bun.lock | 4 + package.json | 1 + packages/app/package.json | 4 +- packages/app/src/pages/layout.tsx | 9 +- packages/app/src/utils/toast.tsx | 17 +- packages/app/test-browser/toast-owner.test.ts | 40 ++- packages/ui/package.json | 1 + packages/ui/src/v2/components/toast-v2.css | 254 ++++++++-------- .../ui/src/v2/components/toast-v2.stories.tsx | 8 +- packages/ui/src/v2/components/toast-v2.tsx | 277 +++++++++++++----- 10 files changed, 406 insertions(+), 209 deletions(-) diff --git a/bun.lock b/bun.lock index aaf43212b70e..9f6ef7b62845 100644 --- a/bun.lock +++ b/bun.lock @@ -1002,6 +1002,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2", }, "devDependencies": { @@ -1146,6 +1147,7 @@ "shiki": "4.2.0", "solid-js": "1.9.10", "solid-list": "0.3.0", + "solid-sonner": "0.3.1", "sst": "4.13.1", "tailwindcss": "4.1.11", "typescript": "5.8.2", @@ -5127,6 +5129,8 @@ "solid-refresh": ["solid-refresh@0.6.3", "", { "dependencies": { "@babel/generator": "^7.23.6", "@babel/helper-module-imports": "^7.22.15", "@babel/types": "^7.23.6" }, "peerDependencies": { "solid-js": "^1.3" } }, "sha512-F3aPsX6hVw9ttm5LYlth8Q15x6MlI/J3Dn+o3EQyRTtTxidepSTwAYdozt01/YA+7ObcciagGEyXIopGZzQtbA=="], + "solid-sonner": ["solid-sonner@0.3.1", "", { "peerDependencies": { "solid-js": "^1.6.0" } }, "sha512-F/+zi9yKJTHh5hX1UGJfkDvyC+F34Vi3jgy44NJwOKCgic1QtAon0b1iT9OsDO77RTgR+PCil+3Y5B8T2Owy1Q=="], + "solid-stripe": ["solid-stripe@0.8.1", "", { "peerDependencies": { "@stripe/stripe-js": ">=1.44.1 <8.0.0", "solid-js": "^1.6.0" } }, "sha512-l2SkWoe51rsvk9u1ILBRWyCHODZebChSGMR6zHYJTivTRC0XWrRnNNKs5x1PYXsaIU71KYI6ov5CZB5cOtGLWw=="], "solid-transition-size": ["solid-transition-size@0.1.4", "", { "dependencies": { "@corvu/utils": "~0.3.2" }, "peerDependencies": { "solid-js": "^1.8" } }, "sha512-ocHVnbfy23CgfaH4cEUR/AFg0Y3CEL8Oh3n9Qv8OHFJgPh+zkmERKZQfi/xH5XvxDCizg8VjPrVUhiHB1Gza8g=="], diff --git a/package.json b/package.json index e479eb74062c..2a8eef635687 100644 --- a/package.json +++ b/package.json @@ -90,6 +90,7 @@ "@sentry/solid": "10.36.0", "@sentry/vite-plugin": "4.6.0", "solid-js": "1.9.10", + "solid-sonner": "0.3.1", "vite-plugin-solid": "2.11.10", "@lydell/node-pty": "1.2.0-beta.12" } diff --git a/packages/app/package.json b/packages/app/package.json index 7199ad942546..08eb9a059f74 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -19,9 +19,9 @@ "build": "vite build", "serve": "vite preview", "test": "bun run test:unit && bun run test:browser", - "test:unit": "bun test --only-failures --preload ./happydom.ts ./src", + "test:unit": "bun test --conditions=solid --only-failures --preload ./happydom.ts ./src", "test:browser": "bun test --conditions=browser --preload ./happydom.ts ./test-browser", - "test:unit:watch": "bun test --watch --preload ./happydom.ts ./src", + "test:unit:watch": "bun test --conditions=solid --watch --preload ./happydom.ts ./src", "test:e2e": "playwright test", "test:e2e:local": "playwright test", "test:e2e:ui": "playwright test --ui", diff --git a/packages/app/src/pages/layout.tsx b/packages/app/src/pages/layout.tsx index b348b44d8a87..694b610903b9 100644 --- a/packages/app/src/pages/layout.tsx +++ b/packages/app/src/pages/layout.tsx @@ -33,8 +33,7 @@ import { createStore, produce, reconcile } from "solid-js/store" import { DragDropProvider, DragDropSensors, DragOverlay, SortableProvider, closestCenter } from "@thisbeyond/solid-dnd" import type { DragEvent } from "@thisbeyond/solid-dnd" import { useProviders } from "@/hooks/use-providers" -import { toaster } from "@opencode-ai/ui/toast" -import { setV2Toast, showToast, ToastRegion } from "@/utils/toast" +import { dismissToast, setV2Toast, showToast, ToastRegion } from "@/utils/toast" import { useServerSDK } from "@/context/server-sdk" import { normalizeProjectInfo } from "@/context/global-sync/utils" import { clearWorkspaceTerminals } from "@/context/terminal" @@ -384,7 +383,7 @@ export default function LegacyLayout(props: ParentProps) { const dismissSessionAlert = (sessionKey: string) => { const toastId = toastBySession.get(sessionKey) if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(toastId) toastBySession.delete(sessionKey) alertedAtBySession.delete(sessionKey) } @@ -1459,7 +1458,7 @@ export default function LegacyLayout(props: ParentProps) { title: language.t("workspace.resetting.title"), description: language.t("workspace.resetting.description"), }) - const dismiss = () => toaster.dismiss(progress) + const dismiss = () => dismissToast(progress) const sessions = await listAllSessions(serverSDK().api.session, { directory, order: "desc" }).catch(() => []) @@ -2450,7 +2449,7 @@ function UpdateAvailableToast(props: { onCleanup(() => { if (toastId === undefined) return - toaster.dismiss(toastId) + dismissToast(toastId) }) return null diff --git a/packages/app/src/utils/toast.tsx b/packages/app/src/utils/toast.tsx index e44454850823..6f23b63d1e83 100644 --- a/packages/app/src/utils/toast.tsx +++ b/packages/app/src/utils/toast.tsx @@ -1,6 +1,12 @@ import { Icon, type IconProps } from "@opencode-ai/ui/icon" -import { Toast, showToast as showLegacyToast, type ToastOptions, type ToastVariant } from "@opencode-ai/ui/toast" -import { ToastV2, showToastV2 } from "@opencode-ai/ui/v2/toast-v2" +import { + Toast, + showToast as showLegacyToast, + toaster as legacyToaster, + type ToastOptions, + type ToastVariant, +} from "@opencode-ai/ui/toast" +import { ToastV2, showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" let v2 = false @@ -27,6 +33,13 @@ export function showToast(options: ToastOptions | string) { }) } +// v1 and v2 ids come from separate registries, so dismissal has to use the same +// implementation that issued the id. +export function dismissToast(toastId: number) { + if (!v2) return legacyToaster.dismiss(toastId) + return toasterV2.dismiss(toastId) +} + function resolveIcon(icon: IconProps["name"] | undefined, variant: ToastVariant | undefined) { const name = icon ?? (variant === "success" ? "check" : undefined) if (!name) return diff --git a/packages/app/test-browser/toast-owner.test.ts b/packages/app/test-browser/toast-owner.test.ts index 104a50585165..25ba5c000e11 100644 --- a/packages/app/test-browser/toast-owner.test.ts +++ b/packages/app/test-browser/toast-owner.test.ts @@ -1,8 +1,46 @@ -import { describe, expect, test } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" import { createSignal, type JSX } from "solid-js" import { showToastV2, toasterV2 } from "@opencode-ai/ui/v2/toast-v2" describe("showToastV2", () => { + // The toast registry is module state, so each test starts from an empty stack. + beforeEach(() => { + toasterV2.dismiss() + }) + + test("coalesces exact active content", () => { + const first = showToastV2({ title: "Repeated error", description: "Try again" }) + const second = showToastV2({ title: "Repeated error", description: "Try again" }) + const different = showToastV2({ title: "Repeated error", description: "A different error" }) + + expect(second).toBe(first) + expect(different).not.toBe(first) + + toasterV2.dismiss(first) + toasterV2.dismiss(different) + }) + + test("allows dismissed content to appear again", () => { + const first = showToastV2("Dismiss and retry") + toasterV2.dismiss(first) + + const second = showToastV2("Dismiss and retry") + expect(second).not.toBe(first) + + toasterV2.dismiss(second) + }) + + test("recreates matching content when it is not the topmost toast", () => { + const first = showToastV2("First toast") + const topmost = showToastV2("Topmost toast") + const repeated = showToastV2("First toast") + + expect(repeated).not.toBe(first) + + toasterV2.dismiss(topmost) + toasterV2.dismiss(repeated) + }) + test("creates no reactive computations at call time", () => { const [tick, setTick] = createSignal(0) let reads = 0 diff --git a/packages/ui/package.json b/packages/ui/package.json index 05c4ffbf63bc..f994110b9f62 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -104,6 +104,7 @@ "remend": "catalog:", "shiki": "catalog:", "solid-list": "catalog:", + "solid-sonner": "catalog:", "strip-ansi": "7.1.2" }, "peerDependencies": { diff --git a/packages/ui/src/v2/components/toast-v2.css b/packages/ui/src/v2/components/toast-v2.css index 5bce87dbdc7c..db173489a9f5 100644 --- a/packages/ui/src/v2/components/toast-v2.css +++ b/packages/ui/src/v2/components/toast-v2.css @@ -1,114 +1,133 @@ -[data-component="toast-v2-region"] { - position: fixed; - bottom: 48px; - right: 32px; +.toast-v2-region { z-index: 1000; - display: flex; - flex-direction: column; - gap: 12px; max-width: min(320px, calc(100vw - 64px)); - max-height: none; - width: 100%; - overflow: visible; pointer-events: none; - - [data-slot="toast-v2-list"] { - display: flex; - flex-direction: column; - gap: 12px; - list-style: none; - margin: 0; - padding: 0; - max-height: none; - overflow-y: visible; - overflow-x: visible; - scrollbar-width: none; - - &::-webkit-scrollbar { - display: none; - } - } + user-select: none; + -webkit-user-select: none; } -[data-component="toast-v2"] { - position: relative; - display: flex; - flex-direction: column; - gap: 12px; +.toast-v2 { + display: grid; + grid-template-columns: minmax(0, 1fr) 20px; + column-gap: 12px; + row-gap: 12px; width: 320px; + max-width: 100%; padding: 12px; max-height: min(420px, calc(100dvh - 96px)); overflow: hidden; pointer-events: auto; - transition: transform 140ms ease-out; - + box-sizing: border-box; + outline: none; + user-select: none; + -webkit-user-select: none; border-radius: 8px; color: var(--v2-text-text-base); background: var(--v2-background-bg-layer-01); box-shadow: var(--v2-elevation-floating); + transition: + transform 280ms cubic-bezier(0.2, 0, 0, 1), + opacity 160ms ease-out, + height 280ms cubic-bezier(0.2, 0, 0, 1), + box-shadow 160ms ease-out; + + &:has(> [data-icon]) { + grid-template-columns: 16px minmax(0, 1fr) 20px; + } - &[data-opened] { - animation: toastV2PopIn 140ms ease-out; + &:focus-visible { + box-shadow: + var(--v2-elevation-floating), + 0 0 0 2px var(--v2-border-border-focus); } - &[data-closed] { - animation: toastV2PopOut 100ms ease-in forwards; + &[data-expanded="true"] { + overflow: visible; } - [data-slot="toast-v2-header"] { - display: flex; - align-items: flex-start; - gap: 12px; - width: 100%; + &[data-expanded="false"][data-front="false"] > * { + opacity: 0; } - [data-slot="toast-v2-icon"] { - flex-shrink: 0; + &[data-expanded="false"][data-front="false"] { + --y: translateY(calc(-1 * var(--toasts-before) * 9.5px)) scale(calc(1 - var(--toasts-before) * 0.05)); + } + + > [data-icon] { + grid-column: 1; + grid-row: 1; + align-self: start; display: flex; align-items: center; justify-content: center; width: 16px; height: 20px; - min-width: 16px; - min-height: 20px; color: var(--v2-icon-icon-base); - - [data-component="icon"] { - color: var(--v2-icon-icon-base); - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - } - - > * { - width: 16px; - height: 16px; - display: inline-flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - } - - svg { - width: 16px; - height: 16px; - display: block; - flex-shrink: 0; - } } - [data-slot="toast-v2-content"] { - flex: 1; + > [data-content] { + grid-column: 1; + grid-row: 1; display: flex; flex-direction: column; gap: 4px; - min-height: 0; min-width: 0; overflow: hidden; } + &:has(> [data-icon]) > [data-content] { + grid-column: 2; + } + + > [data-close-button] { + grid-column: 2; + grid-row: 1; + position: static; + align-self: start; + width: 20px; + height: 20px; + min-width: 20px; + min-height: 20px; + padding: 0; + border: 0; + border-radius: 4px; + transform: none; + background: transparent; + color: var(--v2-icon-icon-muted); + cursor: pointer; + display: inline-flex; + align-items: center; + justify-content: center; + } + + &:has(> [data-icon]) > [data-close-button] { + grid-column: 3; + } + + > [data-close-button]:hover { + background: var(--v2-overlay-simple-overlay-hover); + color: var(--v2-icon-icon-base); + } + + > [data-close-button]:active { + background: var(--v2-overlay-simple-overlay-pressed); + } + + > [data-close-button]:focus-visible { + outline: 2px solid var(--v2-border-border-focus); + outline-offset: 2px; + } + + > [data-icon] > *, + > [data-icon] svg, + > [data-close-button] svg { + width: 16px; + height: 16px; + display: block; + flex-shrink: 0; + } + + [data-title], [data-slot="toast-v2-title"] { color: var(--v2-text-text-base); overflow: hidden; @@ -123,6 +142,7 @@ margin: 0; } + [data-description], [data-slot="toast-v2-description"] { color: var(--v2-text-text-muted); text-wrap-style: pretty; @@ -138,10 +158,15 @@ } [data-slot="toast-v2-actions"] { + grid-column: 1; + grid-row: 2; display: flex; flex-wrap: wrap; gap: 8px; - padding-left: 28px; + } + + &:has(> [data-icon]) [data-slot="toast-v2-actions"] { + grid-column: 2; } [data-slot="toast-v2-actions"] [data-component="button-v2"] { @@ -154,62 +179,47 @@ font-synthesis: none; } - [data-slot="toast-v2-close-button"] { + [data-slot="toast-v2-header"] { + display: flex; + align-items: flex-start; + gap: 12px; + width: 100%; + } + + [data-slot="toast-v2-icon"] { flex-shrink: 0; - width: 20px; - height: 20px; - min-width: 20px; - min-height: 20px; - padding: 0; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--v2-icon-icon-muted); - cursor: pointer; - display: inline-flex; + display: flex; align-items: center; justify-content: center; + width: 16px; + height: 20px; + color: var(--v2-icon-icon-base); + } - &:hover { - background: var(--v2-overlay-simple-overlay-hover); - color: var(--v2-icon-icon-base); - } - - &:active { - background: var(--v2-overlay-simple-overlay-pressed); - } - - &:focus-visible { - outline: 2px solid var(--v2-border-border-focus); - outline-offset: 2px; - } - - svg { - width: 16px; - height: 16px; - display: block; - } + [data-slot="toast-v2-content"] { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + overflow: hidden; } } -@keyframes toastV2PopIn { - from { - opacity: 0.8; - transform: translateY(6px); - } - to { - transform: translateY(0); - opacity: 1; +@media (prefers-reduced-motion: reduce) { + .toast-v2, + .toast-v2 > * { + animation: none !important; + transition: none !important; } } -@keyframes toastV2PopOut { - from { - transform: translateY(0); - opacity: 1; +@media (max-width: 600px) { + .toast-v2-region { + max-width: calc(100vw - 32px); } - to { - transform: translateY(6px); - opacity: 0.8; + + .toast-v2 { + width: 100%; } } diff --git a/packages/ui/src/v2/components/toast-v2.stories.tsx b/packages/ui/src/v2/components/toast-v2.stories.tsx index f096459739f7..b2af8c038dde 100644 --- a/packages/ui/src/v2/components/toast-v2.stories.tsx +++ b/packages/ui/src/v2/components/toast-v2.stories.tsx @@ -19,7 +19,7 @@ Use brief titles/descriptions; limit actions to 1-2. - Toasts render in a portal and auto-dismiss unless persistent. ### Accessibility -- TODO: confirm aria-live behavior from Kobalte Toast. +- The region announces additions through an ARIA live region and expands with Alt+T. ### Theming/tokens - Uses \`data-component="toast-v2"\` and slot data attributes. @@ -57,10 +57,10 @@ export const AllExamples = { - - + + ), actions: [ diff --git a/packages/ui/src/v2/components/toast-v2.tsx b/packages/ui/src/v2/components/toast-v2.tsx index d2dc1b3605f5..efb6ecd40cf8 100644 --- a/packages/ui/src/v2/components/toast-v2.tsx +++ b/packages/ui/src/v2/components/toast-v2.tsx @@ -1,40 +1,78 @@ -import { Toast as Kobalte, toaster } from "@kobalte/core/toast" -import type { ToastRootProps, ToastCloseButtonProps, ToastTitleProps, ToastDescriptionProps } from "@kobalte/core/toast" +import { Toaster, toast, type ToasterProps } from "solid-sonner" import type { ComponentProps, JSX } from "solid-js" -import { Show, children } from "solid-js" +import { createContext, onCleanup, onMount, splitProps, useContext } from "solid-js" import { Portal } from "solid-js/web" -import { ButtonV2 } from "./button-v2" +import "./button-v2.css" import "./toast-v2.css" -export interface ToastV2RegionProps extends ComponentProps {} +export interface ToastV2RegionProps extends ToasterProps {} function ToastV2Region(props: ToastV2RegionProps) { + const [local, rest] = splitProps(props, ["class", "className", "style", "toastOptions", "swipeDirections"]) + onMount(() => { + const sync = () => { + document.querySelectorAll(".toast-v2-region .toast-v2").forEach((element) => { + const hidden = element.dataset.visible === "false" + element.inert = hidden + element.tabIndex = hidden ? -1 : 0 + }) + } + let connected = false + const connect = () => { + const regions = document.querySelectorAll(".toast-v2-region") + if (!regions.length) return + observer.disconnect() + regions.forEach((region) => { + observer.observe(region, { + subtree: true, + childList: true, + attributes: true, + attributeFilter: ["data-visible"], + }) + }) + connected = true + sync() + } + const observer = new MutationObserver(() => { + if (!connected) connect() + else sync() + }) + observer.observe(document.body, { subtree: true, childList: true }) + queueMicrotask(connect) + onCleanup(() => observer.disconnect()) + }) return ( - - - + ) } -export interface ToastV2RootComponentProps extends ToastRootProps { - class?: string - classList?: ComponentProps<"li">["classList"] +const ToastV2Context = createContext() + +export interface ToastV2RootComponentProps { + toastId: number children?: JSX.Element } function ToastV2Root(props: ToastV2RootComponentProps) { - return ( - - ) + return {props.children} } function ToastV2Icon(props: ComponentProps<"div">) { @@ -45,26 +83,43 @@ function ToastV2Content(props: ComponentProps<"div">) { return
    } -function ToastV2Title(props: ToastTitleProps & ComponentProps<"div">) { - return +function ToastV2Title(props: ComponentProps<"div">) { + return
    } -function ToastV2Description(props: ToastDescriptionProps & ComponentProps<"div">) { - return +function ToastV2Description(props: ComponentProps<"div">) { + return
    } function ToastV2Actions(props: ComponentProps<"div">) { return
    } -function ToastV2CloseButton(props: ToastCloseButtonProps & ComponentProps<"button">) { +function ToastV2CloseButton(props: ComponentProps<"button">) { + const toastId = useContext(ToastV2Context) + const [local, rest] = splitProps(props, ["children", "onClick"]) return ( - - - + + ) +} + +function CloseIcon() { + return ( + ) } @@ -78,7 +133,29 @@ export const ToastV2 = Object.assign(ToastV2Root, { CloseButton: ToastV2CloseButton, }) -export { toaster as toasterV2 } +let toastV2Id = 0 + +export const toasterV2 = { + show(render: (props: { toastId: number }) => JSX.Element, options?: { duration?: number; persistent?: boolean }) { + const toastId = --toastV2Id + toast.custom((id) => render({ toastId: Number(id) }), { + id: toastId, + className: "toast-v2", + duration: options?.persistent ? Number.POSITIVE_INFINITY : options?.duration, + unstyled: true, + }) + return toastId + }, + dismiss(toastId?: number) { + if (toastId === undefined) { + activeToastV2ByKey.clear() + activeToastV2ById.clear() + } else { + releaseToastV2(activeToastV2ById.get(toastId)) + } + return toast.dismiss(toastId) + }, +} export interface ToastV2Action { label: string @@ -90,53 +167,107 @@ export interface ToastV2Options { title?: string description?: string icon?: JSX.Element + variant?: "default" | "success" | "error" | "loading" duration?: number persistent?: boolean actions?: ToastV2Action[] } +interface ActiveToastV2 { + id: number + key: string + options: ToastV2Options + actions?: JSX.Element +} + +const activeToastV2ByKey = new Map() +const activeToastV2ById = new Map() + export function showToastV2(options: ToastV2Options | string) { - const opts = typeof options === "string" ? { description: options } : options - return toaster.show((props) => { - const resolvedIcon = children(() => opts.icon) - return ( - -
    - - {resolvedIcon()} - - - - {opts.title} - - - {opts.description} - - - -
    - - - {opts.actions!.map((action) => ( - { - if (typeof action.onClick === "function") { - action.onClick() - } - toaster.dismiss(props.toastId) - }} - > - {action.label} - - ))} - - -
    - ) + const opts: ToastV2Options = typeof options === "string" ? { description: options } : options + const key = JSON.stringify({ + title: opts.title, + description: opts.description, + variant: opts.variant, + duration: opts.duration, + persistent: opts.persistent, + actions: opts.actions?.map((action) => [action.label, action.variant]), }) + const active = activeToastV2ByKey.get(key) + const toasts = toast.getToasts() + + if (active && toasts.at(-1)?.id === active.id) { + active.options = opts + publishToastV2(active) + pulseToastV2(active.id) + return active.id + } + + if (active && toasts.some((item) => item.id === active.id)) toasterV2.dismiss(active.id) + releaseToastV2(active) + + const entry: ActiveToastV2 = { id: --toastV2Id, key, options: opts } + entry.actions = createToastV2Actions(entry) + activeToastV2ByKey.set(key, entry) + activeToastV2ById.set(entry.id, entry) + publishToastV2(entry) + return entry.id +} + +function publishToastV2(entry: ActiveToastV2) { + const release = () => releaseToastV2(entry) + toast(entry.options.title ?? "", { + id: entry.id, + description: entry.options.description, + icon: entry.options.icon, + action: entry.actions, + closeButton: true, + duration: entry.options.persistent ? Number.POSITIVE_INFINITY : entry.options.duration, + className: `toast-v2 toast-v2--${entry.options.variant ?? "default"}`, + testId: `toast-v2-${entry.id}`, + unstyled: true, + onDismiss: release, + onAutoClose: release, + }) +} + +function createToastV2Actions(entry: ActiveToastV2) { + if (!entry.options.actions?.length) return undefined + return ( + + {entry.options.actions.map((action, index) => ( + + ))} + + ) +} + +function pulseToastV2(toastId: number) { + if (typeof document === "undefined" || typeof requestAnimationFrame === "undefined") return + requestAnimationFrame(() => { + const element = document.querySelector(`[data-testid="toast-v2-${toastId}"]`) + if (!element || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return + element.animate([{ scale: 1 }, { scale: 1.025 }, { scale: 1 }], { duration: 160, easing: "ease-out" }) + }) +} + +function releaseToastV2(entry: ActiveToastV2 | undefined) { + if (!entry) return + if (activeToastV2ByKey.get(entry.key) === entry) activeToastV2ByKey.delete(entry.key) + if (activeToastV2ById.get(entry.id) === entry) activeToastV2ById.delete(entry.id) } export interface ToastV2PromiseOptions { From 5b11635d97ecc5dceb66752c876b4cd6c5da8043 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:28:38 +0530 Subject: [PATCH 145/333] feat(app): always show new session button (#39520) Co-authored-by: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> --- packages/app/src/components/titlebar.tsx | 40 +++++++++++------------- 1 file changed, 19 insertions(+), 21 deletions(-) diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 32d7e80b2f0f..649a8f57773c 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -395,27 +395,25 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl }} onReorder={(keys) => tabsStoreActions.reorder(keys)} /> - - - {language.t("command.session.new")} - - - } - > - } - onClick={openNewTab} - aria-label={language.t("command.session.new")} - /> - - + + {language.t("command.session.new")} + + + } + > + } + onClick={openNewTab} + aria-label={language.t("command.session.new")} + /> +
    From eca5e68a5ea7f2f54ec0d81a46a41110c43e62b4 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:32:15 +0530 Subject: [PATCH 146/333] feat(app): disallow duplicate attachments (#39464) --- .../app/src/components/prompt-input-v2.tsx | 1 + packages/app/src/i18n/ar.ts | 1 + packages/app/src/i18n/br.ts | 1 + packages/app/src/i18n/bs.ts | 1 + packages/app/src/i18n/da.ts | 1 + packages/app/src/i18n/de.ts | 1 + packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 1 + packages/app/src/i18n/fr.ts | 1 + packages/app/src/i18n/ja.ts | 1 + packages/app/src/i18n/ko.ts | 1 + packages/app/src/i18n/no.ts | 1 + packages/app/src/i18n/pl.ts | 1 + packages/app/src/i18n/ru.ts | 1 + packages/app/src/i18n/th.ts | 1 + packages/app/src/i18n/tr.ts | 1 + packages/app/src/i18n/uk.ts | 1 + packages/app/src/i18n/zh.ts | 1 + packages/app/src/i18n/zht.ts | 1 + .../test-browser/prompt-attachments.test.ts | 86 +++++++++++++++++++ .../v2/components/prompt-input/attachments.ts | 22 ++++- 21 files changed, 124 insertions(+), 3 deletions(-) diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 634e90f78ff1..2d6a5e98d68d 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -368,6 +368,7 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): title: language.t("prompt.toast.pasteUnsupported.title"), description: language.t("prompt.toast.pasteUnsupported.description"), }), + duplicate: () => showToast({ title: language.t("prompt.toast.attachmentDuplicate.title") }), onError: (error) => showToast({ variant: "error", diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 320738a6b027..89fa39463735 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.action.send": "إرسال", "prompt.action.stop": "توقف", "prompt.toast.pasteUnsupported.title": "مرفق غير مدعوم", + "prompt.toast.attachmentDuplicate.title": "تم تحميل هذا الملف بالفعل", "prompt.toast.pasteUnsupported.description": "يمكن إرفاق الصور أو ملفات PDF أو الملفات النصية فقط هنا.", "prompt.toast.modelAgentRequired.title": "حدد وكيلاً ونموذجاً", "prompt.toast.modelAgentRequired.description": "اختر وكيلاً ونموذجاً قبل إرسال الموجه.", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 9d6765ec94ef..f6e5d8df9df3 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.action.send": "Enviar", "prompt.action.stop": "Parar", "prompt.toast.pasteUnsupported.title": "Anexo não suportado", + "prompt.toast.attachmentDuplicate.title": "Este arquivo já foi enviado", "prompt.toast.pasteUnsupported.description": "Apenas imagens, PDFs ou arquivos de texto podem ser anexados aqui.", "prompt.toast.modelAgentRequired.title": "Selecione um agente e modelo", "prompt.toast.modelAgentRequired.description": "Escolha um agente e modelo antes de enviar um prompt.", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 915313c3e2b5..3343df7a2b55 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -298,6 +298,7 @@ export const dict = { "prompt.action.stop": "Zaustavi", "prompt.toast.pasteUnsupported.title": "Nepodržan prilog", + "prompt.toast.attachmentDuplicate.title": "Ova datoteka je već učitana", "prompt.toast.pasteUnsupported.description": "Ovdje se mogu priložiti samo slike, PDF-ovi ili tekstualne datoteke.", "prompt.toast.modelAgentRequired.title": "Odaberi agenta i model", "prompt.toast.modelAgentRequired.description": "Odaberi agenta i model prije slanja upita.", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 22fcdf39ceb0..52bfb642c3cb 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -296,6 +296,7 @@ export const dict = { "prompt.action.stop": "Stop", "prompt.toast.pasteUnsupported.title": "Ikke understøttet vedhæftning", + "prompt.toast.attachmentDuplicate.title": "Denne fil er allerede uploadet", "prompt.toast.pasteUnsupported.description": "Kun billeder, PDF'er eller tekstfiler kan vedhæftes her.", "prompt.toast.modelAgentRequired.title": "Vælg en agent og model", "prompt.toast.modelAgentRequired.description": "Vælg en agent og model før du sender en forespørgsel.", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index bc2ac9be381d..86195460790a 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -282,6 +282,7 @@ export const dict = { "prompt.action.send": "Senden", "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Nicht unterstützter Anhang", + "prompt.toast.attachmentDuplicate.title": "Diese Datei wurde bereits hochgeladen", "prompt.toast.pasteUnsupported.description": "Hier können nur Bilder, PDFs oder Textdateien angehängt werden.", "prompt.toast.modelAgentRequired.title": "Wählen Sie einen Agenten und ein Modell", "prompt.toast.modelAgentRequired.description": diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index f03147f151d1..76623f44685f 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -301,6 +301,7 @@ export const dict = { "prompt.toast.pasteUnsupported.title": "Unsupported attachment", "prompt.toast.pasteUnsupported.description": "Only images, PDFs, or text files can be attached here.", + "prompt.toast.attachmentDuplicate.title": "This file has already been uploaded", "prompt.toast.modelAgentRequired.title": "Select an agent and model", "prompt.toast.modelAgentRequired.description": "Choose an agent and model before sending a prompt.", "prompt.toast.worktreeCreateFailed.title": "Failed to create worktree", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 00dca6704b25..043c997c0454 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "Detener", "prompt.toast.pasteUnsupported.title": "Adjunto no compatible", + "prompt.toast.attachmentDuplicate.title": "Este archivo ya se ha subido", "prompt.toast.pasteUnsupported.description": "Solo se pueden adjuntar imágenes, PDFs o archivos de texto aquí.", "prompt.toast.modelAgentRequired.title": "Selecciona un agente y modelo", "prompt.toast.modelAgentRequired.description": "Elige un agente y modelo antes de enviar un prompt.", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index d1487ff61ef3..fc5c5bbb346f 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -277,6 +277,7 @@ export const dict = { "prompt.action.send": "Envoyer", "prompt.action.stop": "Arrêter", "prompt.toast.pasteUnsupported.title": "Pièce jointe non prise en charge", + "prompt.toast.attachmentDuplicate.title": "Ce fichier a déjà été téléversé", "prompt.toast.pasteUnsupported.description": "Seules les images, les PDF ou les fichiers texte peuvent être joints ici.", "prompt.toast.modelAgentRequired.title": "Sélectionnez un agent et un modèle", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 6a3b1ebcb02e..56c63e0e7086 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -276,6 +276,7 @@ export const dict = { "prompt.action.send": "送信", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "サポートされていない添付ファイル", + "prompt.toast.attachmentDuplicate.title": "このファイルはすでにアップロードされています", "prompt.toast.pasteUnsupported.description": "画像、PDF、またはテキストファイルのみ添付できます。", "prompt.toast.modelAgentRequired.title": "エージェントとモデルを選択", "prompt.toast.modelAgentRequired.description": "プロンプトを送信する前にエージェントとモデルを選択してください。", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 3c262ce3b93a..02d9a5e0e189 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -264,6 +264,7 @@ export const dict = { "prompt.action.send": "전송", "prompt.action.stop": "중지", "prompt.toast.pasteUnsupported.title": "지원되지 않는 첨부 파일", + "prompt.toast.attachmentDuplicate.title": "이 파일은 이미 업로드되었습니다", "prompt.toast.pasteUnsupported.description": "이미지, PDF 또는 텍스트 파일만 첨부할 수 있습니다.", "prompt.toast.modelAgentRequired.title": "에이전트 및 모델 선택", "prompt.toast.modelAgentRequired.description": "프롬프트를 보내기 전에 에이전트와 모델을 선택하세요.", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index a4e8efb79d27..959e4c68cb40 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -288,6 +288,7 @@ export const dict = { "prompt.action.stop": "Stopp", "prompt.toast.pasteUnsupported.title": "Ikke støttet vedlegg", + "prompt.toast.attachmentDuplicate.title": "Denne filen er allerede lastet opp", "prompt.toast.pasteUnsupported.description": "Kun bilder, PDF-er eller tekstfiler kan legges ved her.", "prompt.toast.modelAgentRequired.title": "Velg en agent og modell", "prompt.toast.modelAgentRequired.description": "Velg en agent og modell før du sender en forespørsel.", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index f2e32ffeb879..7517f16b8125 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -278,6 +278,7 @@ export const dict = { "prompt.action.send": "Wyślij", "prompt.action.stop": "Zatrzymaj", "prompt.toast.pasteUnsupported.title": "Nieobsługiwany załącznik", + "prompt.toast.attachmentDuplicate.title": "Ten plik został już przesłany", "prompt.toast.pasteUnsupported.description": "Można tutaj załączać tylko obrazy, pliki PDF lub pliki tekstowe.", "prompt.toast.modelAgentRequired.title": "Wybierz agenta i model", "prompt.toast.modelAgentRequired.description": "Wybierz agenta i model przed wysłaniem zapytania.", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index fa6fb04d04e1..17d6ba58a9d7 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "Остановить", "prompt.toast.pasteUnsupported.title": "Неподдерживаемое вложение", + "prompt.toast.attachmentDuplicate.title": "Этот файл уже загружен", "prompt.toast.pasteUnsupported.description": "Здесь можно прикрепить только изображения, PDF или текстовые файлы.", "prompt.toast.modelAgentRequired.title": "Выберите агента и модель", "prompt.toast.modelAgentRequired.description": "Выберите агента и модель перед отправкой запроса.", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0ae67ee34eb1..2c0b7d081c8a 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "หยุด", "prompt.toast.pasteUnsupported.title": "ไฟล์แนบที่ไม่รองรับ", + "prompt.toast.attachmentDuplicate.title": "ไฟล์นี้ถูกอัปโหลดแล้ว", "prompt.toast.pasteUnsupported.description": "แนบได้เฉพาะรูปภาพ, PDF หรือไฟล์ข้อความเท่านั้น", "prompt.toast.modelAgentRequired.title": "เลือกเอเจนต์และโมเดล", "prompt.toast.modelAgentRequired.description": "เลือกเอเจนต์และโมเดลก่อนส่งพร้อมท์", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index d401519af9c9..7c123c69aabe 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -302,6 +302,7 @@ export const dict = { "prompt.action.stop": "Durdur", "prompt.toast.pasteUnsupported.title": "Desteklenmeyen ek", + "prompt.toast.attachmentDuplicate.title": "Bu dosya zaten yüklendi", "prompt.toast.pasteUnsupported.description": "Buraya yalnızca resimler, PDF'ler veya metin dosyaları eklenebilir.", "prompt.toast.modelAgentRequired.title": "Bir ajan ve model seçin", "prompt.toast.modelAgentRequired.description": "Komut göndermeden önce bir ajan ve model seçin.", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index c949f41de2dc..d8c9ae8e435e 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -299,6 +299,7 @@ export const dict = { "prompt.action.stop": "Зупинити", "prompt.toast.pasteUnsupported.title": "Непідтримуване вкладення", + "prompt.toast.attachmentDuplicate.title": "Цей файл уже завантажено", "prompt.toast.pasteUnsupported.description": "Сюди можна прикріплювати лише зображення, PDF або текстові файли.", "prompt.toast.modelAgentRequired.title": "Виберіть агента та модель", "prompt.toast.modelAgentRequired.description": "Виберіть агента та модель перед надсиланням запиту.", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 82d73ac3a6a6..724bcc117599 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -316,6 +316,7 @@ export const dict = { "prompt.action.send": "发送", "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支持的附件", + "prompt.toast.attachmentDuplicate.title": "此文件已上传", "prompt.toast.pasteUnsupported.description": "此处仅能附加图片、PDF 或文本文件。", "prompt.toast.modelAgentRequired.title": "请选择智能体和模型", "prompt.toast.modelAgentRequired.description": "发送提示前请先选择智能体和模型。", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index fbe80d95eeff..6f6682216555 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -297,6 +297,7 @@ export const dict = { "prompt.action.stop": "停止", "prompt.toast.pasteUnsupported.title": "不支援的附件", + "prompt.toast.attachmentDuplicate.title": "此檔案已上傳", "prompt.toast.pasteUnsupported.description": "此處僅能附加圖片、PDF 或文字檔案。", "prompt.toast.modelAgentRequired.title": "請選擇代理程式和模型", "prompt.toast.modelAgentRequired.description": "傳送提示前請先選擇代理程式和模型。", diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts index ca7686598e93..d604477e9e47 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -1,7 +1,10 @@ import { describe, expect, test } from "bun:test" import { createRoot } from "solid-js" +import { createStore } from "solid-js/store" import { createPromptAttachmentsCore } from "@/components/prompt-input/attachments" import { createPromptState } from "@/context/prompt" +import { createPromptInputV2Attachments } from "../../session-ui/src/v2/components/prompt-input/attachments" +import type { PromptInputV2Prompt } from "../../session-ui/src/v2/components/prompt-input/types" describe("prompt attachment session ownership", () => { test("adds an asynchronously read image to the session where the read started", async () => { @@ -83,6 +86,89 @@ describe("prompt attachment session ownership", () => { dispose() }) }) + +}) + +test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => { + await createRoot(async (dispose) => { + const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) + const duplicate = Promise.withResolvers() + const files = [ + new File(["hello"], "clipboard-1.txt", { type: "text/plain" }), + new File(["hello"], "clipboard-2.txt", { type: "text/plain" }), + ] + const attachments = createPromptInputV2Attachments({ + capture: () => ({ + current: () => state.prompt, + cursor: () => 0, + set: (prompt) => setState("prompt", prompt), + }), + editor: () => document.createElement("div"), + focusEditor: () => undefined, + addPart: () => false, + setDraggingType: () => undefined, + directory: () => "/", + isDialogActive: () => false, + warn: () => undefined, + duplicate: duplicate.resolve, + onError: () => undefined, + readClipboardImage: async () => files.shift() ?? null, + }) + const event = { + clipboardData: { items: [], getData: () => "" }, + preventDefault: () => undefined, + stopPropagation: () => undefined, + } as unknown as ClipboardEvent + + await attachments.handlePaste(event) + await attachments.handlePaste(event) + await duplicate.promise + + expect(state.prompt).toHaveLength(1) + dispose() + }) +}) + +test("rejects desktop duplicates and keeps changed files in the V2 prompt store", async () => { + await createRoot(async (dispose) => { + const [state, setState] = createStore({ prompt: [] as PromptInputV2Prompt }) + const duplicates: string[] = [] + const attachments = createPromptInputV2Attachments({ + capture: () => ({ + current: () => state.prompt, + cursor: () => 0, + set: (prompt) => setState("prompt", prompt), + }), + editor: () => document.createElement("div"), + focusEditor: () => undefined, + addPart: () => false, + setDraggingType: () => undefined, + directory: () => "/", + isDialogActive: () => false, + warn: () => undefined, + duplicate: () => duplicates.push("duplicate"), + onError: () => undefined, + getPathForFile: (file) => (file.name === "browser.txt" ? "" : `/tmp/${file.name}`), + }) + const first = new File(["first"], "a.txt", { type: "text/plain" }) + const second = new File(["second"], "b.txt", { type: "text/plain" }) + + await attachments.addAttachments([first, second]) + await attachments.addAttachments([first, second]) + expect(state.prompt).toHaveLength(2) + expect(duplicates).toEqual(["duplicate", "duplicate"]) + + await attachments.addAttachments([new File(["edited"], "a.txt", { type: "text/plain" })]) + expect(state.prompt).toHaveLength(3) + + await attachments.addAttachments([ + new File(["same"], "browser.txt", { type: "text/plain" }), + new File(["same"], "browser.txt", { type: "text/plain" }), + ]) + expect(state.prompt).toHaveLength(4) + expect(duplicates).toHaveLength(3) + dispose() + }) }) function images(prompt: ReturnType) { diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts index 8c7b12e9ae1d..cfe3c43bd8a0 100644 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -74,6 +74,7 @@ export type PromptInputV2AttachmentConfig = { directory: () => string isDialogActive: () => boolean warn: () => void + duplicate: () => void onError: (error: unknown) => void readClipboardImage?: () => Promise getPathForFile?: (file: File) => string @@ -94,7 +95,7 @@ export function createPromptInputV2Attachments( if (!editor) return return { prompt, cursor: prompt.cursor() ?? cursorPosition(editor) } } - const add = async (file: File, toast = true, target = capture()) => { + const add = async (file: File, toast = true, target = capture(), clipboard = false) => { if (!target) return false const mime = await attachmentMime(file) if (!mime) { @@ -103,11 +104,26 @@ export function createPromptInputV2Attachments( } const url = await dataUrl(file, mime) if (!url) return false + const sourcePath = input.getPathForFile?.(file) || undefined + // Native clipboard images arrive with a fresh timestamped filename on every paste, so identical + // clipboard content is matched on bytes alone. + const duplicate = target.prompt + .current() + .some( + (part) => + part.type === "image" && + part.dataUrl === url && + (sourcePath ? part.sourcePath === sourcePath : !part.sourcePath && (clipboard || part.filename === file.name)), + ) + if (duplicate) { + input.duplicate() + return true + } const attachment: PromptInputV2Attachment = { type: "image", id: globalThis.crypto?.randomUUID?.() ?? Math.random().toString(16).slice(2), filename: file.name, - sourcePath: input.getPathForFile?.(file) || undefined, + sourcePath, mime, dataUrl: url, } @@ -141,7 +157,7 @@ export function createPromptInputV2Attachments( const plainText = clipboardData.getData("text/plain") ?? "" if (input.readClipboardImage && !plainText) { const file = await input.readClipboardImage() - if (file && (await add(file, true, target))) return + if (file && (await add(file, true, target, true))) return } if (!plainText) return const text = plainText.includes("\r") ? plainText.replace(/\r\n?/g, "\n") : plainText From 005bf088c9371ab886affed91b021fae33388503 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:33:23 +0530 Subject: [PATCH 147/333] feat(app): fix file tree button bg (#38629) --- packages/session-ui/src/v2/components/session-review-v2.css | 2 +- packages/session-ui/src/v2/components/session-review-v2.tsx | 1 + 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index 751c258a5272..e28a6787d4a9 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -244,7 +244,7 @@ } [data-component="icon-button-v2"][data-variant="ghost"].session-review-v2-sidebar-toggle[aria-expanded="true"] { - background-color: var(--v2-overlay-simple-overlay-pressed); + background-color: var(--v2-background-bg-layer-02); } [data-component="session-review-v2"] [data-slot="session-review-v2-file-header"] { diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index bfd6778c541e..d2f3b6b8a6ec 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -333,6 +333,7 @@ export function SessionReviewV2SidebarToggle(props: { opened: boolean; disabled? class="session-review-v2-sidebar-toggle" aria-label={i18n.t("ui.sessionReviewV2.toggleSidebar")} aria-expanded={props.opened} + data-expanded={props.opened ? "" : undefined} disabled={props.disabled} onClick={props.onToggle} icon={} From a277557a5fa767c59a301aacf7f7dfa07c8a6060 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 09:05:12 +0000 Subject: [PATCH 148/333] chore: generate --- packages/app/test-browser/prompt-attachments.test.ts | 1 - .../session-ui/src/v2/components/prompt-input/attachments.ts | 4 +++- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/packages/app/test-browser/prompt-attachments.test.ts b/packages/app/test-browser/prompt-attachments.test.ts index d604477e9e47..49dad279cdf2 100644 --- a/packages/app/test-browser/prompt-attachments.test.ts +++ b/packages/app/test-browser/prompt-attachments.test.ts @@ -86,7 +86,6 @@ describe("prompt attachment session ownership", () => { dispose() }) }) - }) test("rejects a duplicate native clipboard attachment in the V2 prompt store", async () => { diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.ts b/packages/session-ui/src/v2/components/prompt-input/attachments.ts index cfe3c43bd8a0..afa97a7f3e4f 100644 --- a/packages/session-ui/src/v2/components/prompt-input/attachments.ts +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.ts @@ -113,7 +113,9 @@ export function createPromptInputV2Attachments( (part) => part.type === "image" && part.dataUrl === url && - (sourcePath ? part.sourcePath === sourcePath : !part.sourcePath && (clipboard || part.filename === file.name)), + (sourcePath + ? part.sourcePath === sourcePath + : !part.sourcePath && (clipboard || part.filename === file.name)), ) if (duplicate) { input.duplicate() From 3dbce3dade7373ff532f3ed82af9b389ff073f44 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:06 +0530 Subject: [PATCH 149/333] feat(app): adds fades to file tree (#38549) --- .../src/v2/components/session-review-v2.css | 67 ++++++++++++++++++- 1 file changed, 64 insertions(+), 3 deletions(-) diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index e28a6787d4a9..704e8fd7d721 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -63,7 +63,7 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 12px 16px 8px 8px; + padding: 12px 16px 12px 8px; flex-shrink: 0; } @@ -91,7 +91,7 @@ } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-filter"] { - padding: 0 8px 8px; + padding: 0 8px 4px; flex-shrink: 0; min-width: 0; box-sizing: border-box; @@ -109,10 +109,71 @@ [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] { flex: 1; min-height: 0; + timeline-scope: --session-review-v2-sidebar-tree-scroll; +} + +@keyframes session-review-v2-sidebar-tree-fade-top { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes session-review-v2-sidebar-tree-fade-bottom { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before, +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + content: ""; + position: absolute; + inset-inline: 0; + z-index: 10; + height: 16px; + pointer-events: none; + visibility: hidden; +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + top: 0; + background: linear-gradient(to bottom, var(--v2-background-bg-base), transparent); +} + +[data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + bottom: 0; + background: linear-gradient(to top, var(--v2-background-bg-base), transparent); +} + +@supports (animation-timeline: --session-review-v2-sidebar-tree-scroll) and + (timeline-scope: --session-review-v2-sidebar-tree-scroll) { + [data-component="session-review-v2-sidebar-root"] + [data-slot="session-review-v2-sidebar-tree"] + .scroll-view__viewport { + scroll-timeline: --session-review-v2-sidebar-tree-scroll y; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::before { + animation: session-review-v2-sidebar-tree-fade-top linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: 0 0.1px; + } + + [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"]::after { + animation: session-review-v2-sidebar-tree-fade-bottom linear both; + animation-timeline: --session-review-v2-sidebar-tree-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__viewport { - padding: 0 8px 12px; + padding: 4px 8px 12px; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-tree"] .scroll-view__thumb { From c5cf416633e4b12daf21b4ea230d52f2dae03f32 Mon Sep 17 00:00:00 2001 From: Aarav Sareen <96787824+arvsrn@users.noreply.github.com> Date: Thu, 30 Jul 2026 14:37:35 +0530 Subject: [PATCH 150/333] feat(app): fix composer attachment fades (#38547) --- .../components/prompt-input/attachments.css | 44 +++++++++++++++++++ .../src/v2/components/prompt-input/index.tsx | 15 +++++-- 2 files changed, 55 insertions(+), 4 deletions(-) create mode 100644 packages/session-ui/src/v2/components/prompt-input/attachments.css diff --git a/packages/session-ui/src/v2/components/prompt-input/attachments.css b/packages/session-ui/src/v2/components/prompt-input/attachments.css new file mode 100644 index 000000000000..3b8173d105e7 --- /dev/null +++ b/packages/session-ui/src/v2/components/prompt-input/attachments.css @@ -0,0 +1,44 @@ +@keyframes prompt-input-v2-attachments-fade-left { + from { + visibility: hidden; + } + to { + visibility: visible; + } +} + +@keyframes prompt-input-v2-attachments-fade-right { + from { + visibility: visible; + } + to { + visibility: hidden; + } +} + +[data-component="prompt-input-v2-attachments"] { + timeline-scope: --prompt-input-v2-attachments-scroll; + + [data-slot^="prompt-attachments-fade-"] { + visibility: hidden; + } +} + +@supports (animation-timeline: --prompt-input-v2-attachments-scroll) and + (timeline-scope: --prompt-input-v2-attachments-scroll) { + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-scroll"] { + scroll-timeline: --prompt-input-v2-attachments-scroll x; + } + + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-left"] { + animation: prompt-input-v2-attachments-fade-left linear both; + animation-timeline: --prompt-input-v2-attachments-scroll; + animation-range: 0 0.1px; + } + + [data-component="prompt-input-v2-attachments"] [data-slot="prompt-attachments-fade-right"] { + animation: prompt-input-v2-attachments-fade-right linear both; + animation-timeline: --prompt-input-v2-attachments-scroll; + animation-range: calc(100% - 1.1px) calc(100% - 1px); + } +} diff --git a/packages/session-ui/src/v2/components/prompt-input/index.tsx b/packages/session-ui/src/v2/components/prompt-input/index.tsx index c09b5a1b5f9a..0989e2ef6552 100644 --- a/packages/session-ui/src/v2/components/prompt-input/index.tsx +++ b/packages/session-ui/src/v2/components/prompt-input/index.tsx @@ -21,6 +21,7 @@ import type { PromptInputV2Suggestion, } from "./types" import type { PromptInputV2Interaction, PromptInputV2SelectControl } from "./interaction" +import "./attachments.css" export type { PromptInputV2Attachment, @@ -107,7 +108,7 @@ export function PromptInputV2(props: PromptInputV2Props) {
    0 || (props.comments?.length ?? 0) > 0}> -
    +
    -
    -
    +
    +
    ) From c08350b83e3d976c0f93af68d22f5b1518194f30 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 09:11:45 +0000 Subject: [PATCH 151/333] 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 59ba15e5b23d..a7b441535c10 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-Z/YgK5sf0L9MG7hMIb+wj3ias6iNo8L4oFHYNtRHreM=", - "aarch64-linux": "sha256-MoYZmeh6RcDUN7a132wexFNwkHz+Q9Ijs+qiacuKJ90=", - "aarch64-darwin": "sha256-OHo3x7UcJzrPMAF9Ij/9Rhamd+dfpb5QuxP89cy7Rw4=", - "x86_64-darwin": "sha256-ElzZVqkQPYTAd0nvLCB5uel9xtOqMK07QFW8MDr174E=" + "x86_64-linux": "sha256-fK6zAHJC3ut/KUdfqLPxVMH7Z1yv65YZ7qcHF45kPas=", + "aarch64-linux": "sha256-CSWZY/vVUCKzlwfisQQHqHjawUwL0BRc8Y9o2y/QZYA=", + "aarch64-darwin": "sha256-IyFm5NbnU63BaOO/F4/v1exz3VbvkY96yjg9iun+O9Q=", + "x86_64-darwin": "sha256-XnXFoUxqXmEx7AKx5YhaJuLY2NYMtI0ICafaenU5L6k=" } } From ac5380614f310586949e0947b1be244268ebdb7c Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:29:40 +0200 Subject: [PATCH 152/333] fix(app): repair malformed persisted tabs (#39645) --- packages/app/src/context/tab-migration.ts | 23 +++++++++++++++++++++++ packages/app/src/context/tabs.test.ts | 23 +++++++++++++++++++++++ packages/app/src/context/tabs.tsx | 9 ++------- 3 files changed, 48 insertions(+), 7 deletions(-) create mode 100644 packages/app/src/context/tab-migration.ts diff --git a/packages/app/src/context/tab-migration.ts b/packages/app/src/context/tab-migration.ts new file mode 100644 index 000000000000..04c1cb6804ab --- /dev/null +++ b/packages/app/src/context/tab-migration.ts @@ -0,0 +1,23 @@ +import type { ServerConnection } from "./server" +import type { Tab } from "./tabs" + +export function migrateTabs(value: unknown, fallback: ServerConnection.Key): Tab[] { + if (!Array.isArray(value)) return [] + return value.flatMap((tab) => { + if (!tab || typeof tab !== "object") return [] + if ("server" in tab && typeof tab.server !== "string") return [] + const server = ("server" in tab ? tab.server : fallback) as ServerConnection.Key + if (tab.type === "session" && typeof tab.sessionId === "string") { + return [{ type: tab.type, server, sessionId: tab.sessionId }] + } + if ( + tab.type === "draft" && + typeof tab.draftID === "string" && + typeof tab.directory === "string" && + (tab.worktree === undefined || typeof tab.worktree === "string") + ) { + return [{ type: tab.type, server, draftID: tab.draftID, directory: tab.directory, worktree: tab.worktree }] + } + return [] + }) +} diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index b56ea2dfcf93..17371efd80ce 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -3,6 +3,7 @@ import { createRoot, getOwner, onCleanup } from "solid-js" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import type { SessionTab, Tab } from "./tabs" +import { migrateTabs } from "./tab-migration" import type { ServerConnection } from "./server" const server = "local\nhttp://localhost:4096" as ServerConnection.Key @@ -11,6 +12,28 @@ function sessionTab(sessionId: string): SessionTab { return { type: "session", server, sessionId } } +describe("tab migration", () => { + test("drops null and malformed persisted tabs", () => { + expect( + migrateTabs( + [null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], + server, + ), + ).toEqual([sessionTab("a")]) + }) + + test("adds the fallback server to valid legacy tabs", () => { + expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([ + sessionTab("a"), + ]) + }) + + test("replaces invalid top-level persisted data", () => { + expect(migrateTabs(null, server)).toEqual([]) + expect(migrateTabs({}, server)).toEqual([]) + }) +}) + describe("tab memory", () => { test("keeps state until its tab is removed", () => { createRoot((dispose) => { diff --git a/packages/app/src/context/tabs.tsx b/packages/app/src/context/tabs.tsx index 75191d54cf4a..e4d50ac16118 100644 --- a/packages/app/src/context/tabs.tsx +++ b/packages/app/src/context/tabs.tsx @@ -12,6 +12,7 @@ import { sessionHref } from "@/utils/session-route" import { createTabMemory } from "./tab-memory" import { nextTabAfterClose, pushClosedTab, removeClosedTabs, takeClosedTab, type ClosedTab } from "./closed-tabs" import { createDraftPromptSession, type PromptModel } from "./prompt-state" +import { migrateTabs } from "./tab-migration" export type SessionTab = { type: "session" @@ -59,13 +60,7 @@ export const { use: useTabs, provider: TabsProvider } = createSimpleContext({ const [store, setStore, _, ready] = persisted( { ...Persist.window("tabs"), - migrate: (value: unknown) => { - if (!Array.isArray(value)) return value - return value.map((tab) => { - if (!tab || typeof tab !== "object" || "server" in tab) return tab - return { ...tab, server: fallback } - }) - }, + migrate: (value: unknown) => migrateTabs(value, fallback), }, createStore([]), ) From ff0382e97145cb6585b575dcc1269fa1512e853b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 09:31:27 +0000 Subject: [PATCH 153/333] chore: generate --- packages/app/src/context/tabs.test.ts | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/packages/app/src/context/tabs.test.ts b/packages/app/src/context/tabs.test.ts index 17371efd80ce..abd62a4dae13 100644 --- a/packages/app/src/context/tabs.test.ts +++ b/packages/app/src/context/tabs.test.ts @@ -15,17 +15,12 @@ function sessionTab(sessionId: string): SessionTab { describe("tab migration", () => { test("drops null and malformed persisted tabs", () => { expect( - migrateTabs( - [null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], - server, - ), + migrateTabs([null, sessionTab("a"), { type: "session", server }, { type: "unknown", server }, "invalid"], server), ).toEqual([sessionTab("a")]) }) test("adds the fallback server to valid legacy tabs", () => { - expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([ - sessionTab("a"), - ]) + expect(migrateTabs([{ type: "session", sessionId: "a", dirBase64: "legacy" }], server)).toEqual([sessionTab("a")]) }) test("replaces invalid top-level persisted data", () => { From a4f25a94b4e470ec10cd97a0c8e2f2c23e215b38 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 10:12:08 -0400 Subject: [PATCH 154/333] fix: use OpenCode model catalog URL (#39672) Co-authored-by: Dax Raad --- packages/cli/script/generate.ts | 2 +- packages/core/src/models-dev.ts | 4 ++-- packages/llm/script/recording-cost-report.ts | 2 +- packages/stats/app/src/routes/model-catalog.ts | 6 +++--- packages/ui/vite.config.ts | 2 +- 5 files changed, 8 insertions(+), 8 deletions(-) diff --git a/packages/cli/script/generate.ts b/packages/cli/script/generate.ts index d98565e298d3..e162f2ea7e5a 100755 --- a/packages/cli/script/generate.ts +++ b/packages/cli/script/generate.ts @@ -1,4 +1,4 @@ -const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.dev" +const modelsUrl = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" export const modelsData = process.env.MODELS_DEV_API_JSON ? await Bun.file(process.env.MODELS_DEV_API_JSON).text() diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 602133d9047a..7e3118722f79 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -151,10 +151,10 @@ const layer = Layer.effect( ), ) - const source = Flag.OPENCODE_MODELS_URL || "https://models.dev" + const source = Flag.OPENCODE_MODELS_URL || "https://models.opencode.ai" const filepath = path.join( Global.Path.cache, - source === "https://models.dev" ? "models.json" : `models-${Hash.fast(source)}.json`, + source === "https://models.opencode.ai" ? "models.json" : `models-${Hash.fast(source)}.json`, ) const ttl = Duration.minutes(5) const lockKey = `models-dev:${filepath}` diff --git a/packages/llm/script/recording-cost-report.ts b/packages/llm/script/recording-cost-report.ts index 5b08e72d5c7a..1f42dc59323e 100644 --- a/packages/llm/script/recording-cost-report.ts +++ b/packages/llm/script/recording-cost-report.ts @@ -2,7 +2,7 @@ import * as fs from "node:fs/promises" import * as path from "node:path" const RECORDINGS_DIR = path.resolve(import.meta.dir, "..", "test", "fixtures", "recordings") -const MODELS_DEV_URL = "https://models.dev/api.json" +const MODELS_DEV_URL = "https://models.opencode.ai/api.json" type JsonRecord = Record diff --git a/packages/stats/app/src/routes/model-catalog.ts b/packages/stats/app/src/routes/model-catalog.ts index efd6f6b2e097..47fa1cf3474f 100644 --- a/packages/stats/app/src/routes/model-catalog.ts +++ b/packages/stats/app/src/routes/model-catalog.ts @@ -1,8 +1,8 @@ import { query } from "@solidjs/router" -export const modelCatalogSourceUrl = "https://models.dev/catalog.json" -export const modelCatalogPricingUrl = "https://models.dev/api.json" -export const modelCatalogLabSourceUrl = "https://models.dev/labs" +export const modelCatalogSourceUrl = "https://models.opencode.ai/catalog.json" +export const modelCatalogPricingUrl = "https://models.opencode.ai/api.json" +export const modelCatalogLabSourceUrl = "https://models.opencode.ai/labs" export type ModelCatalogCost = { input: number diff --git a/packages/ui/vite.config.ts b/packages/ui/vite.config.ts index 1e38adede2bf..1d5af6934c41 100644 --- a/packages/ui/vite.config.ts +++ b/packages/ui/vite.config.ts @@ -45,7 +45,7 @@ function providerIconsPlugin() { } async function fetchProviderIcons() { - const url = process.env.OPENCODE_MODELS_URL || "https://models.dev" + const url = process.env.OPENCODE_MODELS_URL || "https://models.opencode.ai" const providers = await fetch(`${url}/api.json`) .then((res) => res.json()) .then((json) => Object.keys(json)) From 8c38d260eb6555d2824230be100fb2a7eadd7513 Mon Sep 17 00:00:00 2001 From: opencode Date: Thu, 30 Jul 2026 14:43:50 +0000 Subject: [PATCH 155/333] sync release versions for v1.18.10 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 9f6ef7b62845..160958f8da2c 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.9", + "version": "1.18.10", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.9", + "version": "1.18.10", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@zip.js/zip.js": "2.7.62", "effect": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.9", + "version": "1.18.10", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -841,7 +841,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -854,7 +854,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -888,7 +888,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -907,7 +907,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -949,7 +949,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -976,7 +976,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1028,7 +1028,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 08eb9a059f74..e6e95e35ff6b 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.9", + "version": "1.18.10", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index d64acf4840d8..46a372229b10 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 83cfeaac0c08..e0c89ec26e30 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.9", + "version": "1.18.10", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index b8fd9a04c66e..f27b89305fd2 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index f922ab9ef2fa..63ba0a50f226 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index aadfa42be10d..52581a8c93c4 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.9", + "version": "1.18.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 1a551ed0a5ea..f54268c9c835 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.9", + "version": "1.18.10", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 6d90a394cb4f..6b6a65b6e6a1 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index cc1f5d6c6157..a8a5041e7359 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index 1a5baf91b096..d00b2bda45b6 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 6ae8d912a1e5..9577e7a46363 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index d26bea7f85ae..7c084da87b24 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index d298dd1dee0f..117d2220ad5e 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index 6acd31710e7b..f8e1ee3d86a7 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.9", + "version": "1.18.10", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index cca67e0c3096..56224a41c7c3 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 69e1ea92ebdb..d575867f1cd3 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 105f307319cc..84f03d88abee 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.9", + "version": "1.18.10", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 2607c317d696..33b6d2b0dc65 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index f92dca0c4d20..68313859b608 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 89d7360a79d0..fcfa76143e01 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index eb67c17d1905..f34feebf25ca 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 7ec9f938b7a9..20f1ce059895 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 8e69fd7193df..3a19ff5aada3 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index 45e6acf9763f..c13970267486 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 5d34525aa17b..efa68e21dc47 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 9900a378491a..a96b6a70882c 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.9", + "version": "1.18.10", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index f994110b9f62..36cd5ad0274a 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.9", + "version": "1.18.10", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index bdc41cdd677b..260ca519412f 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.9", + "version": "1.18.10", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index 33c79c001d49..15943599b347 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.9", + "version": "1.18.10", "publisher": "sst-dev", "repository": { "type": "git", From a1ab489e6175cabbb3b46a15c2efac78d49d5083 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:10:34 -0500 Subject: [PATCH 156/333] fix(provider): widen interleaved reasoning fields (#39556) Co-authored-by: Aiden Cline --- packages/core/src/models-dev.ts | 10 ++++-- packages/core/src/v1/config/provider.ts | 10 ++++-- packages/opencode/src/provider/provider.ts | 11 +++++-- .../opencode/test/provider/provider.test.ts | 6 ++++ packages/sdk/js/src/v2/gen/types.gen.ts | 10 ++++-- packages/sdk/openapi.json | 32 +++++++++++++++---- 6 files changed, 63 insertions(+), 16 deletions(-) diff --git a/packages/core/src/models-dev.ts b/packages/core/src/models-dev.ts index 7e3118722f79..e577935f067a 100644 --- a/packages/core/src/models-dev.ts +++ b/packages/core/src/models-dev.ts @@ -15,6 +15,11 @@ import { httpClient } from "./effect/app-node-platform" export const CatalogModelStatus = Schema.Literals(["alpha", "beta", "deprecated"]) export type CatalogModelStatus = typeof CatalogModelStatus.Type +const InterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + const USER_AGENT = `opencode/${InstallationChannel}/${InstallationVersion}/${Flag.OPENCODE_CLIENT}` const CostTier = Schema.Struct({ @@ -71,9 +76,10 @@ export const Model = Schema.Struct({ reasoning_options: Schema.optional(Schema.Array(ReasoningOption)), interleaved: Schema.optional( Schema.Union([ - Schema.Literal(true), + Schema.Boolean, + InterleavedField, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: InterleavedField, }), ]), ), diff --git a/packages/core/src/v1/config/provider.ts b/packages/core/src/v1/config/provider.ts index d54a3f08f926..f860a2b4ac7e 100644 --- a/packages/core/src/v1/config/provider.ts +++ b/packages/core/src/v1/config/provider.ts @@ -5,6 +5,11 @@ import { PositiveInt } from "../../schema" export const ModelStatus = Schema.Literals(["alpha", "beta", "deprecated", "active"]) +const InterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + export const Model = Schema.Struct({ id: Schema.optional(Schema.String), name: Schema.optional(Schema.String), @@ -16,9 +21,10 @@ export const Model = Schema.Struct({ tool_call: Schema.optional(Schema.Boolean), interleaved: Schema.optional( Schema.Union([ - Schema.Literal(true), + Schema.Boolean, + InterleavedField, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: InterleavedField, }), ]), ), diff --git a/packages/opencode/src/provider/provider.ts b/packages/opencode/src/provider/provider.ts index a0ab5eb3474d..85b7fd3978e8 100644 --- a/packages/opencode/src/provider/provider.ts +++ b/packages/opencode/src/provider/provider.ts @@ -976,10 +976,15 @@ const ProviderModalities = Schema.Struct({ pdf: Schema.Boolean, }) +const ProviderInterleavedField = Schema.Union([ + Schema.Literals(["reasoning", "reasoning_content", "reasoning_text"]), + Schema.String, +]) + const ProviderInterleaved = Schema.Union([ Schema.Boolean, Schema.Struct({ - field: Schema.Literals(["reasoning", "reasoning_content", "reasoning_details"]), + field: ProviderInterleavedField, }), ]) @@ -1243,7 +1248,7 @@ function fromModelsDevModel(provider: ModelsDev.Provider, model: ModelsDev.Model video: model.modalities?.output?.includes("video") ?? false, pdf: model.modalities?.output?.includes("pdf") ?? false, }, - interleaved: model.interleaved ?? false, + interleaved: typeof model.interleaved === "string" ? { field: model.interleaved } : (model.interleaved ?? false), }, release_date: model.release_date ?? "", variants: {}, @@ -1475,7 +1480,7 @@ const layer = Layer.effect( pdf: model.modalities?.output?.includes("pdf") ?? existingModel?.capabilities.output.pdf ?? false, }, interleaved: - model.interleaved ?? + (typeof model.interleaved === "string" ? { field: model.interleaved } : model.interleaved) ?? existingModel?.capabilities.interleaved ?? (!existingModel && apiNpm === "@ai-sdk/openai-compatible" && apiID.includes("deepseek") ? { field: "reasoning_content" } diff --git a/packages/opencode/test/provider/provider.test.ts b/packages/opencode/test/provider/provider.test.ts index 058bad23a1c6..44e82c7e1f44 100644 --- a/packages/opencode/test/provider/provider.test.ts +++ b/packages/opencode/test/provider/provider.test.ts @@ -252,6 +252,8 @@ it.instance( const provider = providers[ProviderV2.ID.make("custom-provider")] expect(provider.models["deepseek-r1"].capabilities.interleaved).toEqual({ field: "reasoning_content" }) expect(provider.models["deepseek-details"].capabilities.interleaved).toEqual({ field: "reasoning_details" }) + expect(provider.models["deepseek-text"].capabilities.interleaved).toEqual({ field: "reasoning_text" }) + expect(provider.models["custom-reasoning"].capabilities.interleaved).toEqual({ field: "vendor_reasoning" }) expect(provider.models["custom-model"].capabilities.interleaved).toBe(false) expect( providers[ProviderV2.ID.make("custom-anthropic-provider")].models["deepseek-r1"].capabilities.interleaved, @@ -267,6 +269,8 @@ it.instance( models: { "deepseek-r1": { name: "DeepSeek R1" }, "deepseek-details": { name: "DeepSeek Details", interleaved: { field: "reasoning_details" } }, + "deepseek-text": { name: "DeepSeek Text", interleaved: "reasoning_text" }, + "custom-reasoning": { name: "Custom Reasoning", interleaved: { field: "vendor_reasoning" } }, "custom-model": { name: "Custom Model" }, }, options: { apiKey: "custom-key" }, @@ -1462,6 +1466,7 @@ test("models.dev normalization fills required response fields", () => { id: "gpt-5.4", name: "GPT-5.4", family: "gpt", + interleaved: "reasoning_text", cost: { input: 2.5, output: 15 }, limit: { context: 1_050_000, input: 922_000, output: 128_000 }, }, @@ -1474,6 +1479,7 @@ test("models.dev normalization fills required response fields", () => { expect(model.capabilities.reasoning).toBe(false) expect(model.capabilities.attachment).toBe(false) expect(model.capabilities.toolcall).toBe(true) + expect(model.capabilities.interleaved).toEqual({ field: "reasoning_text" }) expect(model.release_date).toBe("") }) diff --git a/packages/sdk/js/src/v2/gen/types.gen.ts b/packages/sdk/js/src/v2/gen/types.gen.ts index 42d224780d32..90c91e9158cc 100644 --- a/packages/sdk/js/src/v2/gen/types.gen.ts +++ b/packages/sdk/js/src/v2/gen/types.gen.ts @@ -1768,9 +1768,13 @@ export type ProviderConfig = { temperature?: boolean tool_call?: boolean interleaved?: - | true + | boolean + | "reasoning" + | "reasoning_content" + | "reasoning_text" + | string | { - field: "reasoning" | "reasoning_content" | "reasoning_details" + field: "reasoning" | "reasoning_content" | "reasoning_text" | string } cost?: { input: number @@ -2057,7 +2061,7 @@ export type Model = { interleaved: | boolean | { - field: "reasoning" | "reasoning_content" | "reasoning_details" + field: "reasoning" | "reasoning_content" | "reasoning_text" | string } } cost: { diff --git a/packages/sdk/openapi.json b/packages/sdk/openapi.json index b300754bc859..afe14bb604cc 100644 --- a/packages/sdk/openapi.json +++ b/packages/sdk/openapi.json @@ -20820,15 +20820,28 @@ "interleaved": { "anyOf": [ { - "type": "boolean", - "enum": [true] + "type": "boolean" + }, + { + "type": "string", + "enum": ["reasoning", "reasoning_content", "reasoning_text"] + }, + { + "type": "string" }, { "type": "object", "properties": { "field": { - "type": "string", - "enum": ["reasoning", "reasoning_content", "reasoning_details"] + "anyOf": [ + { + "type": "string", + "enum": ["reasoning", "reasoning_content", "reasoning_text"] + }, + { + "type": "string" + } + ] } }, "required": ["field"], @@ -21641,8 +21654,15 @@ "type": "object", "properties": { "field": { - "type": "string", - "enum": ["reasoning", "reasoning_content", "reasoning_details"] + "anyOf": [ + { + "type": "string", + "enum": ["reasoning", "reasoning_content", "reasoning_text"] + }, + { + "type": "string" + } + ] } }, "required": ["field"], From c1ee3c6e344c1822814a1a18e99bc14cd1ea36f3 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:26:48 -0500 Subject: [PATCH 157/333] fix(opencode): stop MCP SSE error reconnect loops (#39697) --- packages/opencode/test/mcp/transport.test.ts | 38 +++++++++++++++++++ .../@modelcontextprotocol%2Fsdk@1.29.0.patch | 20 +++++++++- 2 files changed, 57 insertions(+), 1 deletion(-) create mode 100644 packages/opencode/test/mcp/transport.test.ts diff --git a/packages/opencode/test/mcp/transport.test.ts b/packages/opencode/test/mcp/transport.test.ts new file mode 100644 index 000000000000..f7a74d4d7299 --- /dev/null +++ b/packages/opencode/test/mcp/transport.test.ts @@ -0,0 +1,38 @@ +import { expect, test } from "bun:test" +import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js" + +test("does not reconnect an SSE stream after a JSON-RPC error response", async () => { + let requests = 0 + const transport = new StreamableHTTPClientTransport(new URL("http://mcp.invalid"), { + fetch: async () => { + requests += 1 + return new Response( + new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode("id: prime\nretry: 1\ndata:\n\n")) + controller.enqueue( + new TextEncoder().encode( + 'id: error\ndata: {"jsonrpc":"2.0","error":{"code":-32601,"message":"Method not found"},"id":1}\n\n', + ), + ) + controller.close() + }, + }), + { status: 200, headers: { "content-type": "text/event-stream" } }, + ) + }, + reconnectionOptions: { + initialReconnectionDelay: 1, + maxReconnectionDelay: 1, + reconnectionDelayGrowFactor: 1, + maxRetries: 2, + }, + }) + + await transport.start() + await transport.send({ jsonrpc: "2.0", method: "resources/list", id: 1 }) + await Bun.sleep(25) + await transport.close() + + expect(requests).toBe(1) +}) diff --git a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch index 13b8000a0139..3b70bea55868 100644 --- a/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch +++ b/patches/@modelcontextprotocol%2Fsdk@1.29.0.patch @@ -141,6 +141,15 @@ diff --git a/dist/cjs/client/streamableHttp.js b/dist/cjs/client/streamableHttp. index a29a7d3a0f14d9cd800ef5b296485237350c666f..c362ae5fe6c62c8c8eae7e2e61de1eedff5443c9 100644 --- a/dist/cjs/client/streamableHttp.js +++ b/dist/cjs/client/streamableHttp.js +@@ -204,7 +204,7 @@ class StreamableHTTPClientTransport { + if (!event.event || event.event === 'message') { + try { + const message = types_js_1.JSONRPCMessageSchema.parse(JSON.parse(event.data)); +- if ((0, types_js_1.isJSONRPCResultResponse)(message)) { ++ if ((0, types_js_1.isJSONRPCResultResponse)(message) || (0, types_js_1.isJSONRPCErrorResponse)(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { @@ -290,7 +290,38 @@ class StreamableHTTPClientTransport { this.onclose?.(); } @@ -518,10 +527,19 @@ index 624172aa24ae255a67c083f9c19053343e4a0581..ac75b14545fda44aff7ff4d97cc5da88 @@ -1,5 +1,5 @@ import { createFetchWithInit, normalizeHeaders } from '../shared/transport.js'; -import { isInitializedNotification, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; -+import { isInitializedNotification, isInitializeRequest, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; ++import { isInitializedNotification, isInitializeRequest, isJSONRPCErrorResponse, isJSONRPCRequest, isJSONRPCResultResponse, JSONRPCMessageSchema } from '../types.js'; import { auth, extractWWWAuthenticateParams, UnauthorizedError } from './auth.js'; import { EventSourceParserStream } from 'eventsource-parser/stream'; // Default reconnection options for StreamableHTTP connections +@@ -200,7 +200,7 @@ export class StreamableHTTPClientTransport { + if (!event.event || event.event === 'message') { + try { + const message = JSONRPCMessageSchema.parse(JSON.parse(event.data)); +- if (isJSONRPCResultResponse(message)) { ++ if (isJSONRPCResultResponse(message) || isJSONRPCErrorResponse(message)) { + // Mark that we received a response - no need to reconnect for this request + receivedResponse = true; + if (replayMessageId !== undefined) { @@ -286,7 +286,38 @@ export class StreamableHTTPClientTransport { this.onclose?.(); } From 3f239b3834e7ef30a454af6830053d2e82af009f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 30 Jul 2026 16:46:38 +0000 Subject: [PATCH 158/333] 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 a7b441535c10..b1975757b4f4 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-fK6zAHJC3ut/KUdfqLPxVMH7Z1yv65YZ7qcHF45kPas=", - "aarch64-linux": "sha256-CSWZY/vVUCKzlwfisQQHqHjawUwL0BRc8Y9o2y/QZYA=", - "aarch64-darwin": "sha256-IyFm5NbnU63BaOO/F4/v1exz3VbvkY96yjg9iun+O9Q=", - "x86_64-darwin": "sha256-XnXFoUxqXmEx7AKx5YhaJuLY2NYMtI0ICafaenU5L6k=" + "x86_64-linux": "sha256-AD5kt1o035hdvYPyrvjiz2mcsXFJ7UJVEmbE9AJFDFs=", + "aarch64-linux": "sha256-f1gVXUpIRWC/I8dxIigdGGvPsju04MXVjZhtq5EYL64=", + "aarch64-darwin": "sha256-c+JRpjs2gsgQ2czyM6bTbYEdMbX/erOSQdX6TdrI59k=", + "x86_64-darwin": "sha256-iB7l4PtQQ9Mx/XAdtyayiLe6pZKhuXB9lpmrbTr+uWc=" } } From ceb4890ca3651899dd3e2b1564168ab098ac540d Mon Sep 17 00:00:00 2001 From: Deven Navani <4031146+devennavani@users.noreply.github.com> Date: Thu, 30 Jul 2026 11:29:32 -0700 Subject: [PATCH 159/333] docs: add Modal provider setup (#39710) --- packages/web/src/content/docs/providers.mdx | 24 +++++++++++++++++++++ 1 file changed, 24 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index d3c541311ffe..7931d4e03f69 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1505,6 +1505,30 @@ To use Kimi K2 from Moonshot AI: --- +### Modal + +1. Create a [shared Endpoint](https://modal.com/endpoints) for the model you want to use. + +2. Create a [proxy token](https://modal.com/docs/guide/endpoints#proxy-tokens), then join its ID and secret with a period: + + ```txt + wk-.ws- + ``` + +3. Run the `/connect` command, search for **Modal**, and enter the combined proxy token. + + ```txt + /connect + ``` + +4. Run the `/models` command to select one of the endpoints in your Modal workspace. + + ```txt + /models + ``` + +--- + ### NVIDIA NVIDIA provides access to Nemotron models and many other open models through [build.nvidia.com](https://build.nvidia.com) for free. From e024e2ef92293a81a06b6cc418422c52207ce85d Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Fri, 31 Jul 2026 11:46:36 +1000 Subject: [PATCH 160/333] fix(app): align debug gutter text (#39782) --- packages/app/src/components/debug-bar.tsx | 54 +++++++++++++++-------- 1 file changed, 36 insertions(+), 18 deletions(-) diff --git a/packages/app/src/components/debug-bar.tsx b/packages/app/src/components/debug-bar.tsx index 67c9a899294e..adadeda3035e 100644 --- a/packages/app/src/components/debug-bar.tsx +++ b/packages/app/src/components/debug-bar.tsx @@ -66,7 +66,7 @@ function Cell(props: {
    - {props.label} -
    -
    - {props.value} +
    + {props.label} +
    +
    + {props.value} +
    ) @@ -116,15 +124,25 @@ function FocusCell(props: { active: boolean; inline?: boolean; onClick: () => vo aria-pressed={props.active} classList={{ "flex min-w-0 items-center font-mono uppercase hover:bg-surface-raised-base focus-visible:outline focus-visible:outline-2 focus-visible:outline-offset-[-2px] focus-visible:outline-border-focus": true, - "min-h-[20px] w-fit flex-row justify-start gap-1.5 rounded px-1.5 py-0.5 text-left": !!props.inline, + "min-h-[20px] w-fit justify-start rounded px-1.5 py-0.5 text-left": !!props.inline, "min-h-[42px] w-full flex-col justify-center rounded-[8px] px-0.5 py-1 text-center": !props.inline, "bg-surface-raised-base text-text-strong": props.active, }} onClick={props.onClick} > - FOCUS - - {props.active ? "ON" : "OFF"} + + FOCUS + + {props.active ? "ON" : "OFF"} + ) From 7ea343cabd057e29d4f34d07d7aeae832293bfae Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 31 Jul 2026 06:24:19 +0200 Subject: [PATCH 161/333] fix(app): prevent file tree tab clipping (#39770) --- packages/app/src/pages/session/session-side-panel.tsx | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/packages/app/src/pages/session/session-side-panel.tsx b/packages/app/src/pages/session/session-side-panel.tsx index 22c52e73feb8..6f074fa20304 100644 --- a/packages/app/src/pages/session/session-side-panel.tsx +++ b/packages/app/src/pages/session/session-side-panel.tsx @@ -59,6 +59,7 @@ import { SessionFileBrowserTab, type SessionFileBrowserState } from "@/pages/ses type ReviewDiff = FileDiffInfo | SnapshotFileDiff | VcsFileDiff type RenderDiff = FileDiffInfo | (SnapshotFileDiff & { file: string }) | VcsFileDiff +const FILE_TREE_WIDTH_MIN = 240 function renderDiff(value: ReviewDiff): value is RenderDiff { return typeof value.file === "string" @@ -104,13 +105,14 @@ export function SessionSidePanel(props: { }), ) const open = createMemo(() => reviewOpen() || fileOpen()) + const fileTreeWidth = createMemo(() => Math.max(FILE_TREE_WIDTH_MIN, layout.fileTree.width())) const reviewTab = createMemo(() => isDesktop()) const panelWidth = createMemo(() => { if (!open()) return "0px" if (reviewOpen()) return "auto" - return `${layout.fileTree.width()}px` + return `${fileTreeWidth()}px` }) - const treeWidth = createMemo(() => (fileOpen() ? `${layout.fileTree.width()}px` : "0px")) + const treeWidth = createMemo(() => (fileOpen() ? `${fileTreeWidth()}px` : "0px")) const diffs = createMemo(() => props.diffs().filter(renderDiff)) const diffFiles = createMemo(() => diffs().map((d) => d.file)) @@ -845,8 +847,8 @@ export function SessionSidePanel(props: { { props.size.touch() From d3f30df1806ea9f95190948c59f6e8b4c97301d0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 31 Jul 2026 04:38:34 +0000 Subject: [PATCH 162/333] fix(app): open legacy picker at home (#39804) Co-authored-by: Brendan Allan --- packages/app/src/components/dialog-select-directory.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/app/src/components/dialog-select-directory.tsx b/packages/app/src/components/dialog-select-directory.tsx index 2a6c877df1d2..96bcedb9db59 100644 --- a/packages/app/src/components/dialog-select-directory.tsx +++ b/packages/app/src/components/dialog-select-directory.tsx @@ -57,9 +57,9 @@ export function DialogSelectDirectory(props: DialogSelectDirectoryProps) { const [filter, setFilter] = createSignal("") let list: ListRef | undefined - const missingBase = createMemo(() => !(sync.data.path.home || sync.data.path.directory)) + const missingHome = createMemo(() => !sync.data.path.home) const [fallbackPath] = createResource( - () => (missingBase() ? true : undefined), + () => (missingHome() ? true : undefined), async (): Promise => { if ((await sdk.protocol) !== "v1") return return sdk.client.path From db4dbaa2894a1a98e90e814416eb9f7c35711819 Mon Sep 17 00:00:00 2001 From: OpeOginni <107570612+OpeOginni@users.noreply.github.com> Date: Fri, 31 Jul 2026 07:14:09 +0200 Subject: [PATCH 163/333] fix(app): prevent stale session tab reads (#39767) --- .../app/src/components/session/session-header.tsx | 12 ++++++------ packages/app/src/components/titlebar-tab-nav.tsx | 5 +++-- .../app/src/pages/new-session/new-session-view.tsx | 4 ++-- 3 files changed, 11 insertions(+), 10 deletions(-) diff --git a/packages/app/src/components/session/session-header.tsx b/packages/app/src/components/session/session-header.tsx index da1770cd130e..dc6cdaa6f3fd 100644 --- a/packages/app/src/components/session/session-header.tsx +++ b/packages/app/src/components/session/session-header.tsx @@ -289,9 +289,9 @@ export function SessionHeader() { return ( <> - + {(mount) => ( - +
    - + {(keybind) => ( - {keybind()} + {keybind} )} @@ -319,9 +319,9 @@ export function SessionHeader() { )} - + {(mount) => ( - +
    @@ -886,9 +886,9 @@ function ProviderConnection(props: {
    {language.t("provider.connect.opencodeZen.line2")}
    {language.t("provider.connect.opencodeZen.visit.prefix")} - + {language.t("provider.connect.opencodeZen.visit.link")} - + {language.t("provider.connect.opencodeZen.visit.suffix")}
    @@ -967,9 +967,9 @@ function ProviderConnection(props: {
    {language.t("provider.connect.oauth.code.visit.prefix")} - + {language.t("provider.connect.oauth.code.visit.link")} - + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
    @@ -1006,7 +1006,9 @@ function ProviderConnection(props: {
    {language.t("provider.connect.oauth.code.visit.prefix")} - {language.t("provider.connect.oauth.code.visit.link")} + + {language.t("provider.connect.oauth.code.visit.link")} + {language.t("provider.connect.oauth.code.visit.suffix", { provider: provider().name })}
    @@ -1077,7 +1079,9 @@ function ProviderConnection(props: {
    {language.t("provider.connect.oauth.auto.visit.prefix")} - {language.t("provider.connect.oauth.auto.visit.link")} + + {language.t("provider.connect.oauth.auto.visit.link")} + {language.t("provider.connect.oauth.auto.visit.suffix", { provider: provider().name })}

    {language.t("provider.custom.description.prefix")} - + {language.t("provider.custom.description.link")} - + {language.t("provider.custom.description.suffix")}

    diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index e428d4c2bbbb..bf5da751e225 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -17,7 +17,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) { const platform = usePlatform() const runAction = () => { - if (props.link) platform.openLink(props.link) + if (props.link) platform.openExternal(props.link) props.onClose?.() dialog.close() } diff --git a/packages/app/src/components/external-link.tsx b/packages/app/src/components/external-link.tsx new file mode 100644 index 000000000000..133e752eab3f --- /dev/null +++ b/packages/app/src/components/external-link.tsx @@ -0,0 +1,21 @@ +import { ComponentProps, splitProps } from "solid-js" + +export interface ExternalLinkProps extends Omit, "href"> { + href: string +} + +export function ExternalLink(props: ExternalLinkProps) { + const [local, rest] = splitProps(props, ["href", "children", "class", "target", "rel"]) + + return ( + + {local.children} + + ) +} diff --git a/packages/app/src/components/link.tsx b/packages/app/src/components/link.tsx deleted file mode 100644 index 85f7efc539eb..000000000000 --- a/packages/app/src/components/link.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { ComponentProps, splitProps } from "solid-js" -import { usePlatform } from "@/context/platform" - -export interface LinkProps extends Omit, "href"> { - href: string -} - -export function Link(props: LinkProps) { - const platform = usePlatform() - const [local, rest] = splitProps(props, ["href", "children", "class"]) - - return ( - { - if (!local.href) return - event.preventDefault() - platform.openLink(local.href) - }} - {...rest} - > - {local.children} - - ) -} diff --git a/packages/app/src/components/settings-general.tsx b/packages/app/src/components/settings-general.tsx index a20e66de3ee3..8adaa1f0fdf9 100644 --- a/packages/app/src/components/settings-general.tsx +++ b/packages/app/src/components/settings-general.tsx @@ -29,7 +29,7 @@ import { } from "@/context/settings" import { decode64 } from "@/utils/base64" import { playSoundById, SOUND_OPTIONS } from "@/utils/sound" -import { Link } from "./link" +import { ExternalLink } from "./external-link" import { SettingsList } from "./settings-list" let demoSoundState = { @@ -482,7 +482,7 @@ export const SettingsGeneral: Component = () => { description={ <> {language.t("settings.general.row.theme.description")}{" "} - {language.t("common.learnMore")} + {language.t("common.learnMore")} } > diff --git a/packages/app/src/components/settings-v2/general.tsx b/packages/app/src/components/settings-v2/general.tsx index e50bd316c9ab..b41da107ed6e 100644 --- a/packages/app/src/components/settings-v2/general.tsx +++ b/packages/app/src/components/settings-v2/general.tsx @@ -9,7 +9,7 @@ import { useLanguage } from "@/context/language" import { usePlatform } from "@/context/platform" import { useUpdaterAction } from "../updater-action" import { useSettings } from "@/context/settings" -import { Link } from "../link" +import { ExternalLink } from "../external-link" import { SettingsListV2 } from "./parts/list" import { SettingsRowV2 } from "./parts/row" import { LayoutRetirementNotice, LayoutTransitionToggle } from "./interface-transition" @@ -150,9 +150,9 @@ const AppearanceSection: Component<{ controller: AppearanceSettingsController }> description={ <> {language.t("settings.general.row.theme.description")}{" "} - + {language.t("common.learnMore")} - + } > diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index 65035034760e..7d25e7c64901 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -382,7 +382,11 @@ export const Terminal = (props: TerminalProps) => { event.preventDefault() event.stopImmediatePropagation() - platform.openLink(text) + if (URL.canParse(text) && new URL(text).protocol === "file:" && platform.openLocalFile) { + platform.openLocalFile(text) + return + } + platform.openExternal(text) } onMount(() => { diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index 017ac4cf8dea..e41c2a584a76 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -43,7 +43,7 @@ export function WindowsAppMenu(props: { runAction(entry.action) return } - if (entry.href) props.platform.openLink(entry.href) + if (entry.href) props.platform.openExternal(entry.href) } return ( diff --git a/packages/app/src/context/notification.tsx b/packages/app/src/context/notification.tsx index 9bfa13d854db..fb562a0bf5c7 100644 --- a/packages/app/src/context/notification.tsx +++ b/packages/app/src/context/notification.tsx @@ -1,6 +1,6 @@ import { createStore, reconcile } from "solid-js/store" import { type Accessor, batch, createEffect, createMemo, createRoot, getOwner, onCleanup } from "solid-js" -import { useParams, useSearchParams } from "@solidjs/router" +import { useNavigate, useParams, useSearchParams } from "@solidjs/router" import { createSimpleContext } from "@opencode-ai/ui/context" import type { ServerSDK } from "./server-sdk" import type { ServerSync } from "./server-sync" @@ -118,6 +118,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi const global = useGlobal() const server = useServer() const tabs = useTabs() + const navigate = useNavigate() const platform = usePlatform() const settings = useSettings() const language = useLanguage() @@ -153,6 +154,7 @@ export const { use: useNotification, provider: NotificationProvider } = createSi platform, settings, language, + navigate, }), }), owner ?? undefined, @@ -217,6 +219,7 @@ function createServerNotificationState(input: { platform: ReturnType settings: ReturnType language: ReturnType + navigate: (href: string) => void }) { const serverSDK = () => input.sdk const serverSync = () => input.sync @@ -353,7 +356,11 @@ function createServerNotificationState(input: { const href = `/${base64Encode(directory)}/session/${sessionID}` if (settings.notifications.agent()) { - void platform.notify(language.t("notification.session.responseReady.title"), session.title ?? sessionID, href) + void platform.notify( + language.t("notification.session.responseReady.title"), + session.title ?? sessionID, + () => input.navigate(href), + ) } }) } @@ -386,7 +393,7 @@ function createServerNotificationState(input: { (typeof error === "string" ? error : language.t("notification.session.error.fallbackDescription")) const href = sessionID ? `/${base64Encode(directory)}/session/${sessionID}` : `/${base64Encode(directory)}` if (settings.notifications.errors()) { - void platform.notify(language.t("notification.session.error.title"), description, href) + void platform.notify(language.t("notification.session.error.title"), description, () => input.navigate(href)) } }) } diff --git a/packages/app/src/context/platform.tsx b/packages/app/src/context/platform.tsx index 4c374961a3f6..08686361c546 100644 --- a/packages/app/src/context/platform.tsx +++ b/packages/app/src/context/platform.tsx @@ -31,26 +31,23 @@ type PlatformBase = { /** App version */ version?: string - /** Open a URL in the default browser */ - openLink(url: string): void + /** Open a web or mail URL in the default system application */ + openExternal(url: string): void /** Open a local path in a local app (desktop only) */ openPath?(path: string, app?: string): Promise + /** Open a local file URL in its default app (desktop only) */ + openLocalFile?(url: string): void + /** Reveal a local path in the system file manager; false when the path does not exist (desktop only) */ revealPath?(path: string): Promise /** Restart the app */ restart(): Promise - /** Navigate back in history */ - back(): void - - /** Navigate forward in history */ - forward(): void - - /** Send a system notification (optional deep link) */ - notify(title: string, description?: string, href?: string): Promise + /** Send a system notification */ + notify(title: string, description?: string, onClick?: () => void): Promise /** Open a native attachment picker and read selected files sequentially (desktop only) */ openAttachmentPickerDialog?( @@ -91,9 +88,6 @@ type PlatformBase = { /** Set the preferred display backend (desktop only) */ setDisplayBackend?(backend: DisplayBackend): Promise - /** Parse markdown to HTML using native parser (desktop only, returns unprocessed code blocks) */ - parseMarkdown?(markdown: string): Promise - /** Webview zoom level (desktop only) */ webviewZoom?: Accessor diff --git a/packages/app/src/entry.tsx b/packages/app/src/entry.tsx index c41c1bb4b63e..6823d1c6d729 100644 --- a/packages/app/src/entry.tsx +++ b/packages/app/src/entry.tsx @@ -6,7 +6,6 @@ import { AppBaseProviders, AppInterface } from "@/app" import { type Platform, PlatformProvider } from "@/context/platform" import { dict as en } from "@/i18n/en" import { dict as zh } from "@/i18n/zh" -import { handleNotificationClick } from "@/utils/notification-click" import { authFromToken } from "@/utils/server" import pkg from "../package.json" import { ServerConnection } from "./context/server" @@ -54,7 +53,7 @@ const setStorage = (key: string, value: string | null) => { const readDefaultServerUrl = () => getStorage(DEFAULT_SERVER_URL_KEY) const writeDefaultServerUrl = (url: string | null) => setStorage(DEFAULT_SERVER_URL_KEY, url) -const notify: Platform["notify"] = async (title, description, href) => { +const notify: Platform["notify"] = async (title, description, onClick) => { if (!("Notification" in window)) return const permission = @@ -73,21 +72,17 @@ const notify: Platform["notify"] = async (title, description, href) => { }) notification.onclick = () => { - handleNotificationClick(href) + window.focus() + onClick?.() notification.close() } } -const openLink: Platform["openLink"] = (url) => { - window.open(url, "_blank") -} - -const back: Platform["back"] = () => { - window.history.back() -} - -const forward: Platform["forward"] = () => { - window.history.forward() +const openExternal: Platform["openExternal"] = (value) => { + if (!URL.canParse(value)) return + const url = new URL(value) + if (url.protocol !== "http:" && url.protocol !== "https:" && url.protocol !== "mailto:") return + window.open(url.href, "_blank", "noopener,noreferrer") } const restart: Platform["restart"] = async () => { @@ -122,9 +117,7 @@ const clearAuthToken = () => { const platform: Platform = { platform: "web", version: pkg.version, - openLink, - back, - forward, + openExternal, restart, notify, getDefaultServer: async () => { diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index d18e05fc9c2f..2267ebf08168 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -27,4 +27,3 @@ export { type WslServersState, } from "./wsl/types" export { ServerConnection } from "./context/server" -export { handleNotificationClick } from "./utils/notification-click" diff --git a/packages/app/src/pages/directory-layout.tsx b/packages/app/src/pages/directory-layout.tsx index 77c120e57b68..a0b357639655 100644 --- a/packages/app/src/pages/directory-layout.tsx +++ b/packages/app/src/pages/directory-layout.tsx @@ -63,6 +63,7 @@ export function DirectoryDataProvider( navigate(href(sessionID))} onSessionHref={href} > diff --git a/packages/app/src/pages/error.tsx b/packages/app/src/pages/error.tsx index 86a997b39d7b..2fc60954129d 100644 --- a/packages/app/src/pages/error.tsx +++ b/packages/app/src/pages/error.tsx @@ -354,7 +354,7 @@ export const ErrorPage: Component = (props) => { @@ -89,7 +91,7 @@ export function TabsInfoPopup() { type="button" size="small" variant="neutral" - aria-label="Close" + aria-label={language.t("common.close")} icon={} class="absolute top-[10px] left-[-36px]" /> @@ -102,7 +104,7 @@ export function TabsInfoPopup() { }} >

    - July 14 + {language.t("help.tabs.date")}

    } />

    - Introducing Tabs + {language.t("help.tabs.title")}

    -

    OpenCode Desktop is now built around tabs.

    +

    {language.t("help.tabs.introduction")}

    -

    - Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when - you're starting something new, and close it when you're done. -

    -

    - Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something - memorable if you plan to keep them around. -

    -

    - You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab. -

    +

    {language.t("help.tabs.sessions")}

    +

    {language.t("help.tabs.organize")}

    +

    {language.t("help.tabs.home")}

    -

    When you reopen the app, your tabs are still open.

    -

    - The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using - the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout - will become permanent in a few weeks. -

    +

    {language.t("help.tabs.persistence")}

    +

    {language.t("help.tabs.worktrees")}

    diff --git a/packages/app/src/components/prompt-input-v2.tsx b/packages/app/src/components/prompt-input-v2.tsx index 96b99aaf4a2a..481b7922cf36 100644 --- a/packages/app/src/components/prompt-input-v2.tsx +++ b/packages/app/src/components/prompt-input-v2.tsx @@ -137,7 +137,10 @@ export function usePromptInputV2Controller(props: PromptInputV2ControllerProps): t: (key, params) => language.t(key as Parameters[0], params as never), }), ) - const designPlaceholder = () => promptDesignPlaceholder(mode(), placeholder()) + const designPlaceholder = () => + promptDesignPlaceholder(mode(), placeholder(), (key, params) => + language.t(key as Parameters[0], params as never), + ) const historyComments = () => { const byID = new Map(comments.all().map((item) => [`${item.file}\n${item.id}`, item] as const)) diff --git a/packages/app/src/components/prompt-input.tsx b/packages/app/src/components/prompt-input.tsx index 08c1ee348913..41cf7d30ca34 100644 --- a/packages/app/src/components/prompt-input.tsx +++ b/packages/app/src/components/prompt-input.tsx @@ -77,7 +77,7 @@ import { PromptPopover, type AtOption, type SlashCommand } from "./prompt-input/ import { PromptContextItems } from "./prompt-input/context-items" import { PromptImageAttachments } from "./prompt-input/image-attachments" import { PromptDragOverlay } from "./prompt-input/drag-overlay" -import { promptDesignPlaceholder, promptPlaceholder } from "./prompt-input/placeholder" +import { promptPlaceholder } from "./prompt-input/placeholder" import { createPromptInputTransientState } from "./prompt-input/transient-state" import { showToast } from "@/utils/toast" import { ImagePreview } from "@opencode-ai/ui/image-preview" @@ -1493,6 +1493,7 @@ export const PromptInput: Component = (props) => { } onRemove={removeAttachment} removeLabel={language.t("prompt.attachment.remove")} + fileLabel={language.t("ui.common.file")} newLayoutDesigns={false} />
    void onRemove: (id: string) => void removeLabel: string + fileLabel: string newLayoutDesigns: boolean comments?: PromptCommentItem[] commentActive?: (item: PromptCommentItem) => boolean @@ -94,7 +95,7 @@ export const PromptImageAttachments: Component = (p } > - {typeLabel(attachment.filename, attachment.mime)} + {typeLabel(attachment.filename, attachment.mime, props.fileLabel)} } diff --git a/packages/app/src/components/prompt-input/placeholder.test.ts b/packages/app/src/components/prompt-input/placeholder.test.ts index d4caead0d2e4..b8f0537d0a07 100644 --- a/packages/app/src/components/prompt-input/placeholder.test.ts +++ b/packages/app/src/components/prompt-input/placeholder.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from "bun:test" -import { promptPlaceholder } from "./placeholder" +import { promptDesignPlaceholder, promptPlaceholder } from "./placeholder" describe("promptPlaceholder", () => { const t = (key: string, params?: Record) => `${key}${params?.example ? `:${params.example}` : ""}` @@ -46,3 +46,20 @@ describe("promptPlaceholder", () => { expect(value).toBe("prompt.placeholder.simple") }) }) + +describe("promptDesignPlaceholder", () => { + const t = (key: string, params?: Record) => { + if (key !== "ui.promptInput.placeholder.normal") return key + return `Ask anything, ${params?.slash} for commands, ${params?.at} for context...` + } + + test("composes the design placeholder from localized fragments", () => { + expect(promptDesignPlaceholder("normal", "fallback", t)).toBe( + "Ask anything, / for commands, @ for context...", + ) + }) + + test("preserves the shell placeholder", () => { + expect(promptDesignPlaceholder("shell", "Enter shell command...", t)).toBe("Enter shell command...") + }) +}) diff --git a/packages/app/src/components/prompt-input/placeholder.ts b/packages/app/src/components/prompt-input/placeholder.ts index cbb4c45b7e35..9bd4417f4982 100644 --- a/packages/app/src/components/prompt-input/placeholder.ts +++ b/packages/app/src/components/prompt-input/placeholder.ts @@ -14,7 +14,11 @@ export function promptPlaceholder(input: PromptPlaceholderInput) { return input.t("prompt.placeholder.normal", { example: input.example }) } -export function promptDesignPlaceholder(mode: PromptPlaceholderInput["mode"], placeholder: string) { +export function promptDesignPlaceholder( + mode: PromptPlaceholderInput["mode"], + placeholder: string, + t: PromptPlaceholderInput["t"], +) { if (mode === "shell") return placeholder - return "Ask anything, / for commands, @ for context..." + return t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }) } diff --git a/packages/app/src/components/terminal.tsx b/packages/app/src/components/terminal.tsx index d0887470155e..0ca42b35bb1b 100644 --- a/packages/app/src/components/terminal.tsx +++ b/packages/app/src/components/terminal.tsx @@ -573,9 +573,8 @@ export const Terminal = (props: TerminalProps) => { 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}`) + if (result.response.status === 403) throw new Error(language.t("terminal.connectTicket.csrfError")) + throw new Error(language.t("terminal.connectTicket.statusError", { status: result.response.status })) } // return sdk() // .api.pty.connectToken({ diff --git a/packages/app/src/components/titlebar-tab-nav.tsx b/packages/app/src/components/titlebar-tab-nav.tsx index 5003d0ed303b..65493c54dff5 100644 --- a/packages/app/src/components/titlebar-tab-nav.tsx +++ b/packages/app/src/components/titlebar-tab-nav.tsx @@ -5,6 +5,7 @@ import { createMutation } from "@tanstack/solid-query" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useGlobal } from "@/context/global" +import { useLanguage } from "@/context/language" import { ServerConnection, serverName } from "@/context/server" import { displayName, projectForSession } from "@/pages/layout/helpers" import { SessionTabAvatar } from "@/pages/layout/session-tab-avatar" @@ -323,6 +324,7 @@ export function DraftTabItem(props: { pressed?: boolean hidden?: boolean }) { + const language = useLanguage() const closeTab = (event: MouseEvent) => { event.preventDefault() event.stopPropagation() @@ -397,7 +399,7 @@ export function DraftTabItem(props: { class="hover-reveal relative z-10 group-hover:opacity-100 group-data-[active=true]:opacity-100 group-data-[editing=true]:opacity-100" onClick={closeTab} icon={} - aria-label="Close tab" + aria-label={language.t("common.closeTab")} />
    diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index 649a8f57773c..8bc9d01eb52e 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -113,9 +113,9 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl return { visible: version !== undefined || installing, installing, - label: "Update", + label: language.t("titlebar.update"), ariaLabel: language.t("toast.update.action.installRestart"), - title: version ? `Update ${version}` : undefined, + title: version ? language.t("titlebar.updateVersion", { version }) : undefined, onInstall: () => props.update?.install(), } }) diff --git a/packages/app/src/components/windows-app-menu.tsx b/packages/app/src/components/windows-app-menu.tsx index e41c2a584a76..b6088c57f43e 100644 --- a/packages/app/src/components/windows-app-menu.tsx +++ b/packages/app/src/components/windows-app-menu.tsx @@ -8,6 +8,7 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { useCommand } from "@/context/command" import { DESKTOP_MENU, desktopMenuVisible, type DesktopMenuAction, type DesktopMenuEntry } from "@/desktop-menu" import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" export function WindowsAppMenu(props: { command: ReturnType @@ -15,6 +16,7 @@ export function WindowsAppMenu(props: { variant?: "legacy" | "v2" }) { let lastFocused: HTMLElement | undefined + const language = useLanguage() const rememberFocus = () => { const active = document.activeElement @@ -58,7 +60,7 @@ export function WindowsAppMenu(props: { variant="ghost-muted" size="large" icon={} - aria-label="OpenCode menu" + aria-label={language.t("desktop.menu.ariaLabel")} onPointerDown={rememberFocus} onKeyDown={rememberFocus} /> @@ -69,7 +71,7 @@ export function WindowsAppMenu(props: { icon="menu" variant="ghost" class="titlebar-icon rounded-md shrink-0" - aria-label="OpenCode menu" + aria-label={language.t("desktop.menu.ariaLabel")} onPointerDown={rememberFocus} onKeyDown={rememberFocus} /> @@ -79,7 +81,7 @@ export function WindowsAppMenu(props: { OpenCode {DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "windows")).map((menu) => ( - + {menu.items ?.filter((entry) => desktopMenuVisible(entry, "windows")) .map((entry) => @@ -87,7 +89,7 @@ export function WindowsAppMenu(props: { ) : ( runEntry(entry)} diff --git a/packages/app/src/constants/file-picker.ts b/packages/app/src/constants/file-picker.ts index c661bc8f36b3..1e029b551cc6 100644 --- a/packages/app/src/constants/file-picker.ts +++ b/packages/app/src/constants/file-picker.ts @@ -83,7 +83,7 @@ export const ACCEPTED_FILE_EXTENSIONS = Array.from( ), ).sort() -export function filePickerFilters(ext?: string[]) { +export function filePickerFilters(name: string, ext?: string[]) { if (!ext || ext.length === 0) return undefined - return [{ name: "Files", extensions: ext }] + return [{ name, extensions: ext }] } diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index f1c9ddcfd3aa..adab9ddb72fa 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -1,40 +1,20 @@ import * as i18n from "@solid-primitives/i18n" import { createEffect, createMemo, createResource } from "solid-js" import { createStore } from "solid-js/store" -import { createSimpleContext, pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context" +import { createSimpleContext } from "@opencode-ai/ui/context" +import { pluralCategory, type UiI18nPluralKey } from "@opencode-ai/ui/context/i18n" import { Persist, persisted } from "@/utils/persist" import { dict as en } from "@/i18n/en" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" +import { + createDesktopNativeBundle, + DESKTOP_NATIVE_ENGLISH, + DESKTOP_NATIVE_LOCALES, + type DesktopNativeBundle, + type DesktopNativeLocale, +} from "@/i18n/desktop-native" -export type Locale = - | "en" - | "zh" - | "zht" - | "ko" - | "de" - | "es" - | "fr" - | "da" - | "ja" - | "pl" - | "ru" - | "uk" - | "ar" - | "no" - | "br" - | "th" - | "bs" - | "tr" - | "hi" - | "nl" - | "id" - | "vi" - | "it" - | "ur" - | "pa" - | "az" - | "fi" - | "sv" +export type Locale = DesktopNativeLocale const RTL_LOCALES: ReadonlySet = new Set(["ar", "ur", "pa"]) @@ -51,36 +31,7 @@ function cookie(locale: Locale) { return `oc_locale=${encodeURIComponent(locale)}; Path=/; Max-Age=31536000; SameSite=Lax` } -const LOCALES: readonly Locale[] = [ - "en", - "zh", - "zht", - "ko", - "de", - "es", - "fr", - "da", - "ja", - "pl", - "ru", - "uk", - "bs", - "ar", - "no", - "br", - "th", - "tr", - "hi", - "nl", - "id", - "vi", - "it", - "ur", - "pa", - "az", - "fi", - "sv", -] +const LOCALES: readonly Locale[] = DESKTOP_NATIVE_LOCALES const INTL: Record = { en: "en", @@ -280,7 +231,7 @@ if (warm !== "en") void loadDict(warm) export const { use: useLanguage, provider: LanguageProvider } = createSimpleContext({ name: "Language", gate: false, - init: (props: { locale?: Locale }) => { + init: (props: { locale?: Locale; onNativeTranslations?: (bundle: DesktopNativeBundle) => void }) => { const initial = props.locale ?? readStoredLocale() ?? detectLocale() const [store, setStore, _, ready] = persisted( Persist.global("language", ["language.v1"]), @@ -323,6 +274,15 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont document.cookie = cookie(value) }) + createEffect(() => { + if (!props.onNativeTranslations || dict.loading) return + const current = dict() + if (!current) return + props.onNativeTranslations( + createDesktopNativeBundle(locale(), (key) => current[key] ?? DESKTOP_NATIVE_ENGLISH[key]), + ) + }) + return { ready, locale, diff --git a/packages/app/src/context/prompt-state.ts b/packages/app/src/context/prompt-state.ts index ea639a5bddf5..65f47257810d 100644 --- a/packages/app/src/context/prompt-state.ts +++ b/packages/app/src/context/prompt-state.ts @@ -6,6 +6,7 @@ import type { FileSelection } from "@/context/file" import { Persist, persisted } from "@/utils/persist" import type { ServerScope } from "@/utils/server-scope" import type { BlobReference } from "@/utils/draft-store" +import type { Platform } from "@/context/platform" interface PartBase { content: string @@ -236,13 +237,18 @@ function createPromptStateValue(store: PromptStore, setStore: SetStoreFunction

    , initial?: InitialPrompt) { - const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial))) +function createPersistedPrompt(target: ReturnType, initial?: InitialPrompt, platform?: Platform) { + const [store, setStore, _, ready] = persisted(target, createStore(promptStore(initial)), platform) return { ready, ...createPromptStateValue(store, setStore) } } -export function createPromptSession(serverScope: ServerScope, scope: PromptScope, initial?: InitialPrompt) { - return createPersistedPrompt(promptTarget(serverScope, scope), initial) +export function createPromptSession( + serverScope: ServerScope, + scope: PromptScope, + initial?: InitialPrompt, + platform?: Platform, +) { + return createPersistedPrompt(promptTarget(serverScope, scope), initial, platform) } export function createDraftPromptSession(draftID: string, initial?: InitialPrompt) { diff --git a/packages/app/src/desktop-menu.test.ts b/packages/app/src/desktop-menu.test.ts index c7e36a7cb73b..79dbe89adbd8 100644 --- a/packages/app/src/desktop-menu.test.ts +++ b/packages/app/src/desktop-menu.test.ts @@ -4,10 +4,20 @@ import { DESKTOP_MENU } from "./desktop-menu" describe("desktop menu", () => { test("exports logs through the desktop command registry", () => { const items = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( - (item) => item.type === "item" && item.label === "Export Logs...", + (item) => item.type === "item" && item.labelKey === "desktop.menu.exportLogs", ) expect(items).toHaveLength(2) expect(items.every((item) => item.type === "item" && item.command === "logs.export" && !item.action)).toBe(true) }) + + test("provides translated labels for role-backed entries", () => { + const windowMenu = DESKTOP_MENU.find((menu) => menu.role === "windowMenu") + const roleItems = DESKTOP_MENU.flatMap((menu) => menu.items ?? []).filter( + (item) => item.type === "item" && item.role && item.labelKey, + ) + + expect(windowMenu?.labelKey).toBe("desktop.menu.window") + expect(roleItems.length).toBeGreaterThan(0) + }) }) diff --git a/packages/app/src/desktop-menu.ts b/packages/app/src/desktop-menu.ts index d3902cd2b245..d7c421e94de0 100644 --- a/packages/app/src/desktop-menu.ts +++ b/packages/app/src/desktop-menu.ts @@ -1,3 +1,5 @@ +import type { DesktopNativeKey } from "./i18n/desktop-native" + export type DesktopMenuPlatform = "macos" | "windows" export type DesktopMenuAction = @@ -44,7 +46,7 @@ export type DesktopMenuRole = export type DesktopMenuItem = { type: "item" - label?: string + labelKey?: DesktopNativeKey command?: string action?: DesktopMenuAction role?: DesktopMenuRole @@ -63,7 +65,7 @@ export type DesktopMenuEntry = DesktopMenuItem | DesktopMenuSeparator export type DesktopMenu = { id: string - label: string + labelKey: DesktopNativeKey role?: DesktopMenuRole items?: DesktopMenuEntry[] platforms?: DesktopMenuPlatform[] @@ -72,15 +74,20 @@ export type DesktopMenu = { export const DESKTOP_MENU: DesktopMenu[] = [ { id: "app", - label: "OpenCode", + labelKey: "desktop.menu.app", platforms: ["macos"], items: [ { type: "item", role: "about" }, - { type: "item", label: "Check for Updates...", action: "app.checkForUpdates", enabled: "updater" }, - { type: "item", label: "Settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, - { type: "item", label: "Reload Webview", action: "view.reload" }, - { type: "item", label: "Restart", action: "app.relaunch" }, - { type: "item", label: "Export Logs...", command: "logs.export" }, + { + type: "item", + labelKey: "desktop.menu.checkForUpdates", + action: "app.checkForUpdates", + enabled: "updater", + }, + { type: "item", labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { macos: "Cmd+," } }, + { type: "item", labelKey: "desktop.menu.reloadWebview", action: "view.reload" }, + { type: "item", labelKey: "desktop.menu.restart", action: "app.relaunch" }, + { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" }, { type: "separator" }, { type: "item", role: "hide" }, { type: "item", role: "hideOthers" }, @@ -91,46 +98,63 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { id: "file", - label: "File", + labelKey: "desktop.menu.file", items: [ { type: "item", - label: "New Session", + labelKey: "desktop.menu.newSession", command: "session.new", accelerator: { macos: "Shift+Cmd+S" }, }, - { type: "item", label: "Open Project...", command: "project.open", accelerator: { macos: "Cmd+O" } }, { type: "item", - label: "Settings", + labelKey: "desktop.menu.openProject", + command: "project.open", + accelerator: { macos: "Cmd+O" }, + }, + { + type: "item", + labelKey: "desktop.menu.settings", command: "settings.open", accelerator: { windows: "Ctrl+," }, platforms: ["windows"], }, { type: "item", - label: "New Window", + labelKey: "desktop.menu.newWindow", action: "window.new", accelerator: { macos: "Cmd+Shift+N", windows: "Ctrl+Shift+N" }, }, { type: "separator" }, - { type: "item", label: "Close Window", action: "window.close", role: "close" }, + { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close", role: "close" }, ], }, { id: "edit", - label: "Edit", + labelKey: "desktop.menu.edit", items: [ - { type: "item", label: "Undo", action: "edit.undo", role: "undo", accelerator: { windows: "Ctrl+Z" } }, - { type: "item", label: "Redo", action: "edit.redo", role: "redo", accelerator: { windows: "Ctrl+Y" } }, + { + type: "item", + labelKey: "desktop.menu.undo", + action: "edit.undo", + role: "undo", + accelerator: { windows: "Ctrl+Z" }, + }, + { + type: "item", + labelKey: "desktop.menu.redo", + action: "edit.redo", + role: "redo", + accelerator: { windows: "Ctrl+Y" }, + }, { type: "separator" }, - { type: "item", label: "Cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } }, - { type: "item", label: "Copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } }, - { type: "item", label: "Paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } }, - { type: "item", label: "Delete", action: "edit.delete" }, + { type: "item", labelKey: "desktop.menu.cut", action: "edit.cut", role: "cut", accelerator: { windows: "Ctrl+X" } }, + { type: "item", labelKey: "desktop.menu.copy", action: "edit.copy", role: "copy", accelerator: { windows: "Ctrl+C" } }, + { type: "item", labelKey: "desktop.menu.paste", action: "edit.paste", role: "paste", accelerator: { windows: "Ctrl+V" } }, + { type: "item", labelKey: "desktop.menu.delete", action: "edit.delete" }, { type: "item", - label: "Select All", + labelKey: "desktop.menu.selectAll", action: "edit.selectAll", role: "selectAll", accelerator: { windows: "Ctrl+A" }, @@ -139,47 +163,47 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { id: "view", - label: "View", + labelKey: "desktop.menu.view", items: [ - { type: "item", label: "Toggle Sidebar", command: "sidebar.toggle" }, - { type: "item", label: "Toggle Terminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, - { type: "item", label: "Toggle File Tree", command: "fileTree.toggle" }, + { type: "item", labelKey: "desktop.menu.toggleSidebar", command: "sidebar.toggle" }, + { type: "item", labelKey: "desktop.menu.toggleTerminal", command: "terminal.toggle", accelerator: { macos: "Ctrl+`" } }, + { type: "item", labelKey: "desktop.menu.toggleFileTree", command: "fileTree.toggle" }, { type: "separator" }, - { type: "item", label: "Reload", action: "view.reload", role: "reload" }, - { type: "item", label: "Toggle Developer Tools", action: "view.toggleDevTools", role: "toggleDevTools" }, + { type: "item", labelKey: "desktop.menu.reload", action: "view.reload", role: "reload" }, + { type: "item", labelKey: "desktop.menu.toggleDeveloperTools", action: "view.toggleDevTools", role: "toggleDevTools" }, { type: "separator" }, { type: "item", - label: "Actual Size", + labelKey: "desktop.menu.actualSize", action: "view.resetZoom", role: "resetZoom", accelerator: { windows: "Ctrl+0" }, }, - { type: "item", label: "Zoom In", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } }, - { type: "item", label: "Zoom Out", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } }, + { type: "item", labelKey: "desktop.menu.zoomIn", action: "view.zoomIn", role: "zoomIn", accelerator: { windows: "Ctrl++" } }, + { type: "item", labelKey: "desktop.menu.zoomOut", action: "view.zoomOut", role: "zoomOut", accelerator: { windows: "Ctrl+-" } }, { type: "separator" }, - { type: "item", label: "Toggle Full Screen", action: "view.toggleFullscreen", role: "togglefullscreen" }, + { type: "item", labelKey: "desktop.menu.toggleFullScreen", action: "view.toggleFullscreen", role: "togglefullscreen" }, ], }, { id: "go", - label: "Go", + labelKey: "desktop.menu.go", items: [ - { type: "item", label: "Back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, - { type: "item", label: "Forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, + { type: "item", labelKey: "desktop.menu.back", command: "common.goBack", accelerator: { macos: "Cmd+[" } }, + { type: "item", labelKey: "desktop.menu.forward", command: "common.goForward", accelerator: { macos: "Cmd+]" } }, { type: "separator" }, - { type: "item", label: "Previous Session", command: "session.previous", accelerator: { macos: "Option+Up" } }, - { type: "item", label: "Next Session", command: "session.next", accelerator: { macos: "Option+Down" } }, + { type: "item", labelKey: "desktop.menu.previousSession", command: "session.previous", accelerator: { macos: "Option+Up" } }, + { type: "item", labelKey: "desktop.menu.nextSession", command: "session.next", accelerator: { macos: "Option+Down" } }, { type: "separator" }, { type: "item", - label: "Previous Project", + labelKey: "desktop.menu.previousProject", command: "project.previous", accelerator: { macos: "Cmd+Option+Up" }, }, { type: "item", - label: "Next Project", + labelKey: "desktop.menu.nextProject", command: "project.next", accelerator: { macos: "Cmd+Option+Down" }, }, @@ -187,31 +211,31 @@ export const DESKTOP_MENU: DesktopMenu[] = [ }, { id: "window", - label: "Window", + labelKey: "desktop.menu.window", role: "windowMenu", items: [ - { type: "item", label: "Minimize", action: "window.minimize" }, - { type: "item", label: "Maximize", action: "window.toggleMaximize" }, + { type: "item", labelKey: "desktop.menu.minimize", action: "window.minimize" }, + { type: "item", labelKey: "desktop.menu.maximize", action: "window.toggleMaximize" }, { type: "separator" }, - { type: "item", label: "Close Window", action: "window.close" }, + { type: "item", labelKey: "desktop.menu.closeWindow", action: "window.close" }, ], }, { id: "help", - label: "Help", + labelKey: "desktop.menu.help", items: [ - { type: "item", label: "OpenCode Documentation", href: "https://opencode.ai/docs" }, - { type: "item", label: "Support Forum", href: "https://discord.com/invite/opencode" }, - { type: "item", label: "Export Logs...", command: "logs.export" }, + { type: "item", labelKey: "desktop.menu.documentation", href: "https://opencode.ai/docs" }, + { type: "item", labelKey: "desktop.menu.supportForum", href: "https://discord.com/invite/opencode" }, + { type: "item", labelKey: "desktop.menu.exportLogs", command: "logs.export" }, { type: "separator" }, { type: "item", - label: "Share Feedback", + labelKey: "desktop.menu.shareFeedback", href: "https://github.com/anomalyco/opencode/issues/new?template=feature_request.yml", }, { type: "item", - label: "Report a Bug", + labelKey: "desktop.menu.reportBug", href: "https://github.com/anomalyco/opencode/issues/new?template=bug_report.yml", }, ], diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 0bb5ea2ae721..cc3b7764e4c1 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -1,4 +1,103 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "ملف", + "desktop.menu.edit": "تحرير", + "desktop.menu.view": "عرض", + "desktop.menu.go": "انتقال", + "desktop.menu.window": "نافذة", + "desktop.menu.help": "مساعدة", + "desktop.menu.checkForUpdates": "التحقق من وجود تحديثات...", + "desktop.menu.settings": "الإعدادات", + "desktop.menu.reloadWebview": "إعادة تحميل Webview", + "desktop.menu.restart": "إعادة التشغيل", + "desktop.menu.exportLogs": "تصدير السجلات...", + "desktop.menu.newSession": "جلسة جديدة", + "desktop.menu.openProject": "فتح مشروع...", + "desktop.menu.newWindow": "نافذة جديدة", + "desktop.menu.closeWindow": "إغلاق النافذة", + "desktop.menu.undo": "تراجع", + "desktop.menu.redo": "إعادة", + "desktop.menu.cut": "قص", + "desktop.menu.copy": "نسخ", + "desktop.menu.paste": "لصق", + "desktop.menu.delete": "حذف", + "desktop.menu.selectAll": "تحديد الكل", + "desktop.menu.toggleSidebar": "إظهار الشريط الجانبي أو إخفاؤه", + "desktop.menu.toggleTerminal": "إظهار المحطة الطرفية أو إخفاؤها", + "desktop.menu.toggleFileTree": "إظهار شجرة الملفات أو إخفاؤها", + "desktop.menu.reload": "إعادة التحميل", + "desktop.menu.toggleDeveloperTools": "إظهار أدوات المطور أو إخفاؤها", + "desktop.menu.actualSize": "الحجم الفعلي", + "desktop.menu.zoomIn": "تكبير", + "desktop.menu.zoomOut": "تصغير", + "desktop.menu.toggleFullScreen": "الدخول إلى ملء الشاشة أو الخروج منه", + "desktop.menu.back": "رجوع", + "desktop.menu.forward": "تقدم", + "desktop.menu.previousSession": "الجلسة السابقة", + "desktop.menu.nextSession": "الجلسة التالية", + "desktop.menu.previousProject": "المشروع السابق", + "desktop.menu.nextProject": "المشروع التالي", + "desktop.menu.minimize": "تصغير النافذة", + "desktop.menu.maximize": "تكبير النافذة", + "desktop.menu.documentation": "وثائق OpenCode", + "desktop.menu.supportForum": "منتدى الدعم", + "desktop.menu.shareFeedback": "إرسال الملاحظات", + "desktop.menu.reportBug": "الإبلاغ عن خلل", + "desktop.menu.ariaLabel": "قائمة OpenCode", + + "desktop.updater.dialog.checkFailed.message": "فشل التحقق من وجود تحديثات.", + "desktop.updater.dialog.checkFailed.title": "خطأ في التحديث", + "desktop.updater.dialog.upToDate.message": "لديك أحدث إصدار.", + "desktop.updater.dialog.upToDate.title": "لا توجد تحديثات", + "desktop.updater.dialog.ready.message": "تم تنزيل التحديث {{version}}. هل تريد إعادة التشغيل الآن؟", + "desktop.updater.dialog.ready.title": "التحديث جاهز", + "desktop.updater.dialog.restart": "إعادة التشغيل", + "desktop.updater.dialog.later": "لاحقًا", + + "desktop.recovery.action.relaunch": "إعادة تشغيل التطبيق", + "desktop.recovery.action.exportLogs": "تصدير السجلات", + "desktop.recovery.action.keepWaiting": "متابعة الانتظار", + "desktop.recovery.action.quit": "إنهاء", + "desktop.recovery.loadFailed": "فشل تحميل OpenCode", + "desktop.recovery.terminated": "توقفت نافذة OpenCode بشكل غير متوقع", + "desktop.recovery.unresponsive": "OpenCode لا يستجيب", + "desktop.recovery.unresponsive.detail": "يمكنك إعادة تشغيل التطبيق أو فتح السجلات أو متابعة الانتظار.", + "desktop.recovery.loadFailed.detail": "النافذة: {{window}}\nعنوان URL: {{url}}\nالخطأ: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "النافذة: {{window}}\nالسبب: {{reason}}\nالرمز: {{code}}", + "desktop.recovery.unknown": "<غير معروف>", + + "desktop.dialog.chooseFolder": "اختيار مجلد", + "desktop.dialog.chooseFile": "اختيار ملف", + "desktop.dialog.saveFile": "حفظ الملف", + "desktop.dialog.files": "الملفات", + "desktop.server.local": "الخادم المحلي", + + "desktop.wsl.error.windowsOnly": "لا يتوفر WSL إلا على Windows", + "desktop.wsl.error.unavailable": "WSL غير متاح", + "desktop.wsl.error.listInstalled": "فشل سرد توزيعات WSL المثبتة", + "desktop.wsl.error.listOnline": "فشل سرد توزيعات WSL المتاحة عبر الإنترنت", + "desktop.wsl.error.executeDistro": "تعذر تنفيذ الأوامر في التوزيعة", + "desktop.wsl.error.installWsl": "فشل تثبيت WSL", + "desktop.wsl.error.installDistro": "فشل تثبيت التوزيعة: {{distro}}", + "desktop.wsl.error.installOpencode": "فشل تثبيت OpenCode", + "desktop.wsl.error.alreadyAdded": "تمت إضافة {{distro}} بالفعل", + "desktop.wsl.error.opencodeMissing": "opencode غير مثبت في هذه التوزيعة", + "desktop.wsl.error.opencodeCannotRun": "opencode مثبت، لكن تعذر تشغيله", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode غير مثبت في {{distro}}", + "desktop.wsl.error.updateVersion": + "اكتمل تحديث OpenCode، لكن {{distro}} ما زالت تعرض الإصدار {{installed}}؛ الإصدار المتوقع هو {{expected}}", + "desktop.wsl.error.noVersion": "لا يوجد إصدار", + "desktop.wsl.error.serverExited": "خرج خادم WSL بعد بدء التشغيل (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "خرج خادم WSL قبل أن يصبح سليمًا (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "انتهت مهلة فحص سلامة العملية المصاحبة للتوزيعة {{distro}} بعد {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "انتهت مهلة {{command}} {{args}} بعد {{timeout}}ms", + "desktop.wsl.error.failedPort": "فشل الحصول على المنفذ", + + "desktop.picker.error.notSelected": "لم يحدد منتقي الملفات ملفًا", + "desktop.picker.error.sizeLimit": "تتجاوز المرفقات المحددة الحد البالغ {{limit}} MB", + "command.category.suggested": "مقترح", "command.category.view": "عرض", "command.category.project": "مشروع", @@ -16,6 +115,7 @@ export const dict = { "command.category.permissions": "أذونات", "command.category.workspace": "مساحة عمل", "command.category.settings": "إعدادات", + "command.logs.export": "تصدير السجلات", "theme.scheme.system": "نظام", "theme.scheme.light": "فاتح", "theme.scheme.dark": "داكن", @@ -119,6 +219,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "تسجيل الدخول باستخدام Claude Pro/Max", "provider.connect.selectMethod": "حدد طريقة تسجيل الدخول لـ {{provider}}.", "provider.connect.method.apiKey": "مفتاح API", + "provider.connect.method.browser": "المتصفح", + "provider.connect.method.headless": "بدون واجهة رسومية", "provider.connect.status.inProgress": "جارٍ التفويض...", "provider.connect.status.waiting": "في انتظار التفويض...", "provider.connect.status.failed": "فشل التفويض: {{error}}", @@ -150,6 +252,7 @@ export const dict = { "provider.connect.toast.connected.title": "تم توصيل {{provider}}", "provider.connect.toast.connected.description": "نماذج {{provider}} متاحة الآن للاستخدام.", "provider.custom.title": "موفر مخصص", + "provider.custom.unavailable": "الموفرون المخصصون غير متاحين على هذا الخادم", "provider.custom.description.prefix": "تكوين موفر متوافق مع OpenAI. راجع ", "provider.custom.description.link": "وثائق تكوين الموفر", "provider.custom.description.suffix": ".", @@ -403,6 +506,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "سطح المكتب {{version}}", "wsl.onboarding.versionMismatch": "لا يتطابق الإصدار المثبت مع إصدار تطبيق سطح المكتب.", "wsl.onboarding.adding": "جارٍ الإضافة...", + "help.tabs.toast.ariaLabel": "نقدّم علامات التبويب. نظّم عملك وجلساتك النشطة باستخدام علامات التبويب", + "help.tabs.toast.dismiss": "إغلاق معلومات علامات التبويب", + "help.tabs.title": "نقدّم علامات التبويب", + "help.tabs.description": "نظّم عملك وجلساتك النشطة باستخدام علامات التبويب", + "help.tabs.date": "14 يوليو", + "help.tabs.introduction": "أصبح OpenCode Desktop الآن قائمًا على علامات التبويب.", + "help.tabs.sessions": + "ابدأ جلسة جديدة في علامة تبويب، أو افتح جلسة حالية من أي من مشاريعك. افتح علامة تبويب جديدة عندما تبدأ عملاً جديدًا، وأغلقها عند الانتهاء.", + "help.tabs.organize": + "يسهّل إبقاء بضع علامات تبويب مفتوحة تنظيم جلساتك النشطة. أعد تسمية علامات التبويب بأسماء يسهل تذكرها إذا كنت تنوي إبقاءها مفتوحة.", + "help.tabs.home": + "ستجد جميع جلساتك ومشاريعك في شاشة الصفحة الرئيسية الجديدة. يؤدي تحديد جلسة إلى فتحها في علامة تبويب.", + "help.tabs.persistence": "عندما تعيد فتح التطبيق، تظل علامات تبويبك مفتوحة.", + "help.tabs.worktrees": + "لا يدعم التصميم الجديد أشجار عمل Git حتى الآن، وستتوفر قريبًا. لذلك، إذا كنت تفضل مواصلة استخدام التخطيط السابق، فيمكنك التبديل بين التخطيطات في الإعدادات. ضع في اعتبارك أن التخطيط الجديد سيصبح دائمًا خلال بضعة أسابيع.", "dialog.project.edit.title": "تحرير المشروع", "dialog.project.edit.name": "الاسم", "dialog.project.edit.icon": "أيقونة", @@ -664,6 +782,12 @@ export const dict = { "terminal.close": "إغلاق المحطة الطرفية", "terminal.connectionLost.title": "فقد الاتصال", "terminal.connectionLost.description": "انقطع اتصال المحطة الطرفية. يمكن أن يحدث هذا عند إعادة تشغيل الخادم.", + "terminal.connectTicket.csrfError": "رُفضت تذكرة اتصال PTY بسبب فحوصات الأصل أو CSRF. تحقق من إعداد CORS للخادم.", + "terminal.connectTicket.statusError": "فشلت تذكرة اتصال PTY بالحالة {{status}}", + + "titlebar.update": "تحديث", + "titlebar.updateVersion": "تحديث {{version}}", + "common.closeTab": "إغلاق علامة التبويب", "common.dismiss": "إهمال", "common.requestFailed": "فشل الطلب", diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts index 6a9f48bb0483..66f4b72ab001 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -1,4 +1,98 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Fayl", + "desktop.menu.edit": "Redaktə", + "desktop.menu.view": "Görünüş", + "desktop.menu.go": "Keçid", + "desktop.menu.window": "Pəncərə", + "desktop.menu.help": "Kömək", + "desktop.menu.checkForUpdates": "Yeniləmələri yoxla...", + "desktop.menu.settings": "Tənzimləmələr", + "desktop.menu.reloadWebview": "Webview-u yenidən yüklə", + "desktop.menu.restart": "Yenidən başlat", + "desktop.menu.exportLogs": "Jurnalları ixrac et...", + "desktop.menu.newSession": "Yeni sessiya", + "desktop.menu.openProject": "Layihəni aç...", + "desktop.menu.newWindow": "Yeni pəncərə", + "desktop.menu.closeWindow": "Pəncərəni bağla", + "desktop.menu.undo": "Geri al", + "desktop.menu.redo": "Yenidən et", + "desktop.menu.cut": "Kəs", + "desktop.menu.copy": "Kopyala", + "desktop.menu.paste": "Yapışdır", + "desktop.menu.delete": "Sil", + "desktop.menu.selectAll": "Hamısını seç", + "desktop.menu.toggleSidebar": "Yan paneli aç/bağla", + "desktop.menu.toggleTerminal": "Terminalı aç/bağla", + "desktop.menu.toggleFileTree": "Fayl ağacını aç/bağla", + "desktop.menu.reload": "Yenidən yüklə", + "desktop.menu.toggleDeveloperTools": "Tərtibatçı alətlərini aç/bağla", + "desktop.menu.actualSize": "Faktiki ölçü", + "desktop.menu.zoomIn": "Böyüt", + "desktop.menu.zoomOut": "Kiçilt", + "desktop.menu.toggleFullScreen": "Tam ekran rejimini aç/bağla", + "desktop.menu.back": "Geri", + "desktop.menu.forward": "İrəli", + "desktop.menu.previousSession": "Əvvəlki sessiya", + "desktop.menu.nextSession": "Növbəti sessiya", + "desktop.menu.previousProject": "Əvvəlki layihə", + "desktop.menu.nextProject": "Növbəti layihə", + "desktop.menu.minimize": "Kiçilt", + "desktop.menu.maximize": "Böyüt", + "desktop.menu.documentation": "OpenCode sənədləri", + "desktop.menu.supportForum": "Dəstək forumu", + "desktop.menu.shareFeedback": "Rəy bildir", + "desktop.menu.reportBug": "Xəta bildir", + "desktop.menu.ariaLabel": "OpenCode menyusu", + "desktop.updater.dialog.checkFailed.message": "Yeniləmələri yoxlamaq mümkün olmadı.", + "desktop.updater.dialog.checkFailed.title": "Yeniləmə xətası", + "desktop.updater.dialog.upToDate.message": "Ən son versiyadan istifadə edirsiniz.", + "desktop.updater.dialog.upToDate.title": "Yeniləmə yoxdur", + "desktop.updater.dialog.ready.message": "{{version}} yeniləməsi endirildi. İndi yenidən başlatmaq istəyirsiniz?", + "desktop.updater.dialog.ready.title": "Yeniləmə hazırdır", + "desktop.updater.dialog.restart": "Yenidən başlat", + "desktop.updater.dialog.later": "Daha sonra", + "desktop.recovery.action.relaunch": "Yenidən işə sal", + "desktop.recovery.action.exportLogs": "Jurnalları ixrac et", + "desktop.recovery.action.keepWaiting": "Gözləməyə davam et", + "desktop.recovery.action.quit": "Çıx", + "desktop.recovery.loadFailed": "OpenCode yüklənə bilmədi", + "desktop.recovery.terminated": "OpenCode pəncərəsinin işi gözlənilmədən dayandı", + "desktop.recovery.unresponsive": "OpenCode cavab vermir", + "desktop.recovery.unresponsive.detail": + "Tətbiqi yenidən işə sala, jurnalları aça və ya gözləməyə davam edə bilərsiniz.", + "desktop.recovery.loadFailed.detail": "Pəncərə: {{window}}\nURL: {{url}}\nXəta: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Pəncərə: {{window}}\nSəbəb: {{reason}}\nKod: {{code}}", + "desktop.recovery.unknown": "", + "desktop.dialog.chooseFolder": "Qovluq seçin", + "desktop.dialog.chooseFile": "Fayl seçin", + "desktop.dialog.saveFile": "Faylı saxla", + "desktop.dialog.files": "Fayllar", + "desktop.server.local": "Yerli server", + "desktop.wsl.error.windowsOnly": "WSL yalnız Windows-da əlçatandır", + "desktop.wsl.error.unavailable": "WSL əlçatan deyil", + "desktop.wsl.error.listInstalled": "Quraşdırılmış WSL distribütivlərini siyahılamaq mümkün olmadı", + "desktop.wsl.error.listOnline": "Onlayn WSL distribütivlərini siyahılamaq mümkün olmadı", + "desktop.wsl.error.executeDistro": "Distribütivdə əmrləri icra etmək mümkün deyil", + "desktop.wsl.error.installWsl": "WSL quraşdırılması uğursuz oldu", + "desktop.wsl.error.installDistro": "Distribütivi quraşdırmaq mümkün olmadı: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode quraşdırılması uğursuz oldu", + "desktop.wsl.error.alreadyAdded": "{{distro}} artıq əlavə edilib", + "desktop.wsl.error.opencodeMissing": "Bu distribütivdə opencode quraşdırılmayıb", + "desktop.wsl.error.opencodeCannotRun": "opencode quraşdırılıb, lakin onu işə salmaq mümkün olmadı", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode {{distro}} distribütivində quraşdırılmayıb", + "desktop.wsl.error.updateVersion": + "OpenCode yeniləməsi tamamlandı, lakin {{distro}} hələ də {{installed}} versiyasını bildirir; gözlənilən versiya: {{expected}}", + "desktop.wsl.error.noVersion": "versiya yoxdur", + "desktop.wsl.error.serverExited": "WSL serveri başladıqdan sonra dayandı (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL serveri işlək vəziyyətə gəlməzdən əvvəl dayandı (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "{{distro}} üçün sidecar sağlamlıq yoxlamasının {{timeout}}ms gözləmə müddəti bitdi", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} üçün {{timeout}}ms gözləmə müddəti bitdi", + "desktop.wsl.error.failedPort": "Portu əldə etmək mümkün olmadı", + "desktop.picker.error.notSelected": "Seçim vasitəsində fayl seçilməyib", + "desktop.picker.error.sizeLimit": "Seçilmiş əlavələr {{limit}} MB limitini aşır", "command.category.suggested": "Təklif olunan", "command.category.view": "Görünüş", "command.category.project": "Layihə", @@ -16,6 +110,7 @@ export const dict = { "command.category.permissions": "İcazələr", "command.category.workspace": "İş sahəsi", "command.category.settings": "Tənzimləmələr", + "command.logs.export": "Jurnalları ixrac et", "theme.scheme.system": "Sistem", "theme.scheme.light": "Açıq", "theme.scheme.dark": "Tünd", @@ -119,6 +214,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude Pro/Max ilə daxil ol", "provider.connect.selectMethod": "{{provider}} üçün giriş üsulunu seçin.", "provider.connect.method.apiKey": "API açarı", + "provider.connect.method.browser": "Brauzer", + "provider.connect.method.headless": "Başsız", "provider.connect.status.inProgress": "Avtorizasiya davam edir...", "provider.connect.status.waiting": "Avtorizasiya gözlənilir...", "provider.connect.status.failed": "Avtorizasiya uğursuz oldu: {{error}}", @@ -150,6 +247,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} qoşuldu", "provider.connect.toast.connected.description": "{{provider}} modelləri artıq istifadə üçün mövcuddur.", "provider.custom.title": "Fərdi provayder", + "provider.custom.unavailable": "Fərdi provayderlər bu serverdə əlçatan deyil", "provider.custom.description.prefix": "OpenAI ilə uyğun provayderi konfiqurasiya edin. ", "provider.custom.description.link": "Provayder konfiqurasiyası sənədləri", "provider.custom.description.suffix": "nə baxın.", @@ -412,6 +510,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "masaüstü {{version}}", "wsl.onboarding.versionMismatch": "Quraşdırılmış versiya masaüstü proqram versiyasına uyğun gəlmir.", "wsl.onboarding.adding": "Əlavə edilir...", + "help.tabs.toast.ariaLabel": "Tablarla tanışlıq. İşinizi və aktiv sessiyalarınızı tablarla təşkil edin", + "help.tabs.toast.dismiss": "Tablar haqqında məlumatı bağla", + "help.tabs.title": "Tablarla tanışlıq", + "help.tabs.description": "İşinizi və aktiv sessiyalarınızı tablarla təşkil edin", + "help.tabs.date": "14 iyul", + "help.tabs.introduction": "OpenCode Desktop indi tablar əsasında qurulub.", + "help.tabs.sessions": + "Yeni sessiyanı tabda başladın və ya layihələrinizdən mövcud sessiyanı açın. Yeni işə başlayanda yeni tab açın, işi bitirdikdə isə onu bağlayın.", + "help.tabs.organize": + "Bir neçə tabı açıq saxlamaq aktiv sessiyalarınızı təşkil etməyi asanlaşdırır. Onları saxlamağı planlaşdırırsınızsa, tablara yadda qalan adlar verin.", + "help.tabs.home": + "Bütün sessiya və layihələrinizi yeni Ana səhifədə tapa bilərsiniz. Sessiyanı seçdikdə o, tabda açılır.", + "help.tabs.persistence": "Tətbiqi yenidən açdıqda tablarınız açıq qalır.", + "help.tabs.worktrees": + "Yeni dizayn hələ Git Worktrees-i dəstəkləmir, lakin bu imkan tezliklə əlavə olunacaq. Əvvəlki tərtibatdan istifadə etməyə davam etmək istəyirsinizsə, Tənzimləmələrdə tərtibatlar arasında keçid edə bilərsiniz. Nəzərə alın ki, yeni tərtibat bir neçə həftədən sonra daimi olacaq.", "server.row.noUsername": "istifadəçi adı yoxdur", "dialog.project.edit.title": "Layihəni redaktə et", "dialog.project.edit.name": "Ad", @@ -707,6 +820,11 @@ export const dict = { "terminal.connectionLost.title": "Bağlantı kəsildi", "terminal.connectionLost.abnormalClose": "WebSocket anormal bağlandı: {{code}}", "terminal.connectionLost.description": "Terminal bağlantısı kəsildi. Bu server yenidən başladıqda baş verə bilər.", + "terminal.connectTicket.csrfError": + "PTY bağlantı bileti mənşə və ya CSRF yoxlaması zamanı rədd edildi. Serverin CORS konfiqurasiyasını yoxlayın.", + "terminal.connectTicket.statusError": "PTY bağlantı bileti {{status}} statusu ilə uğursuz oldu", + "titlebar.update": "Yenilə", + "titlebar.updateVersion": "Yenilə {{version}}", "common.closeTab": "Tabı bağla", "common.dismiss": "Bağla", "common.moreCountSuffix": " (+{{count}} daha)", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 35f7a0ce72bf..19cb9a4cab4f 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -1,4 +1,104 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Arquivo", + "desktop.menu.edit": "Editar", + "desktop.menu.view": "Visualizar", + "desktop.menu.go": "Ir", + "desktop.menu.window": "Janela", + "desktop.menu.help": "Ajuda", + "desktop.menu.checkForUpdates": "Verificar atualizações...", + "desktop.menu.settings": "Configurações", + "desktop.menu.reloadWebview": "Recarregar WebView", + "desktop.menu.restart": "Reiniciar", + "desktop.menu.exportLogs": "Exportar logs...", + "desktop.menu.newSession": "Nova sessão", + "desktop.menu.openProject": "Abrir projeto...", + "desktop.menu.newWindow": "Nova janela", + "desktop.menu.closeWindow": "Fechar janela", + "desktop.menu.undo": "Desfazer", + "desktop.menu.redo": "Refazer", + "desktop.menu.cut": "Recortar", + "desktop.menu.copy": "Copiar", + "desktop.menu.paste": "Colar", + "desktop.menu.delete": "Excluir", + "desktop.menu.selectAll": "Selecionar tudo", + "desktop.menu.toggleSidebar": "Alternar barra lateral", + "desktop.menu.toggleTerminal": "Alternar terminal", + "desktop.menu.toggleFileTree": "Alternar árvore de arquivos", + "desktop.menu.reload": "Recarregar", + "desktop.menu.toggleDeveloperTools": "Alternar ferramentas de desenvolvedor", + "desktop.menu.actualSize": "Tamanho real", + "desktop.menu.zoomIn": "Ampliar", + "desktop.menu.zoomOut": "Reduzir", + "desktop.menu.toggleFullScreen": "Alternar tela inteira", + "desktop.menu.back": "Voltar", + "desktop.menu.forward": "Avançar", + "desktop.menu.previousSession": "Sessão anterior", + "desktop.menu.nextSession": "Próxima sessão", + "desktop.menu.previousProject": "Projeto anterior", + "desktop.menu.nextProject": "Próximo projeto", + "desktop.menu.minimize": "Minimizar", + "desktop.menu.maximize": "Maximizar", + "desktop.menu.documentation": "Documentação do OpenCode", + "desktop.menu.supportForum": "Fórum de suporte", + "desktop.menu.shareFeedback": "Enviar comentários", + "desktop.menu.reportBug": "Relatar um bug", + "desktop.menu.ariaLabel": "Menu do OpenCode", + + "desktop.updater.dialog.checkFailed.message": "Falha ao verificar atualizações.", + "desktop.updater.dialog.checkFailed.title": "Erro de atualização", + "desktop.updater.dialog.upToDate.message": "Você está em dia.", + "desktop.updater.dialog.upToDate.title": "Nenhuma atualização", + "desktop.updater.dialog.ready.message": "A atualização {{version}} foi baixada. Deseja reiniciar agora?", + "desktop.updater.dialog.ready.title": "Atualização pronta", + "desktop.updater.dialog.restart": "Reiniciar", + "desktop.updater.dialog.later": "Mais tarde", + + "desktop.recovery.action.relaunch": "Reabrir", + "desktop.recovery.action.exportLogs": "Exportar logs", + "desktop.recovery.action.keepWaiting": "Continuar aguardando", + "desktop.recovery.action.quit": "Encerrar", + "desktop.recovery.loadFailed": "Falha ao carregar o OpenCode", + "desktop.recovery.terminated": "A janela do OpenCode foi encerrada inesperadamente", + "desktop.recovery.unresponsive": "O OpenCode não está respondendo", + "desktop.recovery.unresponsive.detail": "Você pode reabrir o aplicativo, abrir os logs ou continuar aguardando.", + "desktop.recovery.loadFailed.detail": "Janela: {{window}}\nURL: {{url}}\nErro: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Janela: {{window}}\nMotivo: {{reason}}\nCódigo: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Escolher uma pasta", + "desktop.dialog.chooseFile": "Escolher um arquivo", + "desktop.dialog.saveFile": "Salvar arquivo", + "desktop.dialog.files": "Arquivos", + "desktop.server.local": "Servidor local", + + "desktop.wsl.error.windowsOnly": "O WSL está disponível somente no Windows", + "desktop.wsl.error.unavailable": "O WSL está indisponível", + "desktop.wsl.error.listInstalled": "Falha ao listar as distribuições WSL instaladas", + "desktop.wsl.error.listOnline": "Falha ao listar as distribuições WSL disponíveis online", + "desktop.wsl.error.executeDistro": "Não é possível executar comandos na distribuição", + "desktop.wsl.error.installWsl": "Falha na instalação do WSL", + "desktop.wsl.error.installDistro": "Falha ao instalar a distribuição: {{distro}}", + "desktop.wsl.error.installOpencode": "Falha na instalação do OpenCode", + "desktop.wsl.error.alreadyAdded": "{{distro}} já foi adicionada", + "desktop.wsl.error.opencodeMissing": "O opencode não está instalado nesta distribuição", + "desktop.wsl.error.opencodeCannotRun": "O opencode está instalado, mas não pôde ser executado", + "desktop.wsl.error.opencodeNotInstalled": "O OpenCode não está instalado em {{distro}}", + "desktop.wsl.error.updateVersion": + "A atualização do OpenCode foi concluída, mas {{distro}} ainda informa a versão {{installed}}; a versão esperada é {{expected}}", + "desktop.wsl.error.noVersion": "sem versão", + "desktop.wsl.error.serverExited": + "O servidor WSL foi encerrado após a inicialização (código={{code}} sinal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "O servidor WSL foi encerrado antes de ficar íntegro (código={{code}} sinal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "O tempo limite da verificação de integridade do sidecar de {{distro}} foi excedido após {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "O tempo limite de {{command}} {{args}} foi excedido após {{timeout}}ms", + "desktop.wsl.error.failedPort": "Falha ao obter a porta", + + "desktop.picker.error.notSelected": "O arquivo não foi selecionado pelo seletor", + "desktop.picker.error.sizeLimit": "Os anexos selecionados excedem o limite de {{limit}} MB", + "command.category.suggested": "Sugerido", "command.category.view": "Visualizar", "command.category.project": "Projeto", @@ -16,6 +116,7 @@ export const dict = { "command.category.permissions": "Permissões", "command.category.workspace": "Espaço de trabalho", "command.category.settings": "Configurações", + "command.logs.export": "Exportar logs", "theme.scheme.system": "Sistema", "theme.scheme.light": "Claro", "theme.scheme.dark": "Escuro", @@ -119,6 +220,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Entrar com Claude Pro/Max", "provider.connect.selectMethod": "Selecionar método de login para {{provider}}.", "provider.connect.method.apiKey": "Chave de API", + "provider.connect.method.browser": "Navegador", + "provider.connect.method.headless": "Sem interface gráfica", "provider.connect.status.inProgress": "Autorização em andamento...", "provider.connect.status.waiting": "Aguardando autorização...", "provider.connect.status.failed": "Autorização falhou: {{error}}", @@ -150,6 +253,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} conectado", "provider.connect.toast.connected.description": "Modelos do {{provider}} agora estão disponíveis para uso.", "provider.custom.title": "Provedor personalizado", + "provider.custom.unavailable": "Provedores personalizados não estão disponíveis neste servidor", "provider.custom.description.prefix": "Configure um provedor compatível com OpenAI. Veja a ", "provider.custom.description.link": "documentação de configuração do provedor", "provider.custom.description.suffix": ".", @@ -404,6 +508,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.versionMismatch": "A versão instalada não corresponde à versão do aplicativo para desktop.", "wsl.onboarding.adding": "Adicionando...", + "help.tabs.toast.ariaLabel": "Apresentamos as abas. Organize seu trabalho e suas sessões ativas com abas", + "help.tabs.toast.dismiss": "Dispensar informações sobre as abas", + "help.tabs.title": "Apresentamos as abas", + "help.tabs.description": "Organize seu trabalho e suas sessões ativas com abas", + "help.tabs.date": "14 de julho", + "help.tabs.introduction": "Agora, o OpenCode Desktop é organizado em abas.", + "help.tabs.sessions": + "Inicie uma nova sessão em uma aba ou abra uma sessão existente de qualquer um dos seus projetos. Abra uma nova aba quando começar algo novo e feche-a quando terminar.", + "help.tabs.organize": + "Manter algumas abas abertas facilita a organização das sessões ativas. Renomeie as abas com nomes fáceis de lembrar se pretende mantê-las abertas.", + "help.tabs.home": + "Você encontrará todas as suas sessões e projetos na nova tela Início. Ao selecionar uma sessão, ela será aberta em uma aba.", + "help.tabs.persistence": "Quando você reabrir o aplicativo, suas abas ainda estarão abertas.", + "help.tabs.worktrees": + "O novo design ainda não oferece suporte a Git Worktrees, mas esse recurso estará disponível em breve. Se preferir continuar usando o layout anterior, você poderá alternar entre os layouts nas Configurações. Lembre-se apenas de que o novo layout se tornará permanente em algumas semanas.", "dialog.project.edit.title": "Editar projeto", "dialog.project.edit.name": "Nome", "dialog.project.edit.icon": "Ícone", @@ -664,6 +783,13 @@ export const dict = { "terminal.connectionLost.title": "Conexão perdida", "terminal.connectionLost.description": "A conexão do terminal foi interrompida. Isso pode acontecer quando o servidor reinicia.", + "terminal.connectTicket.csrfError": + "O tíquete de conexão PTY foi rejeitado pelas verificações de origem ou CSRF. Verifique a configuração de CORS do servidor.", + "terminal.connectTicket.statusError": "O tíquete de conexão PTY falhou com o status {{status}}", + + "titlebar.update": "Atualizar", + "titlebar.updateVersion": "Atualizar {{version}}", + "common.closeTab": "Fechar aba", "common.dismiss": "Descartar", "common.requestFailed": "Requisição falhou", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 731d61b9fee5..e00f58767d62 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -1,4 +1,104 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Datoteka", + "desktop.menu.edit": "Uredi", + "desktop.menu.view": "Prikaz", + "desktop.menu.go": "Idi", + "desktop.menu.window": "Prozor", + "desktop.menu.help": "Pomoć", + "desktop.menu.checkForUpdates": "Provjeri ima li ažuriranja...", + "desktop.menu.settings": "Postavke", + "desktop.menu.reloadWebview": "Ponovo učitaj Webview", + "desktop.menu.restart": "Ponovo pokreni", + "desktop.menu.exportLogs": "Izvezi zapisnike...", + "desktop.menu.newSession": "Nova sesija", + "desktop.menu.openProject": "Otvori projekat...", + "desktop.menu.newWindow": "Novi prozor", + "desktop.menu.closeWindow": "Zatvori prozor", + "desktop.menu.undo": "Poništi", + "desktop.menu.redo": "Vrati", + "desktop.menu.cut": "Izreži", + "desktop.menu.copy": "Kopiraj", + "desktop.menu.paste": "Zalijepi", + "desktop.menu.delete": "Izbriši", + "desktop.menu.selectAll": "Odaberi sve", + "desktop.menu.toggleSidebar": "Prikaži ili sakrij bočnu traku", + "desktop.menu.toggleTerminal": "Prikaži ili sakrij terminal", + "desktop.menu.toggleFileTree": "Prikaži ili sakrij stablo datoteka", + "desktop.menu.reload": "Ponovo učitaj", + "desktop.menu.toggleDeveloperTools": "Prikaži ili sakrij alate za programere", + "desktop.menu.actualSize": "Stvarna veličina", + "desktop.menu.zoomIn": "Uvećaj", + "desktop.menu.zoomOut": "Umanji", + "desktop.menu.toggleFullScreen": "Prikaži ili sakrij prikaz preko cijelog ekrana", + "desktop.menu.back": "Nazad", + "desktop.menu.forward": "Naprijed", + "desktop.menu.previousSession": "Prethodna sesija", + "desktop.menu.nextSession": "Sljedeća sesija", + "desktop.menu.previousProject": "Prethodni projekat", + "desktop.menu.nextProject": "Sljedeći projekat", + "desktop.menu.minimize": "Minimiziraj", + "desktop.menu.maximize": "Maksimiziraj", + "desktop.menu.documentation": "Dokumentacija za OpenCode", + "desktop.menu.supportForum": "Forum za podršku", + "desktop.menu.shareFeedback": "Pošalji povratne informacije", + "desktop.menu.reportBug": "Prijavi grešku", + "desktop.menu.ariaLabel": "OpenCode meni", + + "desktop.updater.dialog.checkFailed.message": "Provjera ažuriranja nije uspjela.", + "desktop.updater.dialog.checkFailed.title": "Greška pri ažuriranju", + "desktop.updater.dialog.upToDate.message": "Sve je ažurno.", + "desktop.updater.dialog.upToDate.title": "Nema ažuriranja", + "desktop.updater.dialog.ready.message": "Ažuriranje {{version}} je preuzeto. Želiš li sada ponovo pokrenuti aplikaciju?", + "desktop.updater.dialog.ready.title": "Ažuriranje je spremno", + "desktop.updater.dialog.restart": "Ponovo pokreni", + "desktop.updater.dialog.later": "Kasnije", + + "desktop.recovery.action.relaunch": "Ponovo pokreni", + "desktop.recovery.action.exportLogs": "Izvezi zapisnike", + "desktop.recovery.action.keepWaiting": "Nastavi čekati", + "desktop.recovery.action.quit": "Izađi", + "desktop.recovery.loadFailed": "OpenCode se nije uspio učitati", + "desktop.recovery.terminated": "Prozor OpenCode-a neočekivano je zatvoren", + "desktop.recovery.unresponsive": "OpenCode ne reaguje", + "desktop.recovery.unresponsive.detail": + "Možeš ponovo pokrenuti aplikaciju, otvoriti zapisnike ili nastaviti čekati.", + "desktop.recovery.loadFailed.detail": "Prozor: {{window}}\nURL: {{url}}\nGreška: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Prozor: {{window}}\nRazlog: {{reason}}\nKod: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Odaberi fasciklu", + "desktop.dialog.chooseFile": "Odaberi datoteku", + "desktop.dialog.saveFile": "Sačuvaj datoteku", + "desktop.dialog.files": "Datoteke", + "desktop.server.local": "Lokalni server", + + "desktop.wsl.error.windowsOnly": "WSL je dostupan samo u Windowsu", + "desktop.wsl.error.unavailable": "WSL nije dostupan", + "desktop.wsl.error.listInstalled": "Nije uspjelo prikazivanje instaliranih WSL distribucija", + "desktop.wsl.error.listOnline": "Nije uspjelo prikazivanje dostupnih WSL distribucija", + "desktop.wsl.error.executeDistro": "Nije moguće izvršiti komande u distribuciji", + "desktop.wsl.error.installWsl": "Instalacija WSL-a nije uspjela", + "desktop.wsl.error.installDistro": "Instalacija distribucije nije uspjela: {{distro}}", + "desktop.wsl.error.installOpencode": "Instalacija OpenCode-a nije uspjela", + "desktop.wsl.error.alreadyAdded": "Distribucija {{distro}} je već dodana", + "desktop.wsl.error.opencodeMissing": "opencode nije instaliran u ovoj distribuciji", + "desktop.wsl.error.opencodeCannotRun": "opencode je instaliran, ali se ne može pokrenuti", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode nije instaliran u distribuciji {{distro}}", + "desktop.wsl.error.updateVersion": + "Ažuriranje OpenCode-a je završeno, ali {{distro}} i dalje prijavljuje {{installed}}; očekivano je {{expected}}", + "desktop.wsl.error.noVersion": "nema verzije", + "desktop.wsl.error.serverExited": "WSL server se zatvorio nakon pokretanja (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL server se zatvorio prije nego što je postao dostupan (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Isteklo je vrijeme provjere dostupnosti komponente Sidecar za {{distro}} nakon {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "Isteklo je vrijeme za {{command}} {{args}} nakon {{timeout}} ms", + "desktop.wsl.error.failedPort": "Nije uspjelo dohvaćanje porta", + + "desktop.picker.error.notSelected": "Datoteka nije odabrana u izborniku", + "desktop.picker.error.sizeLimit": "Odabrani prilozi premašuju ograničenje od {{limit}} MB", + "command.category.suggested": "Predloženo", "command.category.view": "Prikaz", "command.category.project": "Projekat", @@ -16,6 +116,7 @@ export const dict = { "command.category.permissions": "Dozvole", "command.category.workspace": "Radni prostor", "command.category.settings": "Postavke", + "command.logs.export": "Izvezi zapisnike", "theme.scheme.system": "Sistem", "theme.scheme.light": "Svijetlo", @@ -131,6 +232,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Prijavi se putem Claude Pro/Max", "provider.connect.selectMethod": "Odaberi način prijave za {{provider}}.", "provider.connect.method.apiKey": "API ključ", + "provider.connect.method.browser": "Preglednik", + "provider.connect.method.headless": "Bez grafičkog interfejsa", "provider.connect.status.inProgress": "Autorizacija je u toku...", "provider.connect.status.waiting": "Čekanje na autorizaciju...", "provider.connect.status.failed": "Autorizacija nije uspjela: {{error}}", @@ -163,6 +266,7 @@ export const dict = { "provider.connect.toast.connected.description": "{{provider}} modeli su sada dostupni za korištenje.", "provider.custom.title": "Prilagođeni provajder", + "provider.custom.unavailable": "Prilagođeni provajderi nisu dostupni na ovom serveru", "provider.custom.description.prefix": "Konfiguriši OpenAI-kompatibilnog provajdera. Pogledaj ", "provider.custom.description.link": "dokumentaciju za konfiguraciju provajdera", "provider.custom.description.suffix": ".", @@ -433,6 +537,22 @@ export const dict = { "wsl.onboarding.versionMismatch": "Instalirana verzija ne odgovara verziji desktop aplikacije.", "wsl.onboarding.adding": "Dodavanje...", + "help.tabs.toast.ariaLabel": "Predstavljamo kartice. Organizuj svoj rad i aktivne sesije pomoću kartica", + "help.tabs.toast.dismiss": "Odbaci informacije o karticama", + "help.tabs.title": "Predstavljamo kartice", + "help.tabs.description": "Organizuj svoj rad i aktivne sesije pomoću kartica", + "help.tabs.date": "14. juli", + "help.tabs.introduction": "OpenCode Desktop sada je organizovan oko kartica.", + "help.tabs.sessions": + "Započni novu sesiju u kartici ili otvori postojeću sesiju iz bilo kojeg projekta. Otvori novu karticu kada započinješ nešto novo, a zatvori je kada završiš.", + "help.tabs.organize": + "Nekoliko otvorenih kartica olakšava organizovanje aktivnih sesija. Preimenuj kartice tako da ih možeš lako prepoznati ako ih namjeravaš zadržati.", + "help.tabs.home": + "Sve svoje sesije i projekte pronaći ćeš na novom početnom ekranu. Odabirom sesije ona se otvara u kartici.", + "help.tabs.persistence": "Kada ponovo otvoriš aplikaciju, tvoje kartice ostaju otvorene.", + "help.tabs.worktrees": + "Novi dizajn još ne podržava Git Worktrees, ali podrška stiže uskoro. Ako želiš nastaviti koristiti prethodni raspored, možeš mijenjati rasporede u Postavkama. Imaj na umu da će novi raspored za nekoliko sedmica postati trajan.", + "dialog.project.edit.title": "Uredi projekat", "dialog.project.edit.name": "Naziv", "dialog.project.edit.icon": "Ikonica", @@ -723,6 +843,12 @@ export const dict = { "terminal.connectionLost.title": "Veza prekinuta", "terminal.connectionLost.description": "Veza s terminalom je prekinuta. Ovo se može desiti kada se server ponovo pokrene.", + "terminal.connectTicket.csrfError": + "PTY tiket za povezivanje odbijen je zbog provjera porijekla ili CSRF-a. Provjeri CORS konfiguraciju servera.", + "terminal.connectTicket.statusError": "PTY tiket za povezivanje nije uspio sa statusom {{status}}", + + "titlebar.update": "Ažuriraj", + "titlebar.updateVersion": "Ažuriraj {{version}}", "common.closeTab": "Zatvori karticu", "common.dismiss": "Odbaci", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index 517b01d9e6cc..899f1b1f8619 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -131,6 +131,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Log ind med Claude Pro/Max", "provider.connect.selectMethod": "Vælg loginmetode for {{provider}}.", "provider.connect.method.apiKey": "API-nøgle", + "provider.connect.method.browser": "Browser", + "provider.connect.method.headless": "Browserløs", "provider.connect.status.inProgress": "Godkendelse i gang...", "provider.connect.status.waiting": "Venter på godkendelse...", "provider.connect.status.failed": "Godkendelse mislykkedes: {{error}}", @@ -163,6 +165,7 @@ export const dict = { "provider.connect.toast.connected.description": "Modeller fra {{provider}} er nu tilgængelige.", "provider.custom.title": "Brugerdefineret udbyder", + "provider.custom.unavailable": "Brugerdefinerede udbydere er ikke tilgængelige på denne server", "provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel udbyder. Se ", "provider.custom.description.link": "dokumentation for udbyderkonfiguration", "provider.custom.description.suffix": ".", @@ -717,6 +720,13 @@ export const dict = { "terminal.connectionLost.title": "Forbindelse mistet", "terminal.connectionLost.description": "Terminalforbindelsen blev afbrudt. Dette kan ske, når serveren genstarter.", + "terminal.connectTicket.csrfError": + "PTY-forbindelsesticket blev afvist ved kontrollen af oprindelse eller CSRF. Kontrollér serverens CORS-konfiguration.", + "terminal.connectTicket.statusError": "PTY-forbindelsesticket mislykkedes med {{status}}", + + "titlebar.update": "Opdater", + "titlebar.updateVersion": "Opdater {{version}}", + "common.closeTab": "Luk fane", "common.dismiss": "Afvis", "common.requestFailed": "Forespørgsel mislykkedes", @@ -1097,4 +1107,122 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Kunne ikke oprette vedvarende projektikon", "error.childStore.storeCreateFailed": "Kunne ikke oprette lager", "terminal.connectionLost.abnormalClose": "WebSocket lukkede unormalt: {{code}}", + + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Arkiv", + "desktop.menu.edit": "Rediger", + "desktop.menu.view": "Oversigt", + "desktop.menu.go": "Gå", + "desktop.menu.window": "Vindue", + "desktop.menu.help": "Hjælp", + "desktop.menu.checkForUpdates": "Søg efter opdateringer...", + "desktop.menu.settings": "Indstillinger", + "desktop.menu.reloadWebview": "Genindlæs webvisning", + "desktop.menu.restart": "Genstart", + "desktop.menu.exportLogs": "Eksportér logfiler...", + "desktop.menu.newSession": "Ny session", + "desktop.menu.openProject": "Åbn projekt...", + "desktop.menu.newWindow": "Nyt vindue", + "desktop.menu.closeWindow": "Luk vindue", + "desktop.menu.undo": "Fortryd", + "desktop.menu.redo": "Gentag", + "desktop.menu.cut": "Klip", + "desktop.menu.copy": "Kopier", + "desktop.menu.paste": "Sæt ind", + "desktop.menu.delete": "Slet", + "desktop.menu.selectAll": "Vælg alt", + "desktop.menu.toggleSidebar": "Vis eller skjul indholdsoversigt", + "desktop.menu.toggleTerminal": "Vis eller skjul terminal", + "desktop.menu.toggleFileTree": "Vis eller skjul filtræ", + "desktop.menu.reload": "Genindlæs", + "desktop.menu.toggleDeveloperTools": "Vis eller skjul udviklerværktøjer", + "desktop.menu.actualSize": "Faktisk størrelse", + "desktop.menu.zoomIn": "Zoom ind", + "desktop.menu.zoomOut": "Zoom ud", + "desktop.menu.toggleFullScreen": "Slå fuldskærmsvisning til eller fra", + "desktop.menu.back": "Tilbage", + "desktop.menu.forward": "Frem", + "desktop.menu.previousSession": "Forrige session", + "desktop.menu.nextSession": "Næste session", + "desktop.menu.previousProject": "Forrige projekt", + "desktop.menu.nextProject": "Næste projekt", + "desktop.menu.minimize": "Minimer", + "desktop.menu.maximize": "Maksimer", + "desktop.menu.documentation": "OpenCode-dokumentation", + "desktop.menu.supportForum": "Supportforum", + "desktop.menu.shareFeedback": "Giv feedback", + "desktop.menu.reportBug": "Rapportér en fejl", + "desktop.menu.ariaLabel": "OpenCode-menu", + + "desktop.updater.dialog.checkFailed.message": "Søgningen efter opdateringer mislykkedes.", + "desktop.updater.dialog.checkFailed.title": "Opdateringsfejl", + "desktop.updater.dialog.upToDate.message": "Du er opdateret.", + "desktop.updater.dialog.upToDate.title": "Ingen opdateringer", + "desktop.updater.dialog.ready.message": "Opdatering {{version}} er downloadet. Vil du genstarte nu?", + "desktop.updater.dialog.ready.title": "Opdateringen er klar", + "desktop.updater.dialog.restart": "Genstart", + "desktop.updater.dialog.later": "Senere", + + "desktop.recovery.action.relaunch": "Start igen", + "desktop.recovery.action.exportLogs": "Eksportér logfiler", + "desktop.recovery.action.keepWaiting": "Vent fortsat", + "desktop.recovery.action.quit": "Afslut", + "desktop.recovery.loadFailed": "OpenCode kunne ikke indlæses", + "desktop.recovery.terminated": "OpenCode-vinduet blev uventet afsluttet", + "desktop.recovery.unresponsive": "OpenCode svarer ikke", + "desktop.recovery.unresponsive.detail": + "Du kan starte appen igen, åbne logfilerne eller fortsætte med at vente.", + "desktop.recovery.loadFailed.detail": "Vindue: {{window}}\nURL: {{url}}\nFejl: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Vindue: {{window}}\nÅrsag: {{reason}}\nKode: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Vælg en mappe", + "desktop.dialog.chooseFile": "Vælg en fil", + "desktop.dialog.saveFile": "Gem fil", + "desktop.dialog.files": "Filer", + "desktop.server.local": "Lokal server", + + "desktop.wsl.error.windowsOnly": "WSL er kun tilgængelig i Windows", + "desktop.wsl.error.unavailable": "WSL er ikke tilgængelig", + "desktop.wsl.error.listInstalled": "Kunne ikke vise installerede WSL-distributioner", + "desktop.wsl.error.listOnline": "Kunne ikke vise WSL-distributioner på nettet", + "desktop.wsl.error.executeDistro": "Kan ikke udføre kommandoer i distributionen", + "desktop.wsl.error.installWsl": "Installationen af WSL mislykkedes", + "desktop.wsl.error.installDistro": "Kunne ikke installere distributionen: {{distro}}", + "desktop.wsl.error.installOpencode": "Installationen af OpenCode mislykkedes", + "desktop.wsl.error.alreadyAdded": "{{distro}} er allerede tilføjet", + "desktop.wsl.error.opencodeMissing": "opencode er ikke installeret i denne distribution", + "desktop.wsl.error.opencodeCannotRun": "opencode er installeret, men kunne ikke køre", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode er ikke installeret i {{distro}}", + "desktop.wsl.error.updateVersion": + "Opdateringen af OpenCode er fuldført, men {{distro}} rapporterer stadig {{installed}}. Forventet: {{expected}}", + "desktop.wsl.error.noVersion": "ingen version", + "desktop.wsl.error.serverExited": "WSL-serveren blev afsluttet efter opstart (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL-serveren blev afsluttet, før den var klar (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Tilstandskontrollen af sidecar-processen for {{distro}} fik timeout efter {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} fik timeout efter {{timeout}} ms", + "desktop.wsl.error.failedPort": "Kunne ikke hente porten", + + "desktop.picker.error.notSelected": "Filen blev ikke valgt i filvælgeren", + "desktop.picker.error.sizeLimit": "De valgte vedhæftede filer overskrider grænsen på {{limit}} MB", + + "command.logs.export": "Eksportér logfiler", + + "help.tabs.toast.ariaLabel": "Introduktion til faner. Organiser dit arbejde og dine aktive sessioner med faner", + "help.tabs.toast.dismiss": "Luk oplysninger om faner", + "help.tabs.title": "Introduktion til faner", + "help.tabs.description": "Organiser dit arbejde og dine aktive sessioner med faner", + "help.tabs.date": "14. juli", + "help.tabs.introduction": "OpenCode Desktop er nu bygget op omkring faner.", + "help.tabs.sessions": + "Start en ny session i en fane, eller åbn en eksisterende session fra et af dine projekter. Åbn en ny fane, når du går i gang med noget nyt, og luk den, når du er færdig.", + "help.tabs.organize": + "Når du holder nogle få faner åbne, er det nemmere at organisere dine aktive sessioner. Giv fanerne navne, der er nemme at huske, hvis du vil beholde dem.", + "help.tabs.home": + "Du finder alle dine sessioner og projekter på den nye startskærm. Når du vælger en session, åbnes den i en fane.", + "help.tabs.persistence": "Dine faner er stadig åbne, når du åbner appen igen.", + "help.tabs.worktrees": + "Det nye design understøtter endnu ikke Git Worktrees, men understøttelsen kommer snart. Hvis du foretrækker at fortsætte med det tidligere layout, kan du skifte mellem layoutene under Indstillinger. Bemærk, at det nye layout bliver permanent om nogle få uger.", } diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index fc3ff8cd5a11..dfde96337604 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -123,6 +123,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Mit Claude Pro/Max anmelden", "provider.connect.selectMethod": "Anmeldemethode für {{provider}} auswählen.", "provider.connect.method.apiKey": "API-Schlüssel", + "provider.connect.method.browser": "Browser", + "provider.connect.method.headless": "Browserlos", "provider.connect.status.inProgress": "Autorisierung läuft…", "provider.connect.status.waiting": "Warten auf Autorisierung…", "provider.connect.status.failed": "Autorisierung fehlgeschlagen: {{error}}", @@ -154,6 +156,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} verbunden", "provider.connect.toast.connected.description": "{{provider}}-Modelle sind jetzt verfügbar.", "provider.custom.title": "Benutzerdefinierter Anbieter", + "provider.custom.unavailable": "Benutzerdefinierte Anbieter sind auf diesem Server nicht verfügbar", "provider.custom.description.prefix": "Konfigurieren Sie einen OpenAI-kompatiblen Anbieter. Siehe die ", "provider.custom.description.link": "Dokumentation zur Anbieterkonfiguration", "provider.custom.description.suffix": ".", @@ -670,6 +673,13 @@ export const dict = { "terminal.connectionLost.title": "Verbindung verloren", "terminal.connectionLost.description": "Die Terminalverbindung wurde unterbrochen. Das kann passieren, wenn der Server neu startet.", + "terminal.connectTicket.csrfError": + "Das PTY-Verbindungsticket wurde bei der Ursprungs- oder CSRF-Prüfung abgelehnt. Überprüfen Sie die CORS-Konfiguration des Servers.", + "terminal.connectTicket.statusError": "Das PTY-Verbindungsticket ist mit {{status}} fehlgeschlagen", + + "titlebar.update": "Aktualisieren", + "titlebar.updateVersion": "Aktualisieren {{version}}", + "common.closeTab": "Tab schließen", "common.dismiss": "Verwerfen", "common.requestFailed": "Anfrage fehlgeschlagen", @@ -1042,4 +1052,115 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Dauerhaftes Projektsymbol konnte nicht erstellt werden", "error.childStore.storeCreateFailed": "Speicher konnte nicht erstellt werden", "terminal.connectionLost.abnormalClose": "WebSocket unerwartet geschlossen: {{code}}", + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Datei", + "desktop.menu.edit": "Bearbeiten", + "desktop.menu.view": "Ansicht", + "desktop.menu.go": "Gehe zu", + "desktop.menu.window": "Fenster", + "desktop.menu.help": "Hilfe", + "desktop.menu.checkForUpdates": "Nach Updates suchen…", + "desktop.menu.settings": "Einstellungen", + "desktop.menu.reloadWebview": "Webview neu laden", + "desktop.menu.restart": "Neu starten", + "desktop.menu.exportLogs": "Protokolle exportieren…", + "desktop.menu.newSession": "Neue Sitzung", + "desktop.menu.openProject": "Projekt öffnen…", + "desktop.menu.newWindow": "Neues Fenster", + "desktop.menu.closeWindow": "Fenster schließen", + "desktop.menu.undo": "Rückgängig", + "desktop.menu.redo": "Wiederholen", + "desktop.menu.cut": "Ausschneiden", + "desktop.menu.copy": "Kopieren", + "desktop.menu.paste": "Einfügen", + "desktop.menu.delete": "Löschen", + "desktop.menu.selectAll": "Alles auswählen", + "desktop.menu.toggleSidebar": "Seitenleiste umschalten", + "desktop.menu.toggleTerminal": "Terminal umschalten", + "desktop.menu.toggleFileTree": "Dateibaum umschalten", + "desktop.menu.reload": "Neu laden", + "desktop.menu.toggleDeveloperTools": "Entwicklertools umschalten", + "desktop.menu.actualSize": "Tatsächliche Größe", + "desktop.menu.zoomIn": "Vergrößern", + "desktop.menu.zoomOut": "Verkleinern", + "desktop.menu.toggleFullScreen": "Vollbildmodus umschalten", + "desktop.menu.back": "Zurück", + "desktop.menu.forward": "Vorwärts", + "desktop.menu.previousSession": "Vorherige Sitzung", + "desktop.menu.nextSession": "Nächste Sitzung", + "desktop.menu.previousProject": "Vorheriges Projekt", + "desktop.menu.nextProject": "Nächstes Projekt", + "desktop.menu.minimize": "Minimieren", + "desktop.menu.maximize": "Maximieren", + "desktop.menu.documentation": "OpenCode-Dokumentation", + "desktop.menu.supportForum": "Supportforum", + "desktop.menu.shareFeedback": "Feedback senden", + "desktop.menu.reportBug": "Fehler melden", + "desktop.menu.ariaLabel": "OpenCode-Menü", + "desktop.updater.dialog.checkFailed.message": "Die Suche nach Updates ist fehlgeschlagen.", + "desktop.updater.dialog.checkFailed.title": "Updatefehler", + "desktop.updater.dialog.upToDate.message": "Sie sind auf dem neuesten Stand.", + "desktop.updater.dialog.upToDate.title": "Keine Updates", + "desktop.updater.dialog.ready.message": "Update {{version}} wurde heruntergeladen. Jetzt neu starten?", + "desktop.updater.dialog.ready.title": "Update bereit", + "desktop.updater.dialog.restart": "Neu starten", + "desktop.updater.dialog.later": "Später", + "desktop.recovery.action.relaunch": "Neu starten", + "desktop.recovery.action.exportLogs": "Protokolle exportieren", + "desktop.recovery.action.keepWaiting": "Weiter warten", + "desktop.recovery.action.quit": "Beenden", + "desktop.recovery.loadFailed": "OpenCode konnte nicht geladen werden", + "desktop.recovery.terminated": "Das OpenCode-Fenster wurde unerwartet beendet", + "desktop.recovery.unresponsive": "OpenCode reagiert nicht", + "desktop.recovery.unresponsive.detail": + "Sie können die App neu starten, die Protokolle öffnen oder weiter warten.", + "desktop.recovery.loadFailed.detail": "Fenster: {{window}}\nURL: {{url}}\nFehler: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Fenster: {{window}}\nGrund: {{reason}}\nCode: {{code}}", + "desktop.recovery.unknown": "", + "desktop.dialog.chooseFolder": "Ordner auswählen", + "desktop.dialog.chooseFile": "Datei auswählen", + "desktop.dialog.saveFile": "Datei speichern", + "desktop.dialog.files": "Dateien", + "desktop.server.local": "Lokaler Server", + "desktop.wsl.error.windowsOnly": "WSL ist nur unter Windows verfügbar", + "desktop.wsl.error.unavailable": "WSL ist nicht verfügbar", + "desktop.wsl.error.listInstalled": "Installierte WSL-Distributionen konnten nicht aufgelistet werden", + "desktop.wsl.error.listOnline": "Verfügbare WSL-Distributionen konnten nicht aufgelistet werden", + "desktop.wsl.error.executeDistro": "In der Distribution können keine Befehle ausgeführt werden", + "desktop.wsl.error.installWsl": "WSL-Installation fehlgeschlagen", + "desktop.wsl.error.installDistro": "Distribution konnte nicht installiert werden: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode-Installation fehlgeschlagen", + "desktop.wsl.error.alreadyAdded": "{{distro}} wurde bereits hinzugefügt", + "desktop.wsl.error.opencodeMissing": "opencode ist in dieser Distribution nicht installiert", + "desktop.wsl.error.opencodeCannotRun": "opencode ist installiert, konnte aber nicht ausgeführt werden", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode ist in {{distro}} nicht installiert", + "desktop.wsl.error.updateVersion": + "Das OpenCode-Update wurde abgeschlossen, aber {{distro}} meldet weiterhin {{installed}}; erwartet wurde {{expected}}", + "desktop.wsl.error.noVersion": "keine Version", + "desktop.wsl.error.serverExited": "WSL-Server wurde nach dem Start beendet (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL-Server wurde beendet, bevor er betriebsbereit war (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Zeitüberschreitung bei der Integritätsprüfung des Sidecars für {{distro}} nach {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "Zeitüberschreitung bei {{command}} {{args}} nach {{timeout}} ms", + "desktop.wsl.error.failedPort": "Port konnte nicht abgerufen werden", + "desktop.picker.error.notSelected": "Im Auswahldialog wurde keine Datei ausgewählt", + "desktop.picker.error.sizeLimit": "Die ausgewählten Anhänge überschreiten das Limit von {{limit}} MB", + "command.logs.export": "Protokolle exportieren", + "help.tabs.toast.ariaLabel": + "Neu: Tabs. Organisieren Sie Ihre Arbeit und aktiven Sitzungen mithilfe von Tabs", + "help.tabs.toast.dismiss": "Informationen zu Tabs schließen", + "help.tabs.title": "Neu: Tabs", + "help.tabs.description": "Organisieren Sie Ihre Arbeit und aktiven Sitzungen mithilfe von Tabs", + "help.tabs.date": "14. Juli", + "help.tabs.introduction": "OpenCode Desktop basiert jetzt auf Tabs.", + "help.tabs.sessions": + "Starten Sie eine neue Sitzung in einem Tab oder öffnen Sie eine vorhandene Sitzung aus einem Ihrer Projekte. Öffnen Sie einen neuen Tab, wenn Sie etwas Neues beginnen, und schließen Sie ihn, wenn Sie fertig sind.", + "help.tabs.organize": + "Wenn Sie einige Tabs geöffnet lassen, können Sie Ihre aktiven Sitzungen leichter organisieren. Benennen Sie Tabs einprägsam, wenn Sie sie länger behalten möchten.", + "help.tabs.home": + "Auf der neuen Startseite finden Sie alle Ihre Sitzungen und Projekte. Wenn Sie eine Sitzung auswählen, wird sie in einem Tab geöffnet.", + "help.tabs.persistence": "Wenn Sie die App erneut öffnen, sind Ihre Tabs weiterhin geöffnet.", + "help.tabs.worktrees": + "Das neue Design unterstützt Git-Worktrees noch nicht. Diese Funktion folgt in Kürze. Wenn Sie das bisherige Layout vorerst weiterverwenden möchten, können Sie in den Einstellungen zwischen den Layouts wechseln. Beachten Sie jedoch, dass das neue Layout in einigen Wochen dauerhaft verwendet wird.", } satisfies Partial> diff --git a/packages/app/src/i18n/desktop-native.test.ts b/packages/app/src/i18n/desktop-native.test.ts new file mode 100644 index 000000000000..04a888db066c --- /dev/null +++ b/packages/app/src/i18n/desktop-native.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, test } from "bun:test" +import { + createDesktopNativeBundle, + DESKTOP_NATIVE_ENGLISH, + DESKTOP_NATIVE_KEYS, + DESKTOP_NATIVE_MAX_PAYLOAD_BYTES, + formatDesktopNativeMessage, + parseDesktopNativeBundle, +} from "./desktop-native" + +describe("desktop native translations", () => { + test("accepts the exact typed bundle", () => { + const bundle = createDesktopNativeBundle("en", (key) => DESKTOP_NATIVE_ENGLISH[key]) + expect(parseDesktopNativeBundle(bundle)).toEqual(bundle) + }) + + test("rejects unsupported locales and mismatched key sets", () => { + const bundle = createDesktopNativeBundle("en", (key) => DESKTOP_NATIVE_ENGLISH[key]) + expect(parseDesktopNativeBundle({ ...bundle, locale: "en-US" })).toBeUndefined() + expect( + parseDesktopNativeBundle({ + ...bundle, + messages: Object.fromEntries(DESKTOP_NATIVE_KEYS.slice(1).map((key) => [key, bundle.messages[key]])), + }), + ).toBeUndefined() + expect(parseDesktopNativeBundle({ ...bundle, messages: { ...bundle.messages, extra: "no" } })).toBeUndefined() + expect( + parseDesktopNativeBundle({ + ...bundle, + messages: { ...bundle.messages, [DESKTOP_NATIVE_KEYS[0]]: "x".repeat(DESKTOP_NATIVE_MAX_PAYLOAD_BYTES) }, + }), + ).toBeUndefined() + expect( + parseDesktopNativeBundle({ ...bundle, messages: { ...bundle.messages, [DESKTOP_NATIVE_KEYS[0]]: 1 } }), + ).toBeUndefined() + }) + + test("interpolates native templates without changing unknown placeholders", () => { + expect(formatDesktopNativeMessage("{{known}} {{unknown}}", { known: "yes" })).toBe("yes {{unknown}}") + }) +}) diff --git a/packages/app/src/i18n/desktop-native.ts b/packages/app/src/i18n/desktop-native.ts new file mode 100644 index 000000000000..3c181c1ad55f --- /dev/null +++ b/packages/app/src/i18n/desktop-native.ts @@ -0,0 +1,175 @@ +export const DESKTOP_NATIVE_LOCALES = [ + "en", + "zh", + "zht", + "ko", + "de", + "es", + "fr", + "da", + "ja", + "pl", + "ru", + "uk", + "bs", + "ar", + "no", + "br", + "th", + "tr", + "hi", + "nl", + "id", + "vi", + "it", + "ur", + "pa", + "az", + "fi", + "sv", +] as const + +export type DesktopNativeLocale = (typeof DESKTOP_NATIVE_LOCALES)[number] + +export const DESKTOP_NATIVE_ENGLISH = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "File", + "desktop.menu.edit": "Edit", + "desktop.menu.view": "View", + "desktop.menu.go": "Go", + "desktop.menu.window": "Window", + "desktop.menu.help": "Help", + "desktop.menu.checkForUpdates": "Check for Updates...", + "desktop.menu.settings": "Settings", + "desktop.menu.reloadWebview": "Reload Webview", + "desktop.menu.restart": "Restart", + "desktop.menu.exportLogs": "Export Logs...", + "desktop.menu.newSession": "New Session", + "desktop.menu.openProject": "Open Project...", + "desktop.menu.newWindow": "New Window", + "desktop.menu.closeWindow": "Close Window", + "desktop.menu.undo": "Undo", + "desktop.menu.redo": "Redo", + "desktop.menu.cut": "Cut", + "desktop.menu.copy": "Copy", + "desktop.menu.paste": "Paste", + "desktop.menu.delete": "Delete", + "desktop.menu.selectAll": "Select All", + "desktop.menu.toggleSidebar": "Toggle Sidebar", + "desktop.menu.toggleTerminal": "Toggle Terminal", + "desktop.menu.toggleFileTree": "Toggle File Tree", + "desktop.menu.reload": "Reload", + "desktop.menu.toggleDeveloperTools": "Toggle Developer Tools", + "desktop.menu.actualSize": "Actual Size", + "desktop.menu.zoomIn": "Zoom In", + "desktop.menu.zoomOut": "Zoom Out", + "desktop.menu.toggleFullScreen": "Toggle Full Screen", + "desktop.menu.back": "Back", + "desktop.menu.forward": "Forward", + "desktop.menu.previousSession": "Previous Session", + "desktop.menu.nextSession": "Next Session", + "desktop.menu.previousProject": "Previous Project", + "desktop.menu.nextProject": "Next Project", + "desktop.menu.minimize": "Minimize", + "desktop.menu.maximize": "Maximize", + "desktop.menu.documentation": "OpenCode Documentation", + "desktop.menu.supportForum": "Support Forum", + "desktop.menu.shareFeedback": "Share Feedback", + "desktop.menu.reportBug": "Report a Bug", + "desktop.menu.ariaLabel": "OpenCode menu", + + "desktop.updater.dialog.checkFailed.message": "Update check failed.", + "desktop.updater.dialog.checkFailed.title": "Update Error", + "desktop.updater.dialog.upToDate.message": "You're up to date.", + "desktop.updater.dialog.upToDate.title": "No Updates", + "desktop.updater.dialog.ready.message": "Update {{version}} downloaded. Restart now?", + "desktop.updater.dialog.ready.title": "Update Ready", + "desktop.updater.dialog.restart": "Restart", + "desktop.updater.dialog.later": "Later", + + "desktop.recovery.action.relaunch": "Relaunch", + "desktop.recovery.action.exportLogs": "Export Logs", + "desktop.recovery.action.keepWaiting": "Keep Waiting", + "desktop.recovery.action.quit": "Quit", + "desktop.recovery.loadFailed": "OpenCode failed to load", + "desktop.recovery.terminated": "OpenCode window terminated unexpectedly", + "desktop.recovery.unresponsive": "OpenCode is not responding", + "desktop.recovery.unresponsive.detail": "You can relaunch the app, open the logs, or keep waiting.", + "desktop.recovery.loadFailed.detail": "Window: {{window}}\nURL: {{url}}\nError: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Window: {{window}}\nReason: {{reason}}\nCode: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Choose a folder", + "desktop.dialog.chooseFile": "Choose a file", + "desktop.dialog.saveFile": "Save file", + "desktop.dialog.files": "Files", + "desktop.server.local": "Local Server", + + "desktop.wsl.error.windowsOnly": "WSL is only available on Windows", + "desktop.wsl.error.unavailable": "WSL is unavailable", + "desktop.wsl.error.listInstalled": "Failed to list installed WSL distros", + "desktop.wsl.error.listOnline": "Failed to list online WSL distros", + "desktop.wsl.error.executeDistro": "Cannot execute commands in distro", + "desktop.wsl.error.installWsl": "WSL installation failed", + "desktop.wsl.error.installDistro": "Failed to install distro: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode installation failed", + "desktop.wsl.error.alreadyAdded": "{{distro}} is already added", + "desktop.wsl.error.opencodeMissing": "opencode is not installed in this distro", + "desktop.wsl.error.opencodeCannotRun": "opencode is installed but could not run", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode is not installed in {{distro}}", + "desktop.wsl.error.updateVersion": + "OpenCode update finished but {{distro}} still reports {{installed}}; expected {{expected}}", + "desktop.wsl.error.noVersion": "no version", + "desktop.wsl.error.serverExited": "WSL server exited after startup (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL server exited before becoming healthy (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": "Sidecar for {{distro}} health check timed out after {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} timed out after {{timeout}}ms", + "desktop.wsl.error.failedPort": "Failed to get port", + + "desktop.picker.error.notSelected": "File was not selected by the picker", + "desktop.picker.error.sizeLimit": "Selected attachments exceed the {{limit}} MB limit", +} as const + +export type DesktopNativeKey = keyof typeof DESKTOP_NATIVE_ENGLISH +export type DesktopNativeMessages = Record +export type DesktopNativeBundle = { locale: DesktopNativeLocale; messages: DesktopNativeMessages } + +export const DESKTOP_NATIVE_KEYS = Object.keys(DESKTOP_NATIVE_ENGLISH) as DesktopNativeKey[] +export const DESKTOP_NATIVE_MAX_PAYLOAD_BYTES = 64 * 1024 + +export function createDesktopNativeBundle( + locale: DesktopNativeLocale, + translate: (key: DesktopNativeKey) => string, +): DesktopNativeBundle { + return { + locale, + messages: Object.fromEntries(DESKTOP_NATIVE_KEYS.map((key) => [key, translate(key)])) as DesktopNativeMessages, + } +} + +export function parseDesktopNativeBundle(value: unknown): DesktopNativeBundle | undefined { + if (!value || typeof value !== "object" || Array.isArray(value)) return undefined + try { + if (new TextEncoder().encode(JSON.stringify(value)).byteLength > DESKTOP_NATIVE_MAX_PAYLOAD_BYTES) return undefined + } catch { + return undefined + } + const bundle = value as { locale?: unknown; messages?: unknown } + if (!DESKTOP_NATIVE_LOCALES.some((locale) => locale === bundle.locale)) return undefined + if (!bundle.messages || typeof bundle.messages !== "object" || Array.isArray(bundle.messages)) return undefined + const messages = bundle.messages as Record + const keys = Object.keys(messages) + if (keys.length !== DESKTOP_NATIVE_KEYS.length) return undefined + if (!DESKTOP_NATIVE_KEYS.every((key) => typeof messages[key] === "string")) return undefined + if (!keys.every((key) => key in DESKTOP_NATIVE_ENGLISH)) return undefined + return bundle as DesktopNativeBundle +} + +export function formatDesktopNativeMessage(message: string, params?: Record) { + if (!params) return message + return message.replace(/\{\{([^{}]+)\}\}/g, (match, key: string) => { + const value = params[key] + return value === undefined ? match : String(value) + }) +} diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 76623f44685f..5e24547d74ff 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -1,4 +1,7 @@ +import { DESKTOP_NATIVE_ENGLISH } from "./desktop-native" + export const dict = { + ...DESKTOP_NATIVE_ENGLISH, "command.category.suggested": "Suggested", "command.category.view": "View", "command.category.project": "Project", @@ -16,6 +19,7 @@ export const dict = { "command.category.permissions": "Permissions", "command.category.workspace": "Workspace", "command.category.settings": "Settings", + "command.logs.export": "Export logs", "theme.scheme.system": "System", "theme.scheme.light": "Light", @@ -131,6 +135,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Login with Claude Pro/Max", "provider.connect.selectMethod": "Select login method for {{provider}}.", "provider.connect.method.apiKey": "API key", + "provider.connect.method.browser": "Browser", + "provider.connect.method.headless": "Headless", "provider.connect.status.inProgress": "Authorization in progress...", "provider.connect.status.waiting": "Waiting for authorization...", "provider.connect.status.failed": "Authorization failed: {{error}}", @@ -163,6 +169,7 @@ export const dict = { "provider.connect.toast.connected.description": "{{provider}} models are now available to use.", "provider.custom.title": "Custom provider", + "provider.custom.unavailable": "Custom providers are unavailable on this server", "provider.custom.description.prefix": "Configure an OpenAI-compatible provider. See the ", "provider.custom.description.link": "provider config docs", "provider.custom.description.suffix": ".", @@ -437,6 +444,22 @@ export const dict = { "wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.versionMismatch": "Installed version does not match the desktop app version.", "wsl.onboarding.adding": "Adding...", + + "help.tabs.toast.ariaLabel": "Introducing Tabs. Organize your work and active sessions with tabs", + "help.tabs.toast.dismiss": "Dismiss Tabs information", + "help.tabs.title": "Introducing Tabs", + "help.tabs.description": "Organize your work and active sessions with tabs", + "help.tabs.date": "July 14", + "help.tabs.introduction": "OpenCode Desktop is now built around tabs.", + "help.tabs.sessions": + "Start a new session in a tab, or open an existing session from any of your projects. Open a new tab when you're starting something new, and close it when you're done.", + "help.tabs.organize": + "Keeping a few tabs open makes it easier to organize your active sessions. Rename tabs to something memorable if you plan to keep them around.", + "help.tabs.home": + "You'll find all your sessions and projects on the new Home screen. Selecting a session opens it in a tab.", + "help.tabs.persistence": "When you reopen the app, your tabs are still open.", + "help.tabs.worktrees": + "The new design does not support Git Worktrees yet, it's coming soon. So if you'd prefer to continue using the previous layout, you can switch between layouts in Settings. Just keep in mind that the new layout will become permanent in a few weeks.", "server.row.noUsername": "no username", "dialog.project.edit.title": "Edit project", @@ -765,6 +788,12 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket closed abnormally: {{code}}", "terminal.connectionLost.description": "The terminal connection was interrupted. This can happen when the server restarts.", + "terminal.connectTicket.csrfError": + "PTY connect ticket rejected by origin or CSRF checks. Check the server CORS config.", + "terminal.connectTicket.statusError": "PTY connect ticket failed with {{status}}", + + "titlebar.update": "Update", + "titlebar.updateVersion": "Update {{version}}", "common.closeTab": "Close tab", "common.dismiss": "Dismiss", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index d133c4c796d5..6fe7d6baf8bd 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -1,4 +1,106 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Archivo", + "desktop.menu.edit": "Editar", + "desktop.menu.view": "Ver", + "desktop.menu.go": "Ir", + "desktop.menu.window": "Ventana", + "desktop.menu.help": "Ayuda", + "desktop.menu.checkForUpdates": "Buscar actualizaciones...", + "desktop.menu.settings": "Ajustes", + "desktop.menu.reloadWebview": "Volver a cargar la vista web", + "desktop.menu.restart": "Reiniciar", + "desktop.menu.exportLogs": "Exportar registros...", + "desktop.menu.newSession": "Nueva sesión", + "desktop.menu.openProject": "Abrir proyecto...", + "desktop.menu.newWindow": "Nueva ventana", + "desktop.menu.closeWindow": "Cerrar ventana", + "desktop.menu.undo": "Deshacer", + "desktop.menu.redo": "Rehacer", + "desktop.menu.cut": "Cortar", + "desktop.menu.copy": "Copiar", + "desktop.menu.paste": "Pegar", + "desktop.menu.delete": "Eliminar", + "desktop.menu.selectAll": "Seleccionar todo", + "desktop.menu.toggleSidebar": "Mostrar u ocultar barra lateral", + "desktop.menu.toggleTerminal": "Mostrar u ocultar terminal", + "desktop.menu.toggleFileTree": "Mostrar u ocultar árbol de archivos", + "desktop.menu.reload": "Volver a cargar", + "desktop.menu.toggleDeveloperTools": "Mostrar u ocultar herramientas para desarrolladores", + "desktop.menu.actualSize": "Tamaño real", + "desktop.menu.zoomIn": "Ampliar", + "desktop.menu.zoomOut": "Reducir", + "desktop.menu.toggleFullScreen": "Mostrar u ocultar pantalla completa", + "desktop.menu.back": "Atrás", + "desktop.menu.forward": "Adelante", + "desktop.menu.previousSession": "Sesión anterior", + "desktop.menu.nextSession": "Siguiente sesión", + "desktop.menu.previousProject": "Proyecto anterior", + "desktop.menu.nextProject": "Siguiente proyecto", + "desktop.menu.minimize": "Minimizar", + "desktop.menu.maximize": "Maximizar", + "desktop.menu.documentation": "Documentación de OpenCode", + "desktop.menu.supportForum": "Foro de asistencia", + "desktop.menu.shareFeedback": "Enviar comentarios", + "desktop.menu.reportBug": "Informar de un error", + "desktop.menu.ariaLabel": "Menú de OpenCode", + + "desktop.updater.dialog.checkFailed.message": "No se pudo buscar actualizaciones.", + "desktop.updater.dialog.checkFailed.title": "Error de actualización", + "desktop.updater.dialog.upToDate.message": "Estás al día.", + "desktop.updater.dialog.upToDate.title": "No hay actualizaciones", + "desktop.updater.dialog.ready.message": "Se ha descargado la actualización {{version}}. ¿Quieres reiniciar ahora?", + "desktop.updater.dialog.ready.title": "Actualización lista", + "desktop.updater.dialog.restart": "Reiniciar", + "desktop.updater.dialog.later": "Más tarde", + + "desktop.recovery.action.relaunch": "Volver a iniciar", + "desktop.recovery.action.exportLogs": "Exportar registros", + "desktop.recovery.action.keepWaiting": "Seguir esperando", + "desktop.recovery.action.quit": "Salir", + "desktop.recovery.loadFailed": "No se pudo cargar OpenCode", + "desktop.recovery.terminated": "La ventana de OpenCode se cerró de forma inesperada", + "desktop.recovery.unresponsive": "OpenCode no responde", + "desktop.recovery.unresponsive.detail": + "Puedes volver a iniciar la aplicación, abrir los registros o seguir esperando.", + "desktop.recovery.loadFailed.detail": "Ventana: {{window}}\nURL: {{url}}\nError: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Ventana: {{window}}\nMotivo: {{reason}}\nCódigo: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Elegir una carpeta", + "desktop.dialog.chooseFile": "Elegir un archivo", + "desktop.dialog.saveFile": "Guardar archivo", + "desktop.dialog.files": "Archivos", + "desktop.server.local": "Servidor local", + + "desktop.wsl.error.windowsOnly": "WSL solo está disponible en Windows", + "desktop.wsl.error.unavailable": "WSL no está disponible", + "desktop.wsl.error.listInstalled": "No se pudieron listar las distribuciones WSL instaladas", + "desktop.wsl.error.listOnline": "No se pudieron listar las distribuciones WSL en línea", + "desktop.wsl.error.executeDistro": "No se pueden ejecutar comandos en la distribución", + "desktop.wsl.error.installWsl": "No se pudo instalar WSL", + "desktop.wsl.error.installDistro": "No se pudo instalar la distribución: {{distro}}", + "desktop.wsl.error.installOpencode": "No se pudo instalar OpenCode", + "desktop.wsl.error.alreadyAdded": "Ya se ha añadido {{distro}}", + "desktop.wsl.error.opencodeMissing": "opencode no está instalado en esta distribución", + "desktop.wsl.error.opencodeCannotRun": "opencode está instalado, pero no se pudo ejecutar", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode no está instalado en {{distro}}", + "desktop.wsl.error.updateVersion": + "La actualización de OpenCode finalizó, pero {{distro}} sigue indicando {{installed}}; se esperaba {{expected}}", + "desktop.wsl.error.noVersion": "sin versión", + "desktop.wsl.error.serverExited": + "El servidor WSL se cerró después de iniciarse (código={{code}} señal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "El servidor WSL se cerró antes de estar operativo (código={{code}} señal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Se agotó el tiempo de espera de la comprobación de estado del sidecar de {{distro}} tras {{timeout}} ms", + "desktop.wsl.error.commandTimeout": + "Se agotó el tiempo de espera de {{command}} {{args}} tras {{timeout}} ms", + "desktop.wsl.error.failedPort": "No se pudo obtener el puerto", + + "desktop.picker.error.notSelected": "No se seleccionó ningún archivo en el selector", + "desktop.picker.error.sizeLimit": "Los adjuntos seleccionados superan el límite de {{limit}} MB", + "command.category.suggested": "Sugerido", "command.category.view": "Ver", "command.category.project": "Proyecto", @@ -16,6 +118,7 @@ export const dict = { "command.category.permissions": "Permisos", "command.category.workspace": "Espacio de trabajo", "command.category.settings": "Ajustes", + "command.logs.export": "Exportar registros", "theme.scheme.system": "Sistema", "theme.scheme.light": "Claro", @@ -131,6 +234,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Iniciar sesión con Claude Pro/Max", "provider.connect.selectMethod": "Seleccionar método de inicio de sesión para {{provider}}.", "provider.connect.method.apiKey": "Clave API", + "provider.connect.method.browser": "Navegador", + "provider.connect.method.headless": "Sin navegador", "provider.connect.status.inProgress": "Autorización en progreso...", "provider.connect.status.waiting": "Esperando autorización...", "provider.connect.status.failed": "Autorización fallida: {{error}}", @@ -163,6 +268,7 @@ export const dict = { "provider.connect.toast.connected.description": "Los modelos de {{provider}} ahora están disponibles para usar.", "provider.custom.title": "Proveedor personalizado", + "provider.custom.unavailable": "Los proveedores personalizados no están disponibles en este servidor", "provider.custom.description.prefix": "Configurar un proveedor compatible con OpenAI. Ver la ", "provider.custom.description.link": "documentación de configuración del proveedor", "provider.custom.description.suffix": ".", @@ -434,6 +540,23 @@ export const dict = { "wsl.onboarding.versionMismatch": "La versión instalada no coincide con la versión de la aplicación de escritorio.", "wsl.onboarding.adding": "Añadiendo...", + "help.tabs.toast.ariaLabel": + "Presentamos las pestañas. Organiza tu trabajo y las sesiones activas con pestañas", + "help.tabs.toast.dismiss": "Descartar información sobre las pestañas", + "help.tabs.title": "Presentamos las pestañas", + "help.tabs.description": "Organiza tu trabajo y las sesiones activas con pestañas", + "help.tabs.date": "14 de julio", + "help.tabs.introduction": "OpenCode Desktop ahora se organiza en torno a pestañas.", + "help.tabs.sessions": + "Inicia una nueva sesión en una pestaña o abre una sesión existente de cualquiera de tus proyectos. Abre una pestaña nueva cuando empieces algo nuevo y ciérrala cuando termines.", + "help.tabs.organize": + "Mantener algunas pestañas abiertas facilita la organización de las sesiones activas. Cambia el nombre de las pestañas por uno fácil de recordar si piensas conservarlas.", + "help.tabs.home": + "Encontrarás todas tus sesiones y proyectos en la nueva pantalla Inicio. Al seleccionar una sesión, se abre en una pestaña.", + "help.tabs.persistence": "Cuando vuelvas a abrir la aplicación, las pestañas seguirán abiertas.", + "help.tabs.worktrees": + "El nuevo diseño aún no admite Git Worktrees, pero estarán disponibles pronto. Si prefieres seguir usando el diseño anterior, puedes cambiar de diseño en Ajustes. Ten en cuenta que el nuevo diseño será permanente dentro de unas semanas.", + "dialog.project.edit.title": "Editar proyecto", "dialog.project.edit.name": "Nombre", "dialog.project.edit.icon": "Icono", @@ -728,6 +851,12 @@ export const dict = { "terminal.connectionLost.title": "Conexión perdida", "terminal.connectionLost.description": "La conexión del terminal se interrumpió. Esto puede ocurrir cuando el servidor se reinicia.", + "terminal.connectTicket.csrfError": + "El ticket de conexión PTY fue rechazado por las comprobaciones de origen o CSRF. Comprueba la configuración CORS del servidor.", + "terminal.connectTicket.statusError": "El ticket de conexión PTY falló con {{status}}", + + "titlebar.update": "Actualizar", + "titlebar.updateVersion": "Actualizar {{version}}", "common.closeTab": "Cerrar pestaña", "common.dismiss": "Descartar", diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts index 573e2526c7e3..563b1c60fb85 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -119,6 +119,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Kirjaudu sisään Claude Pro/Maxilla", "provider.connect.selectMethod": "Valitse kirjautumistapa palvelulle {{provider}}.", "provider.connect.method.apiKey": "API-avain", + "provider.connect.method.browser": "Selain", + "provider.connect.method.headless": "Päätön", "provider.connect.status.inProgress": "Valtuutus käynnissä...", "provider.connect.status.waiting": "Odotetaan valtuutusta...", "provider.connect.status.failed": "Valtuutus epäonnistui: {{error}}", @@ -150,6 +152,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} yhdistetty", "provider.connect.toast.connected.description": "{{provider}}-mallit ovat nyt käytettävissä.", "provider.custom.title": "Mukautettu palveluntarjoaja", + "provider.custom.unavailable": "Mukautetut palveluntarjoajat eivät ole käytettävissä tällä palvelimella", "provider.custom.description.prefix": "Määritä OpenAI-yhteensopiva palveluntarjoaja. Katso ", "provider.custom.description.link": "palveluntarjoajan asetusdokumentit", "provider.custom.description.suffix": ".", @@ -707,6 +710,11 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket sulkeutui poikkeavasti: {{code}}", "terminal.connectionLost.description": "Terminaaliyhteys katkesi. Näin voi tapahtua, kun palvelin käynnistyy uudelleen.", + "terminal.connectTicket.csrfError": + "PTY-yhteyslippu hylättiin alkuperä- tai CSRF-tarkistuksessa. Tarkista palvelimen CORS-määritys.", + "terminal.connectTicket.statusError": "PTY-yhteyslippu epäonnistui tilakoodilla {{status}}", + "titlebar.update": "Päivitä", + "titlebar.updateVersion": "Päivitä {{version}}", "common.closeTab": "Sulje välilehti", "common.dismiss": "Hylkää", "common.moreCountSuffix": " (+{{count}} muuta)", @@ -1027,4 +1035,117 @@ export const dict = { "workspace.reset.archived.one": "1 istunto arkistoidaan.", "workspace.reset.archived.many": "{{count}} istuntoa arkistoidaan.", "workspace.reset.note": "Tämä palauttaa työtilan vastaamaan oletushaaraa.", + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Tiedosto", + "desktop.menu.edit": "Muokkaa", + "desktop.menu.view": "Näytä", + "desktop.menu.go": "Siirry", + "desktop.menu.window": "Ikkuna", + "desktop.menu.help": "Ohje", + "desktop.menu.checkForUpdates": "Tarkista päivitykset...", + "desktop.menu.settings": "Asetukset", + "desktop.menu.reloadWebview": "Lataa verkkonäkymä uudelleen", + "desktop.menu.restart": "Käynnistä uudelleen", + "desktop.menu.exportLogs": "Vie lokit...", + "desktop.menu.newSession": "Uusi istunto", + "desktop.menu.openProject": "Avaa projekti...", + "desktop.menu.newWindow": "Uusi ikkuna", + "desktop.menu.closeWindow": "Sulje ikkuna", + "desktop.menu.undo": "Kumoa", + "desktop.menu.redo": "Tee uudelleen", + "desktop.menu.cut": "Leikkaa", + "desktop.menu.copy": "Kopioi", + "desktop.menu.paste": "Liitä", + "desktop.menu.delete": "Poista", + "desktop.menu.selectAll": "Valitse kaikki", + "desktop.menu.toggleSidebar": "Näytä tai piilota sivupalkki", + "desktop.menu.toggleTerminal": "Näytä tai piilota terminaali", + "desktop.menu.toggleFileTree": "Näytä tai piilota tiedostopuu", + "desktop.menu.reload": "Lataa uudelleen", + "desktop.menu.toggleDeveloperTools": "Näytä tai piilota kehittäjätyökalut", + "desktop.menu.actualSize": "Todellinen koko", + "desktop.menu.zoomIn": "Lähennä", + "desktop.menu.zoomOut": "Loitonna", + "desktop.menu.toggleFullScreen": "Koko näytön tila", + "desktop.menu.back": "Takaisin", + "desktop.menu.forward": "Eteenpäin", + "desktop.menu.previousSession": "Edellinen istunto", + "desktop.menu.nextSession": "Seuraava istunto", + "desktop.menu.previousProject": "Edellinen projekti", + "desktop.menu.nextProject": "Seuraava projekti", + "desktop.menu.minimize": "Pienennä", + "desktop.menu.maximize": "Suurenna", + "desktop.menu.documentation": "OpenCode-dokumentaatio", + "desktop.menu.supportForum": "Tukifoorumi", + "desktop.menu.shareFeedback": "Anna palautetta", + "desktop.menu.reportBug": "Ilmoita ohjelmavirheestä", + "desktop.menu.ariaLabel": "OpenCode-valikko", + "desktop.updater.dialog.checkFailed.message": "Päivitysten tarkistus epäonnistui.", + "desktop.updater.dialog.checkFailed.title": "Päivitysvirhe", + "desktop.updater.dialog.upToDate.message": "Olet ajan tasalla.", + "desktop.updater.dialog.upToDate.title": "Ei päivityksiä", + "desktop.updater.dialog.ready.message": "Päivitys {{version}} on ladattu. Käynnistetäänkö nyt uudelleen?", + "desktop.updater.dialog.ready.title": "Päivitys valmis", + "desktop.updater.dialog.restart": "Käynnistä uudelleen", + "desktop.updater.dialog.later": "Myöhemmin", + "desktop.recovery.action.relaunch": "Käynnistä uudelleen", + "desktop.recovery.action.exportLogs": "Vie lokit", + "desktop.recovery.action.keepWaiting": "Jatka odottamista", + "desktop.recovery.action.quit": "Lopeta", + "desktop.recovery.loadFailed": "OpenCoden lataaminen epäonnistui", + "desktop.recovery.terminated": "OpenCode-ikkuna sulkeutui odottamatta", + "desktop.recovery.unresponsive": "OpenCode ei vastaa", + "desktop.recovery.unresponsive.detail": + "Voit käynnistää sovelluksen uudelleen, avata lokit tai jatkaa odottamista.", + "desktop.recovery.loadFailed.detail": "Ikkuna: {{window}}\nURL: {{url}}\nVirhe: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Ikkuna: {{window}}\nSyy: {{reason}}\nKoodi: {{code}}", + "desktop.recovery.unknown": "", + "desktop.dialog.chooseFolder": "Valitse kansio", + "desktop.dialog.chooseFile": "Valitse tiedosto", + "desktop.dialog.saveFile": "Tallenna tiedosto", + "desktop.dialog.files": "Tiedostot", + "desktop.server.local": "Paikallinen palvelin", + "desktop.wsl.error.windowsOnly": "WSL on käytettävissä vain Windowsissa", + "desktop.wsl.error.unavailable": "WSL ei ole käytettävissä", + "desktop.wsl.error.listInstalled": "Asennettujen WSL-jakelujen luettelointi epäonnistui", + "desktop.wsl.error.listOnline": "Verkossa saatavilla olevien WSL-jakelujen luettelointi epäonnistui", + "desktop.wsl.error.executeDistro": "Jakelussa ei voi suorittaa komentoja", + "desktop.wsl.error.installWsl": "WSL:n asennus epäonnistui", + "desktop.wsl.error.installDistro": "Jakelun asentaminen epäonnistui: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCoden asennus epäonnistui", + "desktop.wsl.error.alreadyAdded": "{{distro}} on jo lisätty", + "desktop.wsl.error.opencodeMissing": "opencodea ei ole asennettu tähän jakeluun", + "desktop.wsl.error.opencodeCannotRun": "opencode on asennettu, mutta sitä ei voitu suorittaa", + "desktop.wsl.error.opencodeNotInstalled": "OpenCodea ei ole asennettu jakeluun {{distro}}", + "desktop.wsl.error.updateVersion": + "OpenCoden päivitys valmistui, mutta {{distro}} ilmoittaa yhä version {{installed}}; odotettu versio on {{expected}}", + "desktop.wsl.error.noVersion": "ei versiota", + "desktop.wsl.error.serverExited": + "WSL-palvelin sulkeutui käynnistyksen jälkeen (koodi={{code}} signaali={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL-palvelin sulkeutui ennen toimintavalmiutta (koodi={{code}} signaali={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Jakelun {{distro}} sivuprosessin kuntotarkistus aikakatkaistiin {{timeout}} ms:n jälkeen", + "desktop.wsl.error.commandTimeout": + "Komento {{command}} {{args}} aikakatkaistiin {{timeout}} ms:n jälkeen", + "desktop.wsl.error.failedPort": "Portin noutaminen epäonnistui", + "desktop.picker.error.notSelected": "Tiedostovalitsimessa ei valittu tiedostoa", + "desktop.picker.error.sizeLimit": "Valitut liitteet ylittävät {{limit}} Mt:n rajan", + "command.logs.export": "Vie lokit", + "help.tabs.toast.ariaLabel": + "Esittelyssä välilehdet. Järjestä työsi ja aktiiviset istuntosi välilehtien avulla", + "help.tabs.toast.dismiss": "Sulje välilehtien tiedot", + "help.tabs.title": "Esittelyssä välilehdet", + "help.tabs.description": "Järjestä työsi ja aktiiviset istuntosi välilehtien avulla", + "help.tabs.date": "14. heinäkuuta", + "help.tabs.introduction": "OpenCode Desktop perustuu nyt välilehtiin.", + "help.tabs.sessions": + "Aloita uusi istunto välilehdessä tai avaa olemassa oleva istunto mistä tahansa projektistasi. Avaa uusi välilehti, kun aloitat jotain uutta, ja sulje se, kun olet valmis.", + "help.tabs.organize": + "Muutaman välilehden pitäminen avoinna helpottaa aktiivisten istuntojen järjestämistä. Nimeä välilehdet helposti muistettaviksi, jos aiot pitää ne avoinna.", + "help.tabs.home": + "Kaikki istuntosi ja projektisi ovat uudessa etusivunäkymässä. Istunnon valitseminen avaa sen välilehteen.", + "help.tabs.persistence": "Kun avaat sovelluksen uudelleen, välilehtesi ovat yhä avoinna.", + "help.tabs.worktrees": + "Uusi ulkoasu ei vielä tue Git-työpuita, mutta tuki on tulossa pian. Jos haluat jatkaa aiemman ulkoasun käyttöä, voit vaihtaa ulkoasua asetuksissa. Huomaa kuitenkin, että uudesta ulkoasusta tulee pysyvä muutaman viikon kuluttua.", } diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index adba6c8caab9..e6347550fca9 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -1,4 +1,104 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Fichier", + "desktop.menu.edit": "Édition", + "desktop.menu.view": "Affichage", + "desktop.menu.go": "Aller", + "desktop.menu.window": "Fenêtre", + "desktop.menu.help": "Aide", + "desktop.menu.checkForUpdates": "Rechercher les mises à jour...", + "desktop.menu.settings": "Paramètres", + "desktop.menu.reloadWebview": "Recharger la vue Web", + "desktop.menu.restart": "Redémarrer", + "desktop.menu.exportLogs": "Exporter les journaux...", + "desktop.menu.newSession": "Nouvelle session", + "desktop.menu.openProject": "Ouvrir un projet...", + "desktop.menu.newWindow": "Nouvelle fenêtre", + "desktop.menu.closeWindow": "Fermer la fenêtre", + "desktop.menu.undo": "Annuler", + "desktop.menu.redo": "Rétablir", + "desktop.menu.cut": "Couper", + "desktop.menu.copy": "Copier", + "desktop.menu.paste": "Coller", + "desktop.menu.delete": "Supprimer", + "desktop.menu.selectAll": "Tout sélectionner", + "desktop.menu.toggleSidebar": "Afficher ou masquer la barre latérale", + "desktop.menu.toggleTerminal": "Afficher ou masquer le terminal", + "desktop.menu.toggleFileTree": "Afficher ou masquer l'arborescence des fichiers", + "desktop.menu.reload": "Recharger", + "desktop.menu.toggleDeveloperTools": "Afficher ou masquer les outils de développement", + "desktop.menu.actualSize": "Taille réelle", + "desktop.menu.zoomIn": "Zoom avant", + "desktop.menu.zoomOut": "Zoom arrière", + "desktop.menu.toggleFullScreen": "Activer ou désactiver le mode plein écran", + "desktop.menu.back": "Précédent", + "desktop.menu.forward": "Suivant", + "desktop.menu.previousSession": "Session précédente", + "desktop.menu.nextSession": "Session suivante", + "desktop.menu.previousProject": "Projet précédent", + "desktop.menu.nextProject": "Projet suivant", + "desktop.menu.minimize": "Réduire", + "desktop.menu.maximize": "Agrandir", + "desktop.menu.documentation": "Documentation d'OpenCode", + "desktop.menu.supportForum": "Forum d'assistance", + "desktop.menu.shareFeedback": "Envoyer des commentaires", + "desktop.menu.reportBug": "Signaler un bogue", + "desktop.menu.ariaLabel": "Menu OpenCode", + + "desktop.updater.dialog.checkFailed.message": "La recherche de mises à jour a échoué.", + "desktop.updater.dialog.checkFailed.title": "Erreur de mise à jour", + "desktop.updater.dialog.upToDate.message": "Vous êtes à jour.", + "desktop.updater.dialog.upToDate.title": "Aucune mise à jour", + "desktop.updater.dialog.ready.message": "La mise à jour {{version}} a été téléchargée. Redémarrer maintenant ?", + "desktop.updater.dialog.ready.title": "Mise à jour prête", + "desktop.updater.dialog.restart": "Redémarrer", + "desktop.updater.dialog.later": "Plus tard", + + "desktop.recovery.action.relaunch": "Relancer", + "desktop.recovery.action.exportLogs": "Exporter les journaux", + "desktop.recovery.action.keepWaiting": "Continuer d'attendre", + "desktop.recovery.action.quit": "Quitter", + "desktop.recovery.loadFailed": "Échec du chargement d'OpenCode", + "desktop.recovery.terminated": "La fenêtre OpenCode s'est arrêtée de manière inattendue", + "desktop.recovery.unresponsive": "OpenCode ne répond pas", + "desktop.recovery.unresponsive.detail": + "Vous pouvez relancer l'application, ouvrir les journaux ou continuer d'attendre.", + "desktop.recovery.loadFailed.detail": "Fenêtre : {{window}}\nURL : {{url}}\nErreur : {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Fenêtre : {{window}}\nMotif : {{reason}}\nCode : {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Sélectionner un dossier", + "desktop.dialog.chooseFile": "Sélectionner un fichier", + "desktop.dialog.saveFile": "Enregistrer le fichier", + "desktop.dialog.files": "Fichiers", + "desktop.server.local": "Serveur local", + + "desktop.wsl.error.windowsOnly": "WSL est disponible uniquement sous Windows", + "desktop.wsl.error.unavailable": "WSL est indisponible", + "desktop.wsl.error.listInstalled": "Impossible de répertorier les distributions WSL installées", + "desktop.wsl.error.listOnline": "Impossible de répertorier les distributions WSL en ligne", + "desktop.wsl.error.executeDistro": "Impossible d'exécuter des commandes dans la distribution", + "desktop.wsl.error.installWsl": "Échec de l'installation de WSL", + "desktop.wsl.error.installDistro": "Échec de l'installation de la distribution : {{distro}}", + "desktop.wsl.error.installOpencode": "Échec de l'installation d'OpenCode", + "desktop.wsl.error.alreadyAdded": "{{distro}} a déjà été ajoutée", + "desktop.wsl.error.opencodeMissing": "opencode n'est pas installé dans cette distribution", + "desktop.wsl.error.opencodeCannotRun": "opencode est installé, mais n'a pas pu s'exécuter", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode n'est pas installé dans {{distro}}", + "desktop.wsl.error.updateVersion": + "La mise à jour d'OpenCode est terminée, mais {{distro}} indique toujours {{installed}} au lieu de {{expected}}", + "desktop.wsl.error.noVersion": "aucune version", + "desktop.wsl.error.serverExited": "Le serveur WSL s'est arrêté après son démarrage (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Le serveur WSL s'est arrêté avant d'être opérationnel (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "La vérification de l'état du processus auxiliaire pour {{distro}} a dépassé le délai de {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} a dépassé le délai de {{timeout}} ms", + "desktop.wsl.error.failedPort": "Impossible d'obtenir le port", + + "desktop.picker.error.notSelected": "Aucun fichier n'a été sélectionné dans le sélecteur", + "desktop.picker.error.sizeLimit": "Les pièces jointes sélectionnées dépassent la limite de {{limit}} Mo", + "command.category.suggested": "Suggéré", "command.category.view": "Affichage", "command.category.project": "Projet", @@ -16,6 +116,7 @@ export const dict = { "command.category.permissions": "Permissions", "command.category.workspace": "Espace de travail", "command.category.settings": "Paramètres", + "command.logs.export": "Exporter les journaux", "theme.scheme.system": "Système", "theme.scheme.light": "Clair", "theme.scheme.dark": "Sombre", @@ -119,6 +220,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Connexion avec Claude Pro/Max", "provider.connect.selectMethod": "Sélectionnez la méthode de connexion pour {{provider}}.", "provider.connect.method.apiKey": "Clé API", + "provider.connect.method.browser": "Navigateur", + "provider.connect.method.headless": "Mode headless", "provider.connect.status.inProgress": "Autorisation en cours...", "provider.connect.status.waiting": "En attente d'autorisation...", "provider.connect.status.failed": "Échec de l'autorisation : {{error}}", @@ -150,6 +253,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} connecté", "provider.connect.toast.connected.description": "Les modèles {{provider}} sont maintenant disponibles.", "provider.custom.title": "Fournisseur personnalisé", + "provider.custom.unavailable": "Les fournisseurs personnalisés ne sont pas disponibles sur ce serveur", "provider.custom.description.prefix": "Configurez un fournisseur compatible avec OpenAI. Consultez la ", "provider.custom.description.link": "documentation sur la configuration des fournisseurs", "provider.custom.description.suffix": ".", @@ -408,6 +512,22 @@ export const dict = { "wsl.onboarding.desktopVersion": "application de bureau {{version}}", "wsl.onboarding.versionMismatch": "La version installée ne correspond pas à celle de l'application de bureau.", "wsl.onboarding.adding": "Ajout...", + "help.tabs.toast.ariaLabel": + "Présentation des onglets. Organisez votre travail et vos sessions actives à l'aide d'onglets", + "help.tabs.toast.dismiss": "Ignorer les informations sur les onglets", + "help.tabs.title": "Présentation des onglets", + "help.tabs.description": "Organisez votre travail et vos sessions actives à l'aide d'onglets", + "help.tabs.date": "14 juillet", + "help.tabs.introduction": "OpenCode Desktop s'articule désormais autour d'onglets.", + "help.tabs.sessions": + "Démarrez une nouvelle session dans un onglet ou ouvrez une session existante depuis l'un de vos projets. Ouvrez un nouvel onglet lorsque vous commencez une nouvelle tâche et fermez-le une fois celle-ci terminée.", + "help.tabs.organize": + "Garder quelques onglets ouverts facilite l'organisation de vos sessions actives. Si vous comptez les conserver, renommez-les pour pouvoir les reconnaître facilement.", + "help.tabs.home": + "Vous trouverez toutes vos sessions et tous vos projets sur le nouvel écran d'accueil. La sélection d'une session l'ouvre dans un onglet.", + "help.tabs.persistence": "Lorsque vous rouvrez l'application, vos onglets sont toujours ouverts.", + "help.tabs.worktrees": + "La nouvelle interface ne prend pas encore en charge les arbres de travail Git, mais cette fonctionnalité sera bientôt disponible. Si vous préférez continuer à utiliser la mise en page précédente, vous pouvez changer de mise en page dans les paramètres. N'oubliez pas que la nouvelle mise en page deviendra définitive dans quelques semaines.", "dialog.project.edit.title": "Modifier le projet", "dialog.project.edit.name": "Nom", "dialog.project.edit.icon": "Icône", @@ -1049,4 +1169,10 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "Échec de la création de l'icône de projet persistante", "error.childStore.storeCreateFailed": "Échec de la création du stockage", "terminal.connectionLost.abnormalClose": "WebSocket fermé anormalement : {{code}}", + "terminal.connectTicket.csrfError": + "Le ticket de connexion PTY a été rejeté par les contrôles d'origine ou CSRF. Vérifiez la configuration CORS du serveur.", + "terminal.connectTicket.statusError": "Le ticket de connexion PTY a échoué avec le statut {{status}}", + + "titlebar.update": "Mettre à jour", + "titlebar.updateVersion": "Mettre à jour {{version}}", } diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts index 9a99651555fe..aa7f511d1746 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -1,4 +1,104 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "फ़ाइल", + "desktop.menu.edit": "संपादित करें", + "desktop.menu.view": "दृश्य", + "desktop.menu.go": "जाएँ", + "desktop.menu.window": "विंडो", + "desktop.menu.help": "सहायता", + "desktop.menu.checkForUpdates": "अपडेट की जाँच करें...", + "desktop.menu.settings": "सेटिंग्स", + "desktop.menu.reloadWebview": "वेबव्यू पुनः लोड करें", + "desktop.menu.restart": "पुनः आरंभ करें", + "desktop.menu.exportLogs": "लॉग निर्यात करें...", + "desktop.menu.newSession": "नया सेशन", + "desktop.menu.openProject": "प्रोजेक्ट खोलें...", + "desktop.menu.newWindow": "नई विंडो", + "desktop.menu.closeWindow": "विंडो बंद करें", + "desktop.menu.undo": "पूर्ववत करें", + "desktop.menu.redo": "फिर से करें", + "desktop.menu.cut": "काटें", + "desktop.menu.copy": "कॉपी करें", + "desktop.menu.paste": "चिपकाएँ", + "desktop.menu.delete": "हटाएँ", + "desktop.menu.selectAll": "सभी चुनें", + "desktop.menu.toggleSidebar": "साइडबार टॉगल करें", + "desktop.menu.toggleTerminal": "टर्मिनल टॉगल करें", + "desktop.menu.toggleFileTree": "फ़ाइल ट्री टॉगल करें", + "desktop.menu.reload": "पुनः लोड करें", + "desktop.menu.toggleDeveloperTools": "डेवलपर टूल टॉगल करें", + "desktop.menu.actualSize": "वास्तविक आकार", + "desktop.menu.zoomIn": "ज़ूम इन करें", + "desktop.menu.zoomOut": "ज़ूम आउट करें", + "desktop.menu.toggleFullScreen": "पूर्ण स्क्रीन टॉगल करें", + "desktop.menu.back": "पीछे", + "desktop.menu.forward": "आगे", + "desktop.menu.previousSession": "पिछला सेशन", + "desktop.menu.nextSession": "अगला सेशन", + "desktop.menu.previousProject": "पिछला प्रोजेक्ट", + "desktop.menu.nextProject": "अगला प्रोजेक्ट", + "desktop.menu.minimize": "छोटा करें", + "desktop.menu.maximize": "बड़ा करें", + "desktop.menu.documentation": "OpenCode दस्तावेज़", + "desktop.menu.supportForum": "सहायता फ़ोरम", + "desktop.menu.shareFeedback": "प्रतिक्रिया साझा करें", + "desktop.menu.reportBug": "बग रिपोर्ट करें", + "desktop.menu.ariaLabel": "OpenCode मेन्यू", + + "desktop.updater.dialog.checkFailed.message": "अपडेट की जाँच विफल रही।", + "desktop.updater.dialog.checkFailed.title": "अपडेट त्रुटि", + "desktop.updater.dialog.upToDate.message": "आप नवीनतम संस्करण का उपयोग कर रहे हैं।", + "desktop.updater.dialog.upToDate.title": "कोई अपडेट नहीं", + "desktop.updater.dialog.ready.message": "अपडेट {{version}} डाउनलोड हो गया है। अभी पुनः आरंभ करें?", + "desktop.updater.dialog.ready.title": "अपडेट तैयार है", + "desktop.updater.dialog.restart": "पुनः आरंभ करें", + "desktop.updater.dialog.later": "बाद में", + + "desktop.recovery.action.relaunch": "पुनः लॉन्च करें", + "desktop.recovery.action.exportLogs": "लॉग निर्यात करें", + "desktop.recovery.action.keepWaiting": "प्रतीक्षा जारी रखें", + "desktop.recovery.action.quit": "बाहर निकलें", + "desktop.recovery.loadFailed": "OpenCode लोड नहीं हो सका", + "desktop.recovery.terminated": "OpenCode विंडो अनपेक्षित रूप से बंद हो गई", + "desktop.recovery.unresponsive": "OpenCode प्रतिसाद नहीं दे रहा है", + "desktop.recovery.unresponsive.detail": + "आप ऐप को पुनः लॉन्च कर सकते हैं, लॉग खोल सकते हैं या प्रतीक्षा जारी रख सकते हैं।", + "desktop.recovery.loadFailed.detail": "विंडो: {{window}}\nURL: {{url}}\nत्रुटि: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "विंडो: {{window}}\nकारण: {{reason}}\nकोड: {{code}}", + "desktop.recovery.unknown": "<अज्ञात>", + + "desktop.dialog.chooseFolder": "फ़ोल्डर चुनें", + "desktop.dialog.chooseFile": "फ़ाइल चुनें", + "desktop.dialog.saveFile": "फ़ाइल सहेजें", + "desktop.dialog.files": "फ़ाइलें", + "desktop.server.local": "स्थानीय सर्वर", + + "desktop.wsl.error.windowsOnly": "WSL केवल Windows पर उपलब्ध है", + "desktop.wsl.error.unavailable": "WSL उपलब्ध नहीं है", + "desktop.wsl.error.listInstalled": "इंस्टॉल किए गए WSL डिस्ट्रो की सूची नहीं बनाई जा सकी", + "desktop.wsl.error.listOnline": "ऑनलाइन WSL डिस्ट्रो की सूची नहीं बनाई जा सकी", + "desktop.wsl.error.executeDistro": "डिस्ट्रो में कमांड निष्पादित नहीं किए जा सकते", + "desktop.wsl.error.installWsl": "WSL इंस्टॉल नहीं हो सका", + "desktop.wsl.error.installDistro": "डिस्ट्रो इंस्टॉल नहीं हो सका: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode इंस्टॉल नहीं हो सका", + "desktop.wsl.error.alreadyAdded": "{{distro}} पहले ही जोड़ा जा चुका है", + "desktop.wsl.error.opencodeMissing": "इस डिस्ट्रो में opencode इंस्टॉल नहीं है", + "desktop.wsl.error.opencodeCannotRun": "opencode इंस्टॉल है, लेकिन चल नहीं सका", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}} में OpenCode इंस्टॉल नहीं है", + "desktop.wsl.error.updateVersion": + "OpenCode अपडेट पूरा हो गया, लेकिन {{distro}} अब भी {{installed}} रिपोर्ट कर रहा है; अपेक्षित संस्करण {{expected}} है", + "desktop.wsl.error.noVersion": "कोई संस्करण नहीं", + "desktop.wsl.error.serverExited": "WSL सर्वर स्टार्टअप के बाद बंद हो गया (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL सर्वर के सुचारु होने से पहले बंद हो गया (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "{{distro}} के Sidecar की स्वास्थ्य जाँच {{timeout}}ms के बाद समय-सीमा पार कर गई", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} ने {{timeout}}ms के बाद समय-सीमा पार कर दी", + "desktop.wsl.error.failedPort": "पोर्ट प्राप्त नहीं किया जा सका", + + "desktop.picker.error.notSelected": "पिकर में फ़ाइल नहीं चुनी गई", + "desktop.picker.error.sizeLimit": "चुने गए अटैचमेंट {{limit}} MB की सीमा से अधिक हैं", + "command.category.suggested": "सुझाए गए", "command.category.view": "दृश्य", "command.category.project": "प्रोजेक्ट", @@ -16,6 +116,7 @@ export const dict = { "command.category.permissions": "अनुमतियाँ", "command.category.workspace": "वर्कस्पेस", "command.category.settings": "सेटिंग्स", + "command.logs.export": "लॉग निर्यात करें", "theme.scheme.system": "सिस्टम", "theme.scheme.light": "हल्की", "theme.scheme.dark": "गहरी", @@ -119,6 +220,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude प्रो/मैक्स के साथ लॉगिन करें", "provider.connect.selectMethod": "{{provider}} के लिए लॉगिन विधि चुनें।", "provider.connect.method.apiKey": "API कुंजी", + "provider.connect.method.browser": "ब्राउज़र", + "provider.connect.method.headless": "हेडलेस मोड", "provider.connect.status.inProgress": "प्राधिकरण प्रगति पर है...", "provider.connect.status.waiting": "प्राधिकरण की प्रतीक्षा हो रही है...", "provider.connect.status.failed": "प्राधिकरण विफल: {{error}}", @@ -149,6 +252,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} कनेक्ट हो गया", "provider.connect.toast.connected.description": "{{provider}} मॉडल अब उपयोग के लिए उपलब्ध हैं।", "provider.custom.title": "कस्टम प्रोवाइडर", + "provider.custom.unavailable": "इस सर्वर पर कस्टम प्रोवाइडर उपलब्ध नहीं हैं", "provider.custom.description.prefix": "एक OpenAI-संगत प्रोवाइडर कॉन्फ़िगर करें। देखें ", "provider.custom.description.link": "प्रोवाइडर कॉन्फ़िगरेशन दस्तावेज़", "provider.custom.description.suffix": ".", @@ -410,6 +514,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "डेस्कटॉप {{version}}", "wsl.onboarding.versionMismatch": "इंस्टॉल किया गया संस्करण डेस्कटॉप ऐप संस्करण से मेल नहीं खाता।", "wsl.onboarding.adding": "जोड़ा जा रहा है...", + "help.tabs.toast.ariaLabel": "टैब प्रस्तुत हैं। टैब की सहायता से अपना काम और सक्रिय सेशन व्यवस्थित करें", + "help.tabs.toast.dismiss": "टैब की जानकारी हटाएँ", + "help.tabs.title": "टैब प्रस्तुत हैं", + "help.tabs.description": "टैब की सहायता से अपना काम और सक्रिय सेशन व्यवस्थित करें", + "help.tabs.date": "14 जुलाई", + "help.tabs.introduction": "OpenCode Desktop अब टैब पर आधारित है।", + "help.tabs.sessions": + "किसी टैब में नया सेशन शुरू करें या अपने किसी भी प्रोजेक्ट से मौजूदा सेशन खोलें। कुछ नया शुरू करते समय नया टैब खोलें और काम पूरा होने पर उसे बंद कर दें।", + "help.tabs.organize": + "कुछ टैब खुले रखने से सक्रिय सेशन व्यवस्थित करना आसान होता है। यदि आप टैब खुले रखना चाहते हैं, तो उन्हें कोई याद रहने वाला नाम दें।", + "help.tabs.home": + "आपके सभी सेशन और प्रोजेक्ट नई होम स्क्रीन पर मिलेंगे। किसी सेशन को चुनने पर वह टैब में खुलता है।", + "help.tabs.persistence": "ऐप दोबारा खोलने पर भी आपके टैब खुले रहते हैं।", + "help.tabs.worktrees": + "नया डिज़ाइन अभी Git Worktrees का समर्थन नहीं करता है, यह सुविधा जल्द आ रही है। इसलिए यदि आप पिछले लेआउट का उपयोग जारी रखना चाहते हैं, तो सेटिंग्स में लेआउट के बीच स्विच कर सकते हैं। ध्यान रखें कि नया लेआउट कुछ सप्ताह में स्थायी हो जाएगा।", "server.row.noUsername": "कोई उपयोगकर्ता नाम नहीं", "dialog.project.edit.title": "प्रोजेक्ट संपादित करें", "dialog.project.edit.name": "नाम", @@ -704,6 +823,10 @@ export const dict = { "terminal.connectionLost.title": "कनेक्शन टूट गया", "terminal.connectionLost.abnormalClose": "WebSocket असामान्य रूप से बंद हुआ: {{code}}", "terminal.connectionLost.description": "टर्मिनल कनेक्शन बाधित हो गया था। ऐसा तब हो सकता है जब सर्वर पुनरारंभ हो।", + "terminal.connectTicket.csrfError": "ओरिजिन या CSRF जाँच के कारण PTY कनेक्ट टिकट अस्वीकार कर दिया गया। सर्वर का CORS कॉन्फ़िगरेशन जाँचें।", + "terminal.connectTicket.statusError": "PTY कनेक्ट टिकट विफल रहा (स्थिति: {{status}})", + "titlebar.update": "अपडेट करें", + "titlebar.updateVersion": "अपडेट करें {{version}}", "common.closeTab": "टैब बंद करें", "common.dismiss": "हटाएँ", "common.moreCountSuffix": " (+{{count}} और)", diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts index fdc176c2c1d3..c104d2ad4e4b 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -1,4 +1,105 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Berkas", + "desktop.menu.edit": "Sunting", + "desktop.menu.view": "Tampilan", + "desktop.menu.go": "Navigasi", + "desktop.menu.window": "Jendela", + "desktop.menu.help": "Bantuan", + "desktop.menu.checkForUpdates": "Periksa pembaruan...", + "desktop.menu.settings": "Pengaturan", + "desktop.menu.reloadWebview": "Muat ulang tampilan web", + "desktop.menu.restart": "Mulai ulang", + "desktop.menu.exportLogs": "Ekspor log...", + "desktop.menu.newSession": "Sesi baru", + "desktop.menu.openProject": "Buka proyek...", + "desktop.menu.newWindow": "Jendela baru", + "desktop.menu.closeWindow": "Tutup jendela", + "desktop.menu.undo": "Urungkan", + "desktop.menu.redo": "Ulangi", + "desktop.menu.cut": "Potong", + "desktop.menu.copy": "Salin", + "desktop.menu.paste": "Tempel", + "desktop.menu.delete": "Hapus", + "desktop.menu.selectAll": "Pilih semua", + "desktop.menu.toggleSidebar": "Alihkan panel samping", + "desktop.menu.toggleTerminal": "Alihkan terminal", + "desktop.menu.toggleFileTree": "Alihkan pohon berkas", + "desktop.menu.reload": "Muat ulang", + "desktop.menu.toggleDeveloperTools": "Alihkan alat pengembang", + "desktop.menu.actualSize": "Ukuran sebenarnya", + "desktop.menu.zoomIn": "Perbesar", + "desktop.menu.zoomOut": "Perkecil", + "desktop.menu.toggleFullScreen": "Alihkan layar penuh", + "desktop.menu.back": "Kembali", + "desktop.menu.forward": "Maju", + "desktop.menu.previousSession": "Sesi sebelumnya", + "desktop.menu.nextSession": "Sesi berikutnya", + "desktop.menu.previousProject": "Proyek sebelumnya", + "desktop.menu.nextProject": "Proyek berikutnya", + "desktop.menu.minimize": "Minimalkan", + "desktop.menu.maximize": "Maksimalkan", + "desktop.menu.documentation": "Dokumentasi OpenCode", + "desktop.menu.supportForum": "Forum dukungan", + "desktop.menu.shareFeedback": "Bagikan umpan balik", + "desktop.menu.reportBug": "Laporkan bug", + "desktop.menu.ariaLabel": "Menu OpenCode", + + "desktop.updater.dialog.checkFailed.message": "Pemeriksaan pembaruan gagal.", + "desktop.updater.dialog.checkFailed.title": "Kesalahan pembaruan", + "desktop.updater.dialog.upToDate.message": "Versi Anda sudah terbaru.", + "desktop.updater.dialog.upToDate.title": "Tidak ada pembaruan", + "desktop.updater.dialog.ready.message": "Pembaruan {{version}} telah diunduh. Mulai ulang sekarang?", + "desktop.updater.dialog.ready.title": "Pembaruan siap", + "desktop.updater.dialog.restart": "Mulai ulang", + "desktop.updater.dialog.later": "Nanti", + + "desktop.recovery.action.relaunch": "Luncurkan ulang", + "desktop.recovery.action.exportLogs": "Ekspor log", + "desktop.recovery.action.keepWaiting": "Tetap tunggu", + "desktop.recovery.action.quit": "Keluar", + "desktop.recovery.loadFailed": "OpenCode gagal dimuat", + "desktop.recovery.terminated": "Jendela OpenCode berhenti secara tidak terduga", + "desktop.recovery.unresponsive": "OpenCode tidak merespons", + "desktop.recovery.unresponsive.detail": + "Anda dapat meluncurkan ulang aplikasi, membuka log, atau tetap menunggu.", + "desktop.recovery.loadFailed.detail": "Jendela: {{window}}\nURL: {{url}}\nKesalahan: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Jendela: {{window}}\nAlasan: {{reason}}\nKode: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Pilih folder", + "desktop.dialog.chooseFile": "Pilih berkas", + "desktop.dialog.saveFile": "Simpan berkas", + "desktop.dialog.files": "Berkas", + + "desktop.server.local": "Server lokal", + + "desktop.wsl.error.windowsOnly": "WSL hanya tersedia di Windows", + "desktop.wsl.error.unavailable": "WSL tidak tersedia", + "desktop.wsl.error.listInstalled": "Gagal menampilkan daftar distro WSL yang terinstal", + "desktop.wsl.error.listOnline": "Gagal menampilkan daftar distro WSL daring", + "desktop.wsl.error.executeDistro": "Tidak dapat menjalankan perintah di distro", + "desktop.wsl.error.installWsl": "Instalasi WSL gagal", + "desktop.wsl.error.installDistro": "Gagal menginstal distro: {{distro}}", + "desktop.wsl.error.installOpencode": "Instalasi OpenCode gagal", + "desktop.wsl.error.alreadyAdded": "{{distro}} sudah ditambahkan", + "desktop.wsl.error.opencodeMissing": "opencode belum terinstal di distro ini", + "desktop.wsl.error.opencodeCannotRun": "opencode telah terinstal, tetapi tidak dapat dijalankan", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode belum terinstal di {{distro}}", + "desktop.wsl.error.updateVersion": + "Pembaruan OpenCode selesai, tetapi {{distro}} masih melaporkan {{installed}}; seharusnya {{expected}}", + "desktop.wsl.error.noVersion": "tanpa versi", + "desktop.wsl.error.serverExited": "Server WSL berhenti setelah dimulai (kode={{code}} sinyal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Server WSL berhenti sebelum siap (kode={{code}} sinyal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Pemeriksaan kesehatan sidecar untuk {{distro}} kehabisan waktu setelah {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} kehabisan waktu setelah {{timeout}}ms", + "desktop.wsl.error.failedPort": "Gagal mendapatkan porta", + + "desktop.picker.error.notSelected": "Berkas tidak dipilih melalui pemilih", + "desktop.picker.error.sizeLimit": "Lampiran yang dipilih melebihi batas {{limit}} MB", + "command.category.suggested": "Disarankan", "command.category.view": "Tampilan", "command.category.project": "Proyek", @@ -16,6 +117,7 @@ export const dict = { "command.category.permissions": "Izin", "command.category.workspace": "Ruang kerja", "command.category.settings": "Pengaturan", + "command.logs.export": "Ekspor log", "theme.scheme.system": "Sistem", "theme.scheme.light": "Terang", @@ -131,6 +233,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Masuk dengan Claude Pro/Max", "provider.connect.selectMethod": "Pilih metode masuk untuk {{provider}}.", "provider.connect.method.apiKey": "Kunci API", + "provider.connect.method.browser": "Peramban", + "provider.connect.method.headless": "Headless", "provider.connect.status.inProgress": "Otorisasi sedang berlangsung...", "provider.connect.status.waiting": "Menunggu otorisasi...", "provider.connect.status.failed": "Otorisasi gagal: {{error}}", @@ -163,6 +267,7 @@ export const dict = { "provider.connect.toast.connected.description": "Model {{provider}} sekarang tersedia untuk digunakan.", "provider.custom.title": "Penyedia kustom", + "provider.custom.unavailable": "Penyedia kustom tidak tersedia di server ini", "provider.custom.description.prefix": "Konfigurasikan penyedia yang kompatibel dengan OpenAI. Lihat ", "provider.custom.description.link": "dokumen konfigurasi penyedia", "provider.custom.description.suffix": ".", @@ -438,6 +543,22 @@ export const dict = { "wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.versionMismatch": "Versi terinstal tidak cocok dengan versi aplikasi desktop.", "wsl.onboarding.adding": "Menambahkan...", + + "help.tabs.toast.ariaLabel": "Memperkenalkan Tab. Atur pekerjaan dan sesi aktif Anda dengan tab", + "help.tabs.toast.dismiss": "Tutup informasi tentang Tab", + "help.tabs.title": "Memperkenalkan Tab", + "help.tabs.description": "Atur pekerjaan dan sesi aktif Anda dengan tab", + "help.tabs.date": "14 Juli", + "help.tabs.introduction": "OpenCode Desktop kini dirancang dengan tab sebagai pusatnya.", + "help.tabs.sessions": + "Mulai sesi baru di tab, atau buka sesi yang sudah ada dari proyek Anda. Buka tab baru saat Anda memulai sesuatu yang baru, lalu tutup setelah selesai.", + "help.tabs.organize": + "Membiarkan beberapa tab tetap terbuka memudahkan Anda mengatur sesi aktif. Ganti nama tab agar mudah diingat jika Anda berencana mempertahankannya.", + "help.tabs.home": + "Semua sesi dan proyek Anda tersedia di layar Beranda baru. Memilih sesi akan membukanya di tab.", + "help.tabs.persistence": "Saat Anda membuka kembali aplikasi, tab Anda tetap terbuka.", + "help.tabs.worktrees": + "Desain baru belum mendukung Git Worktrees, tetapi dukungan tersebut akan segera hadir. Jika Anda ingin tetap menggunakan tata letak sebelumnya, Anda dapat beralih antartata letak di Pengaturan. Perlu diingat bahwa tata letak baru akan menjadi permanen dalam beberapa minggu.", "server.row.noUsername": "tanpa nama pengguna", "dialog.project.edit.title": "Sunting proyek", @@ -765,6 +886,11 @@ export const dict = { "terminal.connectionLost.title": "Koneksi Terputus", "terminal.connectionLost.abnormalClose": "WebSocket ditutup secara tidak normal: {{code}}", "terminal.connectionLost.description": "Koneksi terminal terputus. Ini bisa terjadi saat server dimulai ulang.", + "terminal.connectTicket.csrfError": + "Tiket koneksi PTY ditolak oleh pemeriksaan asal atau CSRF. Periksa konfigurasi CORS server.", + "terminal.connectTicket.statusError": "Tiket koneksi PTY gagal dengan status {{status}}", + "titlebar.update": "Perbarui", + "titlebar.updateVersion": "Perbarui {{version}}", "common.closeTab": "Tutup tab", "common.dismiss": "Tutup", diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts index e044f664bef4..56397ce62945 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -16,6 +16,7 @@ export const dict = { "command.category.permissions": "Autorizzazioni", "command.category.workspace": "Area di lavoro", "command.category.settings": "Impostazioni", + "command.logs.export": "Esporta log", "theme.scheme.system": "Sistema", "theme.scheme.light": "Chiaro", "theme.scheme.dark": "Scuro", @@ -119,6 +120,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Accedi con Claude Pro/Max", "provider.connect.selectMethod": "Seleziona il metodo di accesso per {{provider}}.", "provider.connect.method.apiKey": "Chiave API", + "provider.connect.method.browser": "Browser", + "provider.connect.method.headless": "Headless", "provider.connect.status.inProgress": "Autorizzazione in corso...", "provider.connect.status.waiting": "In attesa di autorizzazione...", "provider.connect.status.failed": "Autorizzazione fallita: {{error}}", @@ -186,6 +189,7 @@ export const dict = { "provider.custom.error.baseURL.format": "Deve iniziare con http:// o https://", "provider.custom.error.required": "Necessario", "provider.custom.error.duplicate": "Duplicato", + "provider.custom.unavailable": "I provider personalizzati non sono disponibili su questo server", "provider.disconnect.toast.disconnected.title": "{{provider}} disconnesso", "provider.disconnect.toast.disconnected.description": "I modelli {{provider}} non sono più disponibili.", "model.tag.free": "Gratuito", @@ -411,6 +415,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.versionMismatch": "La versione installata non corrisponde alla versione dell'app desktop.", "wsl.onboarding.adding": "Aggiunta...", + "help.tabs.toast.ariaLabel": "Novità: le schede. Organizza il lavoro e le sessioni attive con le schede", + "help.tabs.toast.dismiss": "Ignora le informazioni sulle schede", + "help.tabs.title": "Novità: le schede", + "help.tabs.description": "Organizza il lavoro e le sessioni attive con le schede", + "help.tabs.date": "14 luglio", + "help.tabs.introduction": "OpenCode Desktop ora è incentrato sulle schede.", + "help.tabs.sessions": + "Avvia una nuova sessione in una scheda oppure apri una sessione esistente da uno dei tuoi progetti. Apri una nuova scheda quando inizi qualcosa di nuovo e chiudila quando hai finito.", + "help.tabs.organize": + "Tenere aperte alcune schede semplifica l'organizzazione delle sessioni attive. Se prevedi di conservarle, rinominale con un nome facile da ricordare.", + "help.tabs.home": + "Troverai tutte le sessioni e i progetti nella nuova schermata Home. Quando selezioni una sessione, questa si apre in una scheda.", + "help.tabs.persistence": "Quando riapri l'app, le schede sono ancora aperte.", + "help.tabs.worktrees": + "Il nuovo design non supporta ancora Git Worktrees, ma il supporto arriverà presto. Se preferisci continuare a utilizzare la disposizione precedente, puoi passare da una disposizione all'altra nelle Impostazioni. Tieni presente che la nuova disposizione diventerà permanente tra alcune settimane.", "server.row.noUsername": "nessun nome utente", "dialog.project.edit.title": "Modifica progetto", "dialog.project.edit.name": "Nome", @@ -712,6 +731,11 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket chiuso in modo anomalo: {{code}}", "terminal.connectionLost.description": "La connessione del terminale è stata interrotta. Ciò può verificarsi al riavvio del server.", + "terminal.connectTicket.csrfError": + "Il ticket di connessione PTY è stato rifiutato dai controlli sull'origine o CSRF. Verifica la configurazione CORS del server.", + "terminal.connectTicket.statusError": "Il ticket di connessione PTY non è riuscito con lo stato {{status}}", + "titlebar.update": "Aggiorna", + "titlebar.updateVersion": "Aggiorna {{version}}", "common.closeTab": "Chiudi scheda", "common.dismiss": "Ignora", "common.moreCountSuffix": " (+ altri {{count}})", @@ -1035,4 +1059,97 @@ export const dict = { "workspace.reset.archived.one": "1 sessione verrà archiviata.", "workspace.reset.archived.many": "Verranno archiviate {{count}} sessioni.", "workspace.reset.note": "L'area di lavoro verrà reimpostata in modo che corrisponda al branch predefinito.", + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "File", + "desktop.menu.edit": "Modifica", + "desktop.menu.view": "Vista", + "desktop.menu.go": "Vai", + "desktop.menu.window": "Finestra", + "desktop.menu.help": "Aiuto", + "desktop.menu.checkForUpdates": "Verifica aggiornamenti...", + "desktop.menu.settings": "Impostazioni", + "desktop.menu.reloadWebview": "Ricarica Webview", + "desktop.menu.restart": "Riavvia", + "desktop.menu.exportLogs": "Esporta log...", + "desktop.menu.newSession": "Nuova sessione", + "desktop.menu.openProject": "Apri progetto...", + "desktop.menu.newWindow": "Nuova finestra", + "desktop.menu.closeWindow": "Chiudi finestra", + "desktop.menu.undo": "Annulla", + "desktop.menu.redo": "Ripristina", + "desktop.menu.cut": "Taglia", + "desktop.menu.copy": "Copia", + "desktop.menu.paste": "Incolla", + "desktop.menu.delete": "Elimina", + "desktop.menu.selectAll": "Seleziona tutto", + "desktop.menu.toggleSidebar": "Mostra o nascondi la barra laterale", + "desktop.menu.toggleTerminal": "Mostra o nascondi il terminale", + "desktop.menu.toggleFileTree": "Mostra o nascondi l'albero dei file", + "desktop.menu.reload": "Ricarica", + "desktop.menu.toggleDeveloperTools": "Mostra o nascondi gli strumenti per sviluppatori", + "desktop.menu.actualSize": "Dimensioni effettive", + "desktop.menu.zoomIn": "Ingrandisci", + "desktop.menu.zoomOut": "Riduci", + "desktop.menu.toggleFullScreen": "Attiva o disattiva lo schermo intero", + "desktop.menu.back": "Indietro", + "desktop.menu.forward": "Avanti", + "desktop.menu.previousSession": "Sessione precedente", + "desktop.menu.nextSession": "Sessione successiva", + "desktop.menu.previousProject": "Progetto precedente", + "desktop.menu.nextProject": "Progetto successivo", + "desktop.menu.minimize": "Riduci a icona", + "desktop.menu.maximize": "Ingrandisci", + "desktop.menu.documentation": "Documentazione di OpenCode", + "desktop.menu.supportForum": "Forum di supporto", + "desktop.menu.shareFeedback": "Invia feedback", + "desktop.menu.reportBug": "Segnala un bug", + "desktop.menu.ariaLabel": "Menu OpenCode", + "desktop.updater.dialog.checkFailed.message": "Impossibile verificare la disponibilità di aggiornamenti.", + "desktop.updater.dialog.checkFailed.title": "Errore di aggiornamento", + "desktop.updater.dialog.upToDate.message": "La versione è aggiornata.", + "desktop.updater.dialog.upToDate.title": "Nessun aggiornamento", + "desktop.updater.dialog.ready.message": "L'aggiornamento {{version}} è stato scaricato. Riavviare ora?", + "desktop.updater.dialog.ready.title": "Aggiornamento pronto", + "desktop.updater.dialog.restart": "Riavvia", + "desktop.updater.dialog.later": "Più tardi", + "desktop.recovery.action.relaunch": "Riapri", + "desktop.recovery.action.exportLogs": "Esporta log", + "desktop.recovery.action.keepWaiting": "Continua ad attendere", + "desktop.recovery.action.quit": "Esci", + "desktop.recovery.loadFailed": "Impossibile caricare OpenCode", + "desktop.recovery.terminated": "La finestra di OpenCode si è chiusa in modo imprevisto", + "desktop.recovery.unresponsive": "OpenCode non risponde", + "desktop.recovery.unresponsive.detail": "Puoi riaprire l'app, aprire i log o continuare ad attendere.", + "desktop.recovery.loadFailed.detail": "Finestra: {{window}}\nURL: {{url}}\nErrore: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Finestra: {{window}}\nMotivo: {{reason}}\nCodice: {{code}}", + "desktop.recovery.unknown": "", + "desktop.dialog.chooseFolder": "Scegli una cartella", + "desktop.dialog.chooseFile": "Scegli un file", + "desktop.dialog.saveFile": "Salva file", + "desktop.dialog.files": "File", + "desktop.server.local": "Server locale", + "desktop.wsl.error.windowsOnly": "WSL è disponibile solo in Windows", + "desktop.wsl.error.unavailable": "WSL non è disponibile", + "desktop.wsl.error.listInstalled": "Impossibile elencare le distribuzioni WSL installate", + "desktop.wsl.error.listOnline": "Impossibile elencare le distribuzioni WSL disponibili online", + "desktop.wsl.error.executeDistro": "Impossibile eseguire comandi nella distribuzione", + "desktop.wsl.error.installWsl": "Installazione di WSL non riuscita", + "desktop.wsl.error.installDistro": "Impossibile installare la distribuzione: {{distro}}", + "desktop.wsl.error.installOpencode": "Installazione di OpenCode non riuscita", + "desktop.wsl.error.alreadyAdded": "{{distro}} è già stata aggiunta", + "desktop.wsl.error.opencodeMissing": "opencode non è installato in questa distribuzione", + "desktop.wsl.error.opencodeCannotRun": "opencode è installato, ma non è stato possibile eseguirlo", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode non è installato in {{distro}}", + "desktop.wsl.error.updateVersion": + "L'aggiornamento di OpenCode è terminato, ma {{distro}} segnala ancora la versione {{installed}}; era prevista la versione {{expected}}", + "desktop.wsl.error.noVersion": "nessuna versione", + "desktop.wsl.error.serverExited": "Il server WSL si è chiuso dopo l'avvio (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Il server WSL si è chiuso prima di diventare operativo (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Il controllo dello stato del sidecar per {{distro}} ha superato il tempo limite di {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} ha superato il tempo limite di {{timeout}}ms", + "desktop.wsl.error.failedPort": "Impossibile ottenere la porta", + "desktop.picker.error.notSelected": "Il file non è stato selezionato nella finestra di selezione", + "desktop.picker.error.sizeLimit": "Gli allegati selezionati superano il limite di {{limit}} MB", } diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 35ccb9e7aaa6..4c4f8a181543 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -1,4 +1,103 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "ファイル", + "desktop.menu.edit": "編集", + "desktop.menu.view": "表示", + "desktop.menu.go": "移動", + "desktop.menu.window": "ウインドウ", + "desktop.menu.help": "ヘルプ", + "desktop.menu.checkForUpdates": "アップデートを確認...", + "desktop.menu.settings": "設定", + "desktop.menu.reloadWebview": "Webビューを再読み込み", + "desktop.menu.restart": "再起動", + "desktop.menu.exportLogs": "ログをエクスポート...", + "desktop.menu.newSession": "新しいセッション", + "desktop.menu.openProject": "プロジェクトを開く...", + "desktop.menu.newWindow": "新しいウインドウ", + "desktop.menu.closeWindow": "ウインドウを閉じる", + "desktop.menu.undo": "取り消す", + "desktop.menu.redo": "やり直す", + "desktop.menu.cut": "切り取り", + "desktop.menu.copy": "コピー", + "desktop.menu.paste": "ペースト", + "desktop.menu.delete": "削除", + "desktop.menu.selectAll": "すべてを選択", + "desktop.menu.toggleSidebar": "サイドバーの表示を切り替える", + "desktop.menu.toggleTerminal": "ターミナルの表示を切り替える", + "desktop.menu.toggleFileTree": "ファイルツリーの表示を切り替える", + "desktop.menu.reload": "再読み込み", + "desktop.menu.toggleDeveloperTools": "開発者ツールの表示を切り替える", + "desktop.menu.actualSize": "実際のサイズ", + "desktop.menu.zoomIn": "拡大", + "desktop.menu.zoomOut": "縮小", + "desktop.menu.toggleFullScreen": "フルスクリーン表示を切り替える", + "desktop.menu.back": "戻る", + "desktop.menu.forward": "進む", + "desktop.menu.previousSession": "前のセッション", + "desktop.menu.nextSession": "次のセッション", + "desktop.menu.previousProject": "前のプロジェクト", + "desktop.menu.nextProject": "次のプロジェクト", + "desktop.menu.minimize": "最小化", + "desktop.menu.maximize": "最大化", + "desktop.menu.documentation": "OpenCodeドキュメント", + "desktop.menu.supportForum": "サポートフォーラム", + "desktop.menu.shareFeedback": "フィードバックを送信", + "desktop.menu.reportBug": "バグを報告", + "desktop.menu.ariaLabel": "OpenCodeメニュー", + + "desktop.updater.dialog.checkFailed.message": "アップデートを確認できませんでした。", + "desktop.updater.dialog.checkFailed.title": "アップデートエラー", + "desktop.updater.dialog.upToDate.message": "最新の状態です。", + "desktop.updater.dialog.upToDate.title": "アップデートはありません", + "desktop.updater.dialog.ready.message": "アップデート{{version}}をダウンロードしました。今すぐ再起動しますか?", + "desktop.updater.dialog.ready.title": "アップデートの準備ができました", + "desktop.updater.dialog.restart": "再起動", + "desktop.updater.dialog.later": "後で", + + "desktop.recovery.action.relaunch": "再起動", + "desktop.recovery.action.exportLogs": "ログをエクスポート", + "desktop.recovery.action.keepWaiting": "待機を続ける", + "desktop.recovery.action.quit": "終了", + "desktop.recovery.loadFailed": "OpenCodeを読み込めませんでした", + "desktop.recovery.terminated": "OpenCodeのウインドウが予期せず終了しました", + "desktop.recovery.unresponsive": "OpenCodeが応答していません", + "desktop.recovery.unresponsive.detail": "アプリを再起動するか、ログを開くか、待機を続けることができます。", + "desktop.recovery.loadFailed.detail": "ウインドウ: {{window}}\nURL: {{url}}\nエラー: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "ウインドウ: {{window}}\n理由: {{reason}}\nコード: {{code}}", + "desktop.recovery.unknown": "<不明>", + + "desktop.dialog.chooseFolder": "フォルダを選択", + "desktop.dialog.chooseFile": "ファイルを選択", + "desktop.dialog.saveFile": "ファイルを保存", + "desktop.dialog.files": "ファイル", + "desktop.server.local": "ローカルサーバー", + + "desktop.wsl.error.windowsOnly": "WSLはWindowsでのみ利用できます", + "desktop.wsl.error.unavailable": "WSLを利用できません", + "desktop.wsl.error.listInstalled": "インストール済みのWSLディストリビューションの一覧を取得できませんでした", + "desktop.wsl.error.listOnline": "オンラインのWSLディストリビューションの一覧を取得できませんでした", + "desktop.wsl.error.executeDistro": "ディストリビューションでコマンドを実行できません", + "desktop.wsl.error.installWsl": "WSLのインストールに失敗しました", + "desktop.wsl.error.installDistro": "ディストリビューションのインストールに失敗しました: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCodeのインストールに失敗しました", + "desktop.wsl.error.alreadyAdded": "{{distro}}はすでに追加されています", + "desktop.wsl.error.opencodeMissing": "このディストリビューションにはopencodeがインストールされていません", + "desktop.wsl.error.opencodeCannotRun": "opencodeはインストールされていますが、実行できませんでした", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}}にはOpenCodeがインストールされていません", + "desktop.wsl.error.updateVersion": + "OpenCodeのアップデートは完了しましたが、{{distro}}から報告されたバージョンはまだ{{installed}}です。想定されるバージョンは{{expected}}です", + "desktop.wsl.error.noVersion": "バージョンなし", + "desktop.wsl.error.serverExited": "WSLサーバーが起動後に終了しました (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSLサーバーが正常に稼働する前に終了しました (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "{{distro}}のSidecarのヘルスチェックが{{timeout}}ms後にタイムアウトしました", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}}が{{timeout}}ms後にタイムアウトしました", + "desktop.wsl.error.failedPort": "ポートを取得できませんでした", + + "desktop.picker.error.notSelected": "ピッカーでファイルが選択されませんでした", + "desktop.picker.error.sizeLimit": "選択した添付ファイルが上限の{{limit}} MBを超えています", + "command.category.suggested": "おすすめ", "command.category.view": "表示", "command.category.project": "プロジェクト", @@ -16,6 +115,7 @@ export const dict = { "command.category.permissions": "権限", "command.category.workspace": "ワークスペース", "command.category.settings": "設定", + "command.logs.export": "ログをエクスポート", "theme.scheme.system": "システム", "theme.scheme.light": "ライト", "theme.scheme.dark": "ダーク", @@ -119,6 +219,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude Pro/Maxでログイン", "provider.connect.selectMethod": "{{provider}}のログイン方法を選択してください。", "provider.connect.method.apiKey": "APIキー", + "provider.connect.method.browser": "ブラウザ", + "provider.connect.method.headless": "ヘッドレス モード", "provider.connect.status.inProgress": "認証中...", "provider.connect.status.waiting": "認証を待機中...", "provider.connect.status.failed": "認証に失敗しました: {{error}}", @@ -149,6 +251,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}}が接続されました", "provider.connect.toast.connected.description": "{{provider}}モデルが使用可能になりました。", "provider.custom.title": "カスタムプロバイダー", + "provider.custom.unavailable": "このサーバーではカスタムプロバイダーを利用できません", "provider.custom.description.prefix": "OpenAI互換のプロバイダーを設定します。詳細は", "provider.custom.description.link": "プロバイダー設定ドキュメント", "provider.custom.description.suffix": "をご覧ください。", @@ -403,6 +506,22 @@ export const dict = { "wsl.onboarding.desktopVersion": "デスクトップ {{version}}", "wsl.onboarding.versionMismatch": "インストールされているバージョンがデスクトップアプリのバージョンと一致しません。", "wsl.onboarding.adding": "追加中...", + + "help.tabs.toast.ariaLabel": "タブのご紹介。タブを使って作業と進行中のセッションを整理できます", + "help.tabs.toast.dismiss": "タブに関する案内を閉じる", + "help.tabs.title": "タブのご紹介", + "help.tabs.description": "タブを使って作業と進行中のセッションを整理できます", + "help.tabs.date": "7月14日", + "help.tabs.introduction": "OpenCode Desktopでタブを中心とした操作ができるようになりました。", + "help.tabs.sessions": + "新しいセッションをタブで開始したり、プロジェクトから既存のセッションを開いたりできます。新しい作業を始めるときは新しいタブを開き、作業が終わったら閉じます。", + "help.tabs.organize": + "複数のタブを開いておくと、進行中のセッションを整理しやすくなります。タブを残しておく場合は、覚えやすい名前に変更できます。", + "help.tabs.home": + "新しいホーム画面には、すべてのセッションとプロジェクトが表示されます。セッションを選択するとタブで開きます。", + "help.tabs.persistence": "アプリを再度開いたときも、タブは開いたままです。", + "help.tabs.worktrees": + "新しいデザインはまだGitワークツリーに対応していませんが、近日中に対応する予定です。以前のレイアウトを引き続き使用する場合は、設定でレイアウトを切り替えることができます。新しいレイアウトは数週間後に標準となる予定です。", "dialog.project.edit.title": "プロジェクトを編集", "dialog.project.edit.name": "名前", "dialog.project.edit.icon": "アイコン", @@ -1021,4 +1140,10 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "永続プロジェクトアイコンの作成に失敗しました", "error.childStore.storeCreateFailed": "ストアの作成に失敗しました", "terminal.connectionLost.abnormalClose": "WebSocket が異常終了しました: {{code}}", + "terminal.connectTicket.csrfError": + "PTY接続チケットは、オリジンまたはCSRFチェックによって拒否されました。サーバーのCORS設定を確認してください。", + "terminal.connectTicket.statusError": "PTY接続チケットがステータス {{status}} で失敗しました", + + "titlebar.update": "アップデート", + "titlebar.updateVersion": "アップデート {{version}}", } diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 175b31698b78..7f950ede61b8 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -115,6 +115,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude Pro/Max로 로그인", "provider.connect.selectMethod": "{{provider}} 로그인 방법 선택", "provider.connect.method.apiKey": "API 키", + "provider.connect.method.browser": "브라우저", + "provider.connect.method.headless": "헤드리스 모드", "provider.connect.status.inProgress": "인증 진행 중...", "provider.connect.status.waiting": "인증 대기 중...", "provider.connect.status.failed": "인증 실패: {{error}}", @@ -145,6 +147,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} 연결됨", "provider.connect.toast.connected.description": "이제 {{provider}} 모델을 사용할 수 있습니다.", "provider.custom.title": "사용자 지정 공급자", + "provider.custom.unavailable": "이 서버에서는 사용자 지정 공급자를 사용할 수 없습니다", "provider.custom.description.prefix": "OpenAI 호환 공급자를 구성합니다. ", "provider.custom.description.link": "공급자 구성 문서", "provider.custom.description.suffix": "를 참조하세요.", @@ -860,6 +863,12 @@ export const dict = { "error.childStore.persistedProjectIconCreateFailed": "영구 프로젝트 아이콘 생성 실패", "error.childStore.storeCreateFailed": "저장소 생성 실패", "terminal.connectionLost.abnormalClose": "WebSocket이 비정상적으로 닫힘: {{code}}", + "terminal.connectTicket.csrfError": + "PTY 연결 티켓이 오리진 또는 CSRF 검사에서 거부되었습니다. 서버의 CORS 구성을 확인하세요.", + "terminal.connectTicket.statusError": "PTY 연결 티켓 실패(상태: {{status}})", + + "titlebar.update": "업데이트", + "titlebar.updateVersion": "업데이트 {{version}}", "command.project.previous": "이전 프로젝트", "command.project.next": "다음 프로젝트", @@ -1019,4 +1028,119 @@ export const dict = { "settings.general.row.pinchZoom.description": "트랙패드 핀치 및 Ctrl-스크롤 제스처로 확대/축소 허용", "settings.updates.action.downloading": "다운로드 중...", "settings.updates.action.installing": "설치 중...", + + "command.logs.export": "로그 내보내기", + + "help.tabs.toast.ariaLabel": "탭을 소개합니다. 탭으로 작업과 활성 세션을 정리하세요", + "help.tabs.toast.dismiss": "탭 정보 닫기", + "help.tabs.title": "탭 소개", + "help.tabs.description": "탭으로 작업과 활성 세션을 정리하세요", + "help.tabs.date": "7월 14일", + "help.tabs.introduction": "이제 OpenCode Desktop은 탭을 중심으로 구성됩니다.", + "help.tabs.sessions": + "탭에서 새 세션을 시작하거나 프로젝트에서 기존 세션을 여세요. 새로운 작업을 시작할 때 새 탭을 열고, 작업을 마치면 닫으세요.", + "help.tabs.organize": + "탭을 몇 개 열어 두면 활성 세션을 더 쉽게 정리할 수 있습니다. 탭을 계속 열어 둘 예정이라면 기억하기 쉬운 이름으로 바꾸세요.", + "help.tabs.home": "새 홈 화면에서 모든 세션과 프로젝트를 확인할 수 있습니다. 세션을 선택하면 탭에서 열립니다.", + "help.tabs.persistence": "앱을 다시 열어도 탭은 그대로 열려 있습니다.", + "help.tabs.worktrees": + "새 디자인은 아직 Git Worktrees를 지원하지 않지만 곧 지원할 예정입니다. 이전 레이아웃을 계속 사용하려면 설정에서 레이아웃을 전환할 수 있습니다. 단, 몇 주 후에는 새 레이아웃이 영구 적용됩니다.", + + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "파일", + "desktop.menu.edit": "편집", + "desktop.menu.view": "보기", + "desktop.menu.go": "이동", + "desktop.menu.window": "윈도우", + "desktop.menu.help": "도움말", + "desktop.menu.checkForUpdates": "업데이트 확인...", + "desktop.menu.settings": "설정", + "desktop.menu.reloadWebview": "WebView 새로 고침", + "desktop.menu.restart": "다시 시작", + "desktop.menu.exportLogs": "로그 내보내기...", + "desktop.menu.newSession": "새 세션", + "desktop.menu.openProject": "프로젝트 열기...", + "desktop.menu.newWindow": "새 윈도우", + "desktop.menu.closeWindow": "윈도우 닫기", + "desktop.menu.undo": "실행 취소", + "desktop.menu.redo": "다시 실행", + "desktop.menu.cut": "잘라내기", + "desktop.menu.copy": "복사", + "desktop.menu.paste": "붙여넣기", + "desktop.menu.delete": "삭제", + "desktop.menu.selectAll": "모두 선택", + "desktop.menu.toggleSidebar": "사이드바 표시/숨기기", + "desktop.menu.toggleTerminal": "터미널 표시/숨기기", + "desktop.menu.toggleFileTree": "파일 트리 표시/숨기기", + "desktop.menu.reload": "새로 고침", + "desktop.menu.toggleDeveloperTools": "개발자 도구 표시/숨기기", + "desktop.menu.actualSize": "실제 크기", + "desktop.menu.zoomIn": "확대", + "desktop.menu.zoomOut": "축소", + "desktop.menu.toggleFullScreen": "전체 화면 전환", + "desktop.menu.back": "뒤로", + "desktop.menu.forward": "앞으로", + "desktop.menu.previousSession": "이전 세션", + "desktop.menu.nextSession": "다음 세션", + "desktop.menu.previousProject": "이전 프로젝트", + "desktop.menu.nextProject": "다음 프로젝트", + "desktop.menu.minimize": "최소화", + "desktop.menu.maximize": "최대화", + "desktop.menu.documentation": "OpenCode 문서", + "desktop.menu.supportForum": "지원 포럼", + "desktop.menu.shareFeedback": "피드백 보내기", + "desktop.menu.reportBug": "버그 신고", + "desktop.menu.ariaLabel": "OpenCode 메뉴", + + "desktop.updater.dialog.checkFailed.message": "업데이트를 확인하지 못했습니다.", + "desktop.updater.dialog.checkFailed.title": "업데이트 오류", + "desktop.updater.dialog.upToDate.message": "최신 버전을 사용 중입니다.", + "desktop.updater.dialog.upToDate.title": "업데이트 없음", + "desktop.updater.dialog.ready.message": "업데이트 {{version}} 다운로드가 완료되었습니다. 지금 다시 시작할까요?", + "desktop.updater.dialog.ready.title": "업데이트 준비 완료", + "desktop.updater.dialog.restart": "다시 시작", + "desktop.updater.dialog.later": "나중에", + + "desktop.recovery.action.relaunch": "다시 실행", + "desktop.recovery.action.exportLogs": "로그 내보내기", + "desktop.recovery.action.keepWaiting": "계속 기다리기", + "desktop.recovery.action.quit": "종료", + "desktop.recovery.loadFailed": "OpenCode를 불러오지 못했습니다", + "desktop.recovery.terminated": "OpenCode 윈도우가 예기치 않게 종료되었습니다", + "desktop.recovery.unresponsive": "OpenCode가 응답하지 않습니다", + "desktop.recovery.unresponsive.detail": "앱을 다시 실행하거나 로그를 열거나 계속 기다릴 수 있습니다.", + "desktop.recovery.loadFailed.detail": "윈도우: {{window}}\nURL: {{url}}\n오류: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "윈도우: {{window}}\n원인: {{reason}}\n코드: {{code}}", + "desktop.recovery.unknown": "<알 수 없음>", + + "desktop.dialog.chooseFolder": "폴더 선택", + "desktop.dialog.chooseFile": "파일 선택", + "desktop.dialog.saveFile": "파일 저장", + "desktop.dialog.files": "파일", + "desktop.server.local": "로컬 서버", + + "desktop.wsl.error.windowsOnly": "WSL은 Windows에서만 사용할 수 있습니다", + "desktop.wsl.error.unavailable": "WSL을 사용할 수 없습니다", + "desktop.wsl.error.listInstalled": "설치된 WSL 배포판 목록을 가져오지 못했습니다", + "desktop.wsl.error.listOnline": "온라인 WSL 배포판 목록을 가져오지 못했습니다", + "desktop.wsl.error.executeDistro": "배포판에서 명령을 실행할 수 없습니다", + "desktop.wsl.error.installWsl": "WSL 설치 실패", + "desktop.wsl.error.installDistro": "배포판 설치 실패: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode 설치 실패", + "desktop.wsl.error.alreadyAdded": "{{distro}} 배포판은 이미 추가되었습니다", + "desktop.wsl.error.opencodeMissing": "이 배포판에 opencode가 설치되어 있지 않습니다", + "desktop.wsl.error.opencodeCannotRun": "opencode가 설치되어 있지만 실행할 수 없습니다", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}}에 OpenCode가 설치되어 있지 않습니다", + "desktop.wsl.error.updateVersion": + "OpenCode 업데이트가 완료되었지만 {{distro}}에서 여전히 {{installed}} 버전으로 표시됩니다. 예상 버전: {{expected}}", + "desktop.wsl.error.noVersion": "버전 없음", + "desktop.wsl.error.serverExited": "WSL 서버가 시작 후 종료되었습니다(코드={{code}} 신호={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL 서버가 정상 상태가 되기 전에 종료되었습니다(코드={{code}} 신호={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": "{{distro}} 사이드카의 상태 확인이 {{timeout}}ms 후 시간 초과되었습니다", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} 명령이 {{timeout}}ms 후 시간 초과되었습니다", + "desktop.wsl.error.failedPort": "포트를 가져오지 못했습니다", + + "desktop.picker.error.notSelected": "선택기에서 파일을 선택하지 않았습니다", + "desktop.picker.error.sizeLimit": "선택한 첨부 파일이 {{limit}} MB 제한을 초과합니다", } diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts index bf141a69f547..7b98ad4f81d9 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -1,4 +1,98 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Bestand", + "desktop.menu.edit": "Bewerken", + "desktop.menu.view": "Weergave", + "desktop.menu.go": "Ga", + "desktop.menu.window": "Venster", + "desktop.menu.help": "Help", + "desktop.menu.checkForUpdates": "Controleren op updates...", + "desktop.menu.settings": "Instellingen", + "desktop.menu.reloadWebview": "Webweergave opnieuw laden", + "desktop.menu.restart": "Opnieuw opstarten", + "desktop.menu.exportLogs": "Logboeken exporteren...", + "desktop.menu.newSession": "Nieuwe sessie", + "desktop.menu.openProject": "Project openen...", + "desktop.menu.newWindow": "Nieuw venster", + "desktop.menu.closeWindow": "Venster sluiten", + "desktop.menu.undo": "Ongedaan maken", + "desktop.menu.redo": "Opnieuw uitvoeren", + "desktop.menu.cut": "Knippen", + "desktop.menu.copy": "Kopiëren", + "desktop.menu.paste": "Plakken", + "desktop.menu.delete": "Verwijderen", + "desktop.menu.selectAll": "Alles selecteren", + "desktop.menu.toggleSidebar": "Zijbalk tonen of verbergen", + "desktop.menu.toggleTerminal": "Terminal tonen of verbergen", + "desktop.menu.toggleFileTree": "Bestandsstructuur tonen of verbergen", + "desktop.menu.reload": "Opnieuw laden", + "desktop.menu.toggleDeveloperTools": "Ontwikkelaarstools tonen of verbergen", + "desktop.menu.actualSize": "Werkelijke grootte", + "desktop.menu.zoomIn": "Inzoomen", + "desktop.menu.zoomOut": "Uitzoomen", + "desktop.menu.toggleFullScreen": "Schermvullende weergave in- of uitschakelen", + "desktop.menu.back": "Terug", + "desktop.menu.forward": "Vooruit", + "desktop.menu.previousSession": "Vorige sessie", + "desktop.menu.nextSession": "Volgende sessie", + "desktop.menu.previousProject": "Vorig project", + "desktop.menu.nextProject": "Volgend project", + "desktop.menu.minimize": "Minimaliseren", + "desktop.menu.maximize": "Maximaliseren", + "desktop.menu.documentation": "OpenCode-documentatie", + "desktop.menu.supportForum": "Supportforum", + "desktop.menu.shareFeedback": "Feedback delen", + "desktop.menu.reportBug": "Een bug melden", + "desktop.menu.ariaLabel": "OpenCode-menu", + "desktop.updater.dialog.checkFailed.message": "Controleren op updates is mislukt.", + "desktop.updater.dialog.checkFailed.title": "Updatefout", + "desktop.updater.dialog.upToDate.message": "Je bent helemaal bijgewerkt.", + "desktop.updater.dialog.upToDate.title": "Geen updates", + "desktop.updater.dialog.ready.message": "Update {{version}} is gedownload. Nu opnieuw opstarten?", + "desktop.updater.dialog.ready.title": "Update gereed", + "desktop.updater.dialog.restart": "Opnieuw opstarten", + "desktop.updater.dialog.later": "Later", + "desktop.recovery.action.relaunch": "Opnieuw openen", + "desktop.recovery.action.exportLogs": "Logboeken exporteren", + "desktop.recovery.action.keepWaiting": "Blijven wachten", + "desktop.recovery.action.quit": "Stoppen", + "desktop.recovery.loadFailed": "OpenCode kan niet worden geladen", + "desktop.recovery.terminated": "Het OpenCode-venster is onverwacht beëindigd", + "desktop.recovery.unresponsive": "OpenCode reageert niet", + "desktop.recovery.unresponsive.detail": + "Je kunt de app opnieuw openen, de logboeken openen of blijven wachten.", + "desktop.recovery.loadFailed.detail": "Venster: {{window}}\nURL: {{url}}\nFout: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Venster: {{window}}\nReden: {{reason}}\nCode: {{code}}", + "desktop.recovery.unknown": "", + "desktop.dialog.chooseFolder": "Kies een map", + "desktop.dialog.chooseFile": "Kies een bestand", + "desktop.dialog.saveFile": "Bestand opslaan", + "desktop.dialog.files": "Bestanden", + "desktop.server.local": "Lokale server", + "desktop.wsl.error.windowsOnly": "WSL is alleen beschikbaar op Windows", + "desktop.wsl.error.unavailable": "WSL is niet beschikbaar", + "desktop.wsl.error.listInstalled": "Kan geïnstalleerde WSL-distributies niet weergeven", + "desktop.wsl.error.listOnline": "Kan online WSL-distributies niet weergeven", + "desktop.wsl.error.executeDistro": "Kan geen opdrachten uitvoeren in de distributie", + "desktop.wsl.error.installWsl": "Installatie van WSL is mislukt", + "desktop.wsl.error.installDistro": "Kan distributie niet installeren: {{distro}}", + "desktop.wsl.error.installOpencode": "Installatie van OpenCode is mislukt", + "desktop.wsl.error.alreadyAdded": "{{distro}} is al toegevoegd", + "desktop.wsl.error.opencodeMissing": "opencode is niet geïnstalleerd in deze distributie", + "desktop.wsl.error.opencodeCannotRun": "opencode is geïnstalleerd, maar kan niet worden uitgevoerd", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode is niet geïnstalleerd in {{distro}}", + "desktop.wsl.error.updateVersion": + "De OpenCode-update is voltooid, maar {{distro}} meldt nog steeds {{installed}}; verwacht: {{expected}}", + "desktop.wsl.error.noVersion": "geen versie", + "desktop.wsl.error.serverExited": "WSL-server is na het opstarten afgesloten (code={{code}} signaal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL-server is afgesloten voordat deze gereed was (code={{code}} signaal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Time-out bij statuscontrole van sidecar voor {{distro}} na {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "Time-out voor {{command}} {{args}} na {{timeout}} ms", + "desktop.wsl.error.failedPort": "Kan poort niet ophalen", + "desktop.picker.error.notSelected": "Er is geen bestand geselecteerd in de bestandskiezer", + "desktop.picker.error.sizeLimit": "De geselecteerde bijlagen overschrijden de limiet van {{limit}} MB", "command.category.suggested": "Voorgesteld", "command.category.view": "Weergave", "command.category.project": "Project", @@ -16,6 +110,7 @@ export const dict = { "command.category.permissions": "Machtigingen", "command.category.workspace": "Werkruimte", "command.category.settings": "Instellingen", + "command.logs.export": "Logboeken exporteren", "theme.scheme.system": "Systeem", "theme.scheme.light": "Licht", "theme.scheme.dark": "Donker", @@ -119,6 +214,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Inloggen met Claude Pro/Max", "provider.connect.selectMethod": "Selecteer de aanmeldingsmethode voor {{provider}}.", "provider.connect.method.apiKey": "API-sleutel", + "provider.connect.method.browser": "Browser", + "provider.connect.method.headless": "Headless", "provider.connect.status.inProgress": "Autorisatie in behandeling...", "provider.connect.status.waiting": "Wachten op autorisatie...", "provider.connect.status.failed": "Autorisatie mislukt: {{error}}", @@ -150,6 +247,7 @@ export const dict = { "provider.connect.toast.connected.title": "Verbonden met {{provider}}", "provider.connect.toast.connected.description": "{{provider}}-modellen zijn nu beschikbaar voor gebruik.", "provider.custom.title": "Aangepaste aanbieder", + "provider.custom.unavailable": "Aangepaste aanbieders zijn niet beschikbaar op deze server", "provider.custom.description.prefix": "Configureer een OpenAI-compatibele aanbieder. Bekijk de ", "provider.custom.description.link": "documentatie over aanbiedersconfiguratie", "provider.custom.description.suffix": ".", @@ -411,6 +509,22 @@ export const dict = { "wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.versionMismatch": "De geïnstalleerde versie komt niet overeen met de versie van de desktop-app.", "wsl.onboarding.adding": "Toevoegen...", + "help.tabs.toast.ariaLabel": + "Introductie van tabbladen. Organiseer je werk en actieve sessies met tabbladen", + "help.tabs.toast.dismiss": "Informatie over tabbladen sluiten", + "help.tabs.title": "Introductie van tabbladen", + "help.tabs.description": "Organiseer je werk en actieve sessies met tabbladen", + "help.tabs.date": "14 juli", + "help.tabs.introduction": "OpenCode Desktop is nu opgebouwd rond tabbladen.", + "help.tabs.sessions": + "Start een nieuwe sessie in een tabblad of open een bestaande sessie vanuit een van je projecten. Open een nieuw tabblad wanneer je aan iets nieuws begint en sluit het wanneer je klaar bent.", + "help.tabs.organize": + "Met enkele geopende tabbladen kun je je actieve sessies gemakkelijker organiseren. Geef tabbladen een herkenbare naam als je ze geopend wilt houden.", + "help.tabs.home": + "Je vindt al je sessies en projecten op het nieuwe startscherm. Wanneer je een sessie selecteert, wordt deze in een tabblad geopend.", + "help.tabs.persistence": "Wanneer je de app opnieuw opent, zijn je tabbladen nog steeds geopend.", + "help.tabs.worktrees": + "Het nieuwe ontwerp ondersteunt Git-worktrees nog niet, maar die ondersteuning komt binnenkort. Als je liever de vorige lay-out blijft gebruiken, kun je in Instellingen tussen de lay-outs schakelen. Houd er rekening mee dat de nieuwe lay-out over enkele weken permanent wordt.", "server.row.noUsername": "geen gebruikersnaam", "dialog.project.edit.title": "Project bewerken", "dialog.project.edit.name": "Naam", @@ -709,6 +823,11 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket abnormaal gesloten: {{code}}", "terminal.connectionLost.description": "De terminalverbinding is onderbroken. Dit kan gebeuren wanneer de server opnieuw wordt opgestart.", + "terminal.connectTicket.csrfError": + "PTY-verbindingsticket geweigerd door oorsprongs- of CSRF-controles. Controleer de CORS-configuratie van de server.", + "terminal.connectTicket.statusError": "PTY-verbindingsticket mislukt met status {{status}}", + "titlebar.update": "Bijwerken", + "titlebar.updateVersion": "Bijwerken {{version}}", "common.closeTab": "Tabblad sluiten", "common.dismiss": "Negeren", "common.moreCountSuffix": " (+{{count}} meer)", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index c52bb87c1cfb..f2048f3bd88b 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -2,6 +2,105 @@ import { dict as en } from "./en" type Keys = keyof typeof en export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Arkiv", + "desktop.menu.edit": "Rediger", + "desktop.menu.view": "Vis", + "desktop.menu.go": "Gå", + "desktop.menu.window": "Vindu", + "desktop.menu.help": "Hjelp", + "desktop.menu.checkForUpdates": "Søk etter oppdateringer...", + "desktop.menu.settings": "Innstillinger", + "desktop.menu.reloadWebview": "Last inn WebView på nytt", + "desktop.menu.restart": "Start på nytt", + "desktop.menu.exportLogs": "Eksporter logger...", + "desktop.menu.newSession": "Ny sesjon", + "desktop.menu.openProject": "Åpne prosjekt...", + "desktop.menu.newWindow": "Nytt vindu", + "desktop.menu.closeWindow": "Lukk vindu", + "desktop.menu.undo": "Angre", + "desktop.menu.redo": "Gjør om", + "desktop.menu.cut": "Klipp ut", + "desktop.menu.copy": "Kopier", + "desktop.menu.paste": "Lim inn", + "desktop.menu.delete": "Slett", + "desktop.menu.selectAll": "Merk alt", + "desktop.menu.toggleSidebar": "Vis eller skjul sidepanel", + "desktop.menu.toggleTerminal": "Vis eller skjul terminal", + "desktop.menu.toggleFileTree": "Vis eller skjul filtreet", + "desktop.menu.reload": "Last inn på nytt", + "desktop.menu.toggleDeveloperTools": "Vis eller skjul utviklerverktøy", + "desktop.menu.actualSize": "Faktisk størrelse", + "desktop.menu.zoomIn": "Zoom inn", + "desktop.menu.zoomOut": "Zoom ut", + "desktop.menu.toggleFullScreen": "Slå fullskjerm av eller på", + "desktop.menu.back": "Tilbake", + "desktop.menu.forward": "Frem", + "desktop.menu.previousSession": "Forrige sesjon", + "desktop.menu.nextSession": "Neste sesjon", + "desktop.menu.previousProject": "Forrige prosjekt", + "desktop.menu.nextProject": "Neste prosjekt", + "desktop.menu.minimize": "Minimer", + "desktop.menu.maximize": "Maksimer", + "desktop.menu.documentation": "OpenCode-dokumentasjon", + "desktop.menu.supportForum": "Brukerstøtteforum", + "desktop.menu.shareFeedback": "Del tilbakemelding", + "desktop.menu.reportBug": "Rapporter en feil", + "desktop.menu.ariaLabel": "OpenCode-meny", + + "desktop.updater.dialog.checkFailed.message": "Søket etter oppdateringer mislyktes.", + "desktop.updater.dialog.checkFailed.title": "Oppdateringsfeil", + "desktop.updater.dialog.upToDate.message": "Du har den nyeste versjonen.", + "desktop.updater.dialog.upToDate.title": "Ingen oppdateringer", + "desktop.updater.dialog.ready.message": "Oppdateringen {{version}} er lastet ned. Vil du starte på nytt nå?", + "desktop.updater.dialog.ready.title": "Oppdateringen er klar", + "desktop.updater.dialog.restart": "Start på nytt", + "desktop.updater.dialog.later": "Senere", + + "desktop.recovery.action.relaunch": "Start appen på nytt", + "desktop.recovery.action.exportLogs": "Eksporter logger", + "desktop.recovery.action.keepWaiting": "Fortsett å vente", + "desktop.recovery.action.quit": "Avslutt", + "desktop.recovery.loadFailed": "OpenCode kunne ikke lastes inn", + "desktop.recovery.terminated": "OpenCode-vinduet ble uventet avsluttet", + "desktop.recovery.unresponsive": "OpenCode svarer ikke", + "desktop.recovery.unresponsive.detail": "Du kan starte appen på nytt, åpne loggene eller fortsette å vente.", + "desktop.recovery.loadFailed.detail": "Vindu: {{window}}\nURL: {{url}}\nFeil: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Vindu: {{window}}\nÅrsak: {{reason}}\nKode: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Velg en mappe", + "desktop.dialog.chooseFile": "Velg en fil", + "desktop.dialog.saveFile": "Lagre fil", + "desktop.dialog.files": "Filer", + "desktop.server.local": "Lokal server", + + "desktop.wsl.error.windowsOnly": "WSL er bare tilgjengelig på Windows", + "desktop.wsl.error.unavailable": "WSL er utilgjengelig", + "desktop.wsl.error.listInstalled": "Kunne ikke vise installerte WSL-distribusjoner", + "desktop.wsl.error.listOnline": "Kunne ikke vise tilgjengelige WSL-distribusjoner", + "desktop.wsl.error.executeDistro": "Kan ikke kjøre kommandoer i distribusjonen", + "desktop.wsl.error.installWsl": "Installasjonen av WSL mislyktes", + "desktop.wsl.error.installDistro": "Kunne ikke installere distribusjonen: {{distro}}", + "desktop.wsl.error.installOpencode": "Installasjonen av OpenCode mislyktes", + "desktop.wsl.error.alreadyAdded": "{{distro}} er allerede lagt til", + "desktop.wsl.error.opencodeMissing": "opencode er ikke installert i denne distribusjonen", + "desktop.wsl.error.opencodeCannotRun": "opencode er installert, men kunne ikke kjøres", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode er ikke installert i {{distro}}", + "desktop.wsl.error.updateVersion": + "Oppdateringen av OpenCode er fullført, men {{distro}} rapporterer fortsatt {{installed}}; forventet {{expected}}", + "desktop.wsl.error.noVersion": "ingen versjon", + "desktop.wsl.error.serverExited": "WSL-serveren ble avsluttet etter oppstart (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL-serveren ble avsluttet før den var klar (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Tidsavbrudd for tilstandskontrollen av sidecar for {{distro}} etter {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "Tidsavbrudd for {{command}} {{args}} etter {{timeout}} ms", + "desktop.wsl.error.failedPort": "Kunne ikke hente port", + + "desktop.picker.error.notSelected": "Ingen fil ble valgt i filvelgeren", + "desktop.picker.error.sizeLimit": "De valgte vedleggene overskrider grensen på {{limit}} MB", + "command.category.suggested": "Foreslått", "command.category.view": "Visning", "command.category.project": "Prosjekt", @@ -19,6 +118,7 @@ export const dict = { "command.category.permissions": "Tillatelser", "command.category.workspace": "Arbeidsområde", "command.category.settings": "Innstillinger", + "command.logs.export": "Eksporter logger", "theme.scheme.system": "System", "theme.scheme.light": "Lys", @@ -130,6 +230,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Logg inn med Claude Pro/Max", "provider.connect.selectMethod": "Velg innloggingsmetode for {{provider}}.", "provider.connect.method.apiKey": "API-nøkkel", + "provider.connect.method.browser": "Nettleser", + "provider.connect.method.headless": "Uten nettleser", "provider.connect.status.inProgress": "Autorisering pågår...", "provider.connect.status.waiting": "Venter på autorisering...", "provider.connect.status.failed": "Autorisering mislyktes: {{error}}", @@ -162,6 +264,7 @@ export const dict = { "provider.connect.toast.connected.description": "{{provider}}-modeller er nå tilgjengelige.", "provider.custom.title": "Egendefinert leverandør", + "provider.custom.unavailable": "Egendefinerte leverandører er ikke tilgjengelige på denne serveren", "provider.custom.description.prefix": "Konfigurer en OpenAI-kompatibel leverandør. Se ", "provider.custom.description.link": "dokumentasjon for leverandørkonfigurasjon", "provider.custom.description.suffix": ".", @@ -605,6 +708,12 @@ export const dict = { "terminal.connectionLost.title": "Tilkobling mistet", "terminal.connectionLost.description": "Terminalforbindelsen ble avbrutt. Dette kan skje når serveren starter på nytt.", + "terminal.connectTicket.csrfError": + "Tilkoblingsbilletten for PTY ble avvist av opprinnelses- eller CSRF-kontroller. Kontroller serverens CORS-konfigurasjon.", + "terminal.connectTicket.statusError": "Tilkoblingsbilletten for PTY mislyktes med {{status}}", + + "titlebar.update": "Oppdater", + "titlebar.updateVersion": "Oppdater {{version}}", "common.closeTab": "Lukk fane", "common.dismiss": "Avvis", @@ -1038,6 +1147,22 @@ export const dict = { "wsl.onboarding.versionMismatch": "Den installerte versjonen samsvarer ikke med versjonen av skrivebordsappen.", "wsl.onboarding.adding": "Legger til...", + "help.tabs.toast.ariaLabel": "Vi introduserer faner. Organiser arbeidet og de aktive sesjonene dine med faner", + "help.tabs.toast.dismiss": "Avvis informasjon om faner", + "help.tabs.title": "Vi introduserer faner", + "help.tabs.description": "Organiser arbeidet og de aktive sesjonene dine med faner", + "help.tabs.date": "14. juli", + "help.tabs.introduction": "OpenCode Desktop er nå bygget rundt faner.", + "help.tabs.sessions": + "Start en ny sesjon i en fane, eller åpne en eksisterende sesjon fra et av prosjektene dine. Åpne en ny fane når du skal begynne på noe nytt, og lukk den når du er ferdig.", + "help.tabs.organize": + "Når du har noen få faner åpne, blir det enklere å organisere de aktive sesjonene. Gi fanene navn som er lette å huske, hvis du planlegger å beholde dem.", + "help.tabs.home": + "Du finner alle sesjonene og prosjektene dine på den nye Hjem-skjermen. Når du velger en sesjon, åpnes den i en fane.", + "help.tabs.persistence": "Fanene er fortsatt åpne når du åpner appen på nytt.", + "help.tabs.worktrees": + "Det nye oppsettet støtter ikke Git Worktrees ennå, men støtte kommer snart. Hvis du foretrekker å fortsette med det forrige oppsettet, kan du bytte mellom oppsettene i Innstillinger. Vær oppmerksom på at det nye oppsettet blir permanent om noen uker.", + "language.uk": "Українська", "error.page.description.localServerStartup": "Det oppstod en feil under oppstart av den lokale serveren.", diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 05c58b065e2b..e7d3e076c1ea 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -1,4 +1,102 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "فائل", + "desktop.menu.edit": "ترمیم", + "desktop.menu.view": "نظارہ", + "desktop.menu.go": "جاؤ", + "desktop.menu.window": "ونڈو", + "desktop.menu.help": "مدد", + "desktop.menu.checkForUpdates": "اپ ڈیٹاں دی پڑتال کرو...", + "desktop.menu.settings": "ترتیبات", + "desktop.menu.reloadWebview": "Webview دوبارہ لوڈ کرو", + "desktop.menu.restart": "دوبارہ شروع کرو", + "desktop.menu.exportLogs": "لاگ برآمد کرو...", + "desktop.menu.newSession": "نواں سیشن", + "desktop.menu.openProject": "پروجیکٹ کھولو...", + "desktop.menu.newWindow": "نویں ونڈو", + "desktop.menu.closeWindow": "ونڈو بند کرو", + "desktop.menu.undo": "کالعدم کرو", + "desktop.menu.redo": "دوبارہ کرو", + "desktop.menu.cut": "کٹ کرو", + "desktop.menu.copy": "کاپی کرو", + "desktop.menu.paste": "چپکاؤ", + "desktop.menu.delete": "مٹاؤ", + "desktop.menu.selectAll": "سارا کجھ چنو", + "desktop.menu.toggleSidebar": "سائڈبار ٹوگل کرو", + "desktop.menu.toggleTerminal": "ٹرمینل ٹوگل کرو", + "desktop.menu.toggleFileTree": "فائل ٹری ٹوگل کرو", + "desktop.menu.reload": "دوبارہ لوڈ کرو", + "desktop.menu.toggleDeveloperTools": "ڈیویلپر ٹولز ٹوگل کرو", + "desktop.menu.actualSize": "اصل سائز", + "desktop.menu.zoomIn": "وڈا کرو", + "desktop.menu.zoomOut": "چھوٹا کرو", + "desktop.menu.toggleFullScreen": "پوری سکرین ٹوگل کرو", + "desktop.menu.back": "پچھے", + "desktop.menu.forward": "اگے", + "desktop.menu.previousSession": "پچھلا سیشن", + "desktop.menu.nextSession": "اگلا سیشن", + "desktop.menu.previousProject": "پچھلا پروجیکٹ", + "desktop.menu.nextProject": "اگلا پروجیکٹ", + "desktop.menu.minimize": "چھوٹا کرو", + "desktop.menu.maximize": "وڈا کرو", + "desktop.menu.documentation": "OpenCode دیاں دستاویزاں", + "desktop.menu.supportForum": "مدد دا فورم", + "desktop.menu.shareFeedback": "اپنی رائے سانجھی کرو", + "desktop.menu.reportBug": "خرابی دی رپورٹ کرو", + "desktop.menu.ariaLabel": "OpenCode مینو", + + "desktop.updater.dialog.checkFailed.message": "اپ ڈیٹ دی پڑتال ناکام ہو گئی۔", + "desktop.updater.dialog.checkFailed.title": "اپ ڈیٹ دی غلطی", + "desktop.updater.dialog.upToDate.message": "تہاڈے کول تازہ ترین ورژن اے۔", + "desktop.updater.dialog.upToDate.title": "کوئی اپ ڈیٹ نئیں", + "desktop.updater.dialog.ready.message": "اپ ڈیٹ {{version}} ڈاؤن لوڈ ہو گئی اے۔ ہن دوبارہ شروع کرنا اے؟", + "desktop.updater.dialog.ready.title": "اپ ڈیٹ تیار اے", + "desktop.updater.dialog.restart": "دوبارہ شروع کرو", + "desktop.updater.dialog.later": "پچھوں", + + "desktop.recovery.action.relaunch": "دوبارہ چلاؤ", + "desktop.recovery.action.exportLogs": "لاگ برآمد کرو", + "desktop.recovery.action.keepWaiting": "اڈیک جاری رکھو", + "desktop.recovery.action.quit": "بند کرو", + "desktop.recovery.loadFailed": "OpenCode لوڈ نئیں ہو سکیا", + "desktop.recovery.terminated": "OpenCode دی ونڈو اچانک بند ہو گئی", + "desktop.recovery.unresponsive": "OpenCode جواب نئیں دے رہیا", + "desktop.recovery.unresponsive.detail": "تسی ایپ دوبارہ چلا سکدے او، لاگ کھول سکدے او یا اڈیک جاری رکھ سکدے او۔", + "desktop.recovery.loadFailed.detail": "ونڈو: {{window}}\nURL: {{url}}\nغلطی: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "ونڈو: {{window}}\nکارن: {{reason}}\nکوڈ: {{code}}", + "desktop.recovery.unknown": "<نامعلوم>", + + "desktop.dialog.chooseFolder": "فولڈر چنو", + "desktop.dialog.chooseFile": "فائل چنو", + "desktop.dialog.saveFile": "فائل محفوظ کرو", + "desktop.dialog.files": "فائلاں", + "desktop.server.local": "مقامی سرور", + + "desktop.wsl.error.windowsOnly": "WSL صرف Windows تے دستیاب اے", + "desktop.wsl.error.unavailable": "WSL دستیاب نئیں", + "desktop.wsl.error.listInstalled": "انسٹال کیتے WSL ڈسٹرو دی فہرست نئیں بن سکی", + "desktop.wsl.error.listOnline": "آن لائن WSL ڈسٹرو دی فہرست نئیں بن سکی", + "desktop.wsl.error.executeDistro": "ڈسٹرو وچ کمانڈاں نئیں چل سکدیاں", + "desktop.wsl.error.installWsl": "WSL دی تنصیب ناکام ہو گئی", + "desktop.wsl.error.installDistro": "ڈسٹرو انسٹال نئیں ہو سکیا: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode دی تنصیب ناکام ہو گئی", + "desktop.wsl.error.alreadyAdded": "{{distro}} پہلے ای شامل اے", + "desktop.wsl.error.opencodeMissing": "اس ڈسٹرو وچ opencode انسٹال نئیں اے", + "desktop.wsl.error.opencodeCannotRun": "opencode انسٹال اے پر چل نئیں سکیا", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}} وچ OpenCode انسٹال نئیں اے", + "desktop.wsl.error.updateVersion": + "OpenCode دی اپ ڈیٹ پوری ہو گئی پر {{distro}} ہلے وی {{installed}} دس رہیا اے؛ {{expected}} چاہیدا سی", + "desktop.wsl.error.noVersion": "کوئی ورژن نئیں", + "desktop.wsl.error.serverExited": "WSL سرور شروع ہون پچھوں بند ہو گیا (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL سرور ٹھیک حالت وچ آون توں پہلاں بند ہو گیا (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": "{{distro}} دے sidecar دی صحت پڑتال لئی مقرر ویلا {{timeout}}ms پچھوں مُک گیا", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} لئی مقرر ویلا {{timeout}}ms پچھوں مُک گیا", + "desktop.wsl.error.failedPort": "پورٹ نئیں مل سکی", + + "desktop.picker.error.notSelected": "چونکار ولوں فائل نئیں چنی گئی", + "desktop.picker.error.sizeLimit": "چنیاں منسلک فائلاں {{limit}} MB دی حد توں ودھ نیں", + "command.category.suggested": "تجویز کیتا گیا", "command.category.view": "نظارہ", "command.category.project": "پروجیکٹ", @@ -16,6 +114,7 @@ export const dict = { "command.category.permissions": "اجازتاں", "command.category.workspace": "ورک سپیس", "command.category.settings": "ترتیبات", + "command.logs.export": "لاگ برآمد کرو", "theme.scheme.system": "نظام", "theme.scheme.light": "ہلکا", "theme.scheme.dark": "گہرا", @@ -119,6 +218,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude پرو/میکس دے نال لاگ ان کرو", "provider.connect.selectMethod": "{{provider}} لئی لاگ ان دا طریقہ منتخب کرو۔", "provider.connect.method.apiKey": "API کلید", + "provider.connect.method.browser": "براؤزر", + "provider.connect.method.headless": "ہیڈلیس", "provider.connect.status.inProgress": "اختیار دیندے پئے آں...", "provider.connect.status.waiting": "اختیار ملن دی اڈیک اے...", "provider.connect.status.failed": "اختیار نئیں مل سکیا: {{error}}", @@ -185,6 +286,7 @@ export const dict = { "provider.custom.error.baseURL.format": "http:// یا https:// توں شروع کرنا ضروری اے۔", "provider.custom.error.required": "لازمی اے", "provider.custom.error.duplicate": "دُہرا", + "provider.custom.unavailable": "ایس سرور تے من پسند پرووائیڈر دستیاب نئیں نیں", "provider.disconnect.toast.disconnected.title": "{{provider}} منقطع", "provider.disconnect.toast.disconnected.description": "{{provider}} ماڈل ہن دستیاب نئیں ہن۔", "model.tag.free": "مفت", @@ -411,6 +513,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "ڈیسک ٹاپ {{version}}", "wsl.onboarding.versionMismatch": "انسٹال کیتا گیا ورژن ڈیسک ٹاپ ایپ ورژن نال میل نئیں کھاندا۔", "wsl.onboarding.adding": "شامل کر رہیا واں...", + "help.tabs.toast.ariaLabel": "ٹیباں دی جان پچھان۔ اپنے کم تے چالو سیشناں نوں ٹیباں نال ترتیب دیو", + "help.tabs.toast.dismiss": "ٹیباں دی جانکاری ہٹاؤ", + "help.tabs.title": "ٹیباں دی جان پچھان", + "help.tabs.description": "اپنے کم تے چالو سیشناں نوں ٹیباں نال ترتیب دیو", + "help.tabs.date": "14 جولائی", + "help.tabs.introduction": "OpenCode Desktop ہن ٹیباں دے گرد بنایا گیا اے۔", + "help.tabs.sessions": + "اک ٹیب وچ نواں سیشن شروع کرو یا اپنے کسے وی پروجیکٹ توں موجودہ سیشن کھولو۔ جدوں کجھ نواں شروع کرو تے نواں ٹیب کھولو تے کم پورا ہون تے اینوں بند کر دیو۔", + "help.tabs.organize": + "کجھ ٹیب کھلے رکھن نال چالو سیشناں نوں ترتیب نال رکھنا سوکھا ہوندا اے۔ جے تسی ٹیب سنبھال کے رکھنا اے تے اینوں کوئی چیتے رہن آلا ناں دیو۔", + "help.tabs.home": + "نویں ہوم سکرین تے تہانوں اپنے سارے سیشن تے پروجیکٹ لبھن گے۔ سیشن چنن نال اوہ اک ٹیب وچ کھل جاندا اے۔", + "help.tabs.persistence": "ایپ دوبارہ کھولن تے تہاڈے ٹیب کھلے رہندے نیں۔", + "help.tabs.worktrees": + "نواں ڈیزائن ہلے Git Worktrees نوں سہارا نئیں دیندا، ایہ سہولت چھیتی آ رہی اے۔ جے تسی پچھلا لے آؤٹ ورتدے رہنا چاہندے او تے ترتیبات وچ لے آؤٹ بدل سکدے او۔ بس چیتے رکھو کہ کجھ ہفتیاں پچھوں نواں لے آؤٹ پکا ہو جائے گا۔", "server.row.noUsername": "کوئی ورتن آلا ناں نئیں", "dialog.project.edit.title": "پروجیکٹ وچ ترمیم کرو", "dialog.project.edit.name": "ناں", @@ -703,6 +820,11 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket غیر معمولی طور تے بند کیتا گیا: {{code}}", "terminal.connectionLost.description": "ٹرمینل کنکشن وچ رکاوٹ پے گئی۔ ایہ اودوں ہو سکدا اے جدوں سرور دوبارہ شروع ہوندا اے۔", + "terminal.connectTicket.csrfError": + "PTY کنکشن ٹکٹ نوں ماخذ یا CSRF جانچاں نے رد کر دتا۔ سرور دی CORS کنفگ چیک کرو۔", + "terminal.connectTicket.statusError": "PTY کنکشن ٹکٹ {{status}} سٹیٹس نال ناکام ہو گیا", + "titlebar.update": "اپ ڈیٹ کرو", + "titlebar.updateVersion": "اپ ڈیٹ کرو {{version}}", "common.closeTab": "ٹیب بند کرو", "common.dismiss": "برخاست کرو", "common.moreCountSuffix": " (+{{count}} ہور)", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index c49c28178b90..b46590f80006 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -1,4 +1,105 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Plik", + "desktop.menu.edit": "Edycja", + "desktop.menu.view": "Widok", + "desktop.menu.go": "Przejdź", + "desktop.menu.window": "Okno", + "desktop.menu.help": "Pomoc", + "desktop.menu.checkForUpdates": "Sprawdź aktualizacje...", + "desktop.menu.settings": "Ustawienia", + "desktop.menu.reloadWebview": "Wczytaj ponownie widok internetowy", + "desktop.menu.restart": "Uruchom ponownie", + "desktop.menu.exportLogs": "Eksportuj dzienniki...", + "desktop.menu.newSession": "Nowa sesja", + "desktop.menu.openProject": "Otwórz projekt...", + "desktop.menu.newWindow": "Nowe okno", + "desktop.menu.closeWindow": "Zamknij okno", + "desktop.menu.undo": "Cofnij", + "desktop.menu.redo": "Ponów", + "desktop.menu.cut": "Wytnij", + "desktop.menu.copy": "Kopiuj", + "desktop.menu.paste": "Wklej", + "desktop.menu.delete": "Usuń", + "desktop.menu.selectAll": "Zaznacz wszystko", + "desktop.menu.toggleSidebar": "Przełącz pasek boczny", + "desktop.menu.toggleTerminal": "Przełącz terminal", + "desktop.menu.toggleFileTree": "Przełącz drzewo plików", + "desktop.menu.reload": "Wczytaj ponownie", + "desktop.menu.toggleDeveloperTools": "Przełącz narzędzia deweloperskie", + "desktop.menu.actualSize": "Rozmiar rzeczywisty", + "desktop.menu.zoomIn": "Powiększ", + "desktop.menu.zoomOut": "Pomniejsz", + "desktop.menu.toggleFullScreen": "Przełącz tryb pełnoekranowy", + "desktop.menu.back": "Wstecz", + "desktop.menu.forward": "Dalej", + "desktop.menu.previousSession": "Poprzednia sesja", + "desktop.menu.nextSession": "Następna sesja", + "desktop.menu.previousProject": "Poprzedni projekt", + "desktop.menu.nextProject": "Następny projekt", + "desktop.menu.minimize": "Minimalizuj", + "desktop.menu.maximize": "Maksymalizuj", + "desktop.menu.documentation": "Dokumentacja OpenCode", + "desktop.menu.supportForum": "Forum pomocy technicznej", + "desktop.menu.shareFeedback": "Prześlij opinię", + "desktop.menu.reportBug": "Zgłoś błąd", + "desktop.menu.ariaLabel": "Menu OpenCode", + + "desktop.updater.dialog.checkFailed.message": "Nie udało się sprawdzić dostępności aktualizacji.", + "desktop.updater.dialog.checkFailed.title": "Błąd aktualizacji", + "desktop.updater.dialog.upToDate.message": "Masz najnowszą wersję.", + "desktop.updater.dialog.upToDate.title": "Brak aktualizacji", + "desktop.updater.dialog.ready.message": "Pobrano aktualizację {{version}}. Uruchomić ponownie teraz?", + "desktop.updater.dialog.ready.title": "Aktualizacja jest gotowa", + "desktop.updater.dialog.restart": "Uruchom ponownie", + "desktop.updater.dialog.later": "Później", + + "desktop.recovery.action.relaunch": "Uruchom ponownie", + "desktop.recovery.action.exportLogs": "Eksportuj dzienniki", + "desktop.recovery.action.keepWaiting": "Czekaj dalej", + "desktop.recovery.action.quit": "Zakończ", + "desktop.recovery.loadFailed": "Nie udało się wczytać OpenCode", + "desktop.recovery.terminated": "Okno OpenCode zostało nieoczekiwanie zamknięte", + "desktop.recovery.unresponsive": "OpenCode nie odpowiada", + "desktop.recovery.unresponsive.detail": + "Możesz ponownie uruchomić aplikację, otworzyć dzienniki lub nadal czekać.", + "desktop.recovery.loadFailed.detail": "Okno: {{window}}\nURL: {{url}}\nBłąd: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Okno: {{window}}\nPrzyczyna: {{reason}}\nKod: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Wybierz folder", + "desktop.dialog.chooseFile": "Wybierz plik", + "desktop.dialog.saveFile": "Zapisz plik", + "desktop.dialog.files": "Pliki", + "desktop.server.local": "Serwer lokalny", + + "desktop.wsl.error.windowsOnly": "WSL jest dostępny tylko w systemie Windows", + "desktop.wsl.error.unavailable": "WSL jest niedostępny", + "desktop.wsl.error.listInstalled": "Nie udało się wyświetlić listy zainstalowanych dystrybucji WSL", + "desktop.wsl.error.listOnline": "Nie udało się wyświetlić listy dystrybucji WSL dostępnych online", + "desktop.wsl.error.executeDistro": "Nie można wykonywać poleceń w dystrybucji", + "desktop.wsl.error.installWsl": "Nie udało się zainstalować WSL", + "desktop.wsl.error.installDistro": "Nie udało się zainstalować dystrybucji: {{distro}}", + "desktop.wsl.error.installOpencode": "Nie udało się zainstalować OpenCode", + "desktop.wsl.error.alreadyAdded": "Dystrybucja {{distro}} została już dodana", + "desktop.wsl.error.opencodeMissing": "Program opencode nie jest zainstalowany w tej dystrybucji", + "desktop.wsl.error.opencodeCannotRun": "Program opencode jest zainstalowany, ale nie można go uruchomić", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode nie jest zainstalowany w dystrybucji {{distro}}", + "desktop.wsl.error.updateVersion": + "Aktualizacja OpenCode została ukończona, ale dystrybucja {{distro}} nadal zgłasza wersję {{installed}}; oczekiwano {{expected}}", + "desktop.wsl.error.noVersion": "brak wersji", + "desktop.wsl.error.serverExited": "Serwer WSL zakończył działanie po uruchomieniu (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Serwer WSL zakończył działanie przed osiągnięciem gotowości (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Przekroczono limit czasu {{timeout}}ms podczas sprawdzania kondycji procesu sidecar dla dystrybucji {{distro}}", + "desktop.wsl.error.commandTimeout": + "Polecenie {{command}} {{args}} przekroczyło limit czasu {{timeout}}ms", + "desktop.wsl.error.failedPort": "Nie udało się uzyskać portu", + + "desktop.picker.error.notSelected": "Nie wybrano pliku w oknie wyboru", + "desktop.picker.error.sizeLimit": "Wybrane załączniki przekraczają limit {{limit}} MB", + "command.category.suggested": "Sugerowane", "command.category.view": "Widok", "command.category.project": "Projekt", @@ -16,6 +117,7 @@ export const dict = { "command.category.permissions": "Uprawnienia", "command.category.workspace": "Przestrzeń robocza", "command.category.settings": "Ustawienia", + "command.logs.export": "Eksportuj dzienniki", "theme.scheme.system": "Systemowy", "theme.scheme.light": "Jasny", "theme.scheme.dark": "Ciemny", @@ -119,6 +221,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Zaloguj się za pomocą konta Claude Pro/Max", "provider.connect.selectMethod": "Wybierz metodę logowania dla {{provider}}.", "provider.connect.method.apiKey": "Klucz API", + "provider.connect.method.browser": "Przeglądarka", + "provider.connect.method.headless": "Bez przeglądarki", "provider.connect.status.inProgress": "Autoryzacja w toku...", "provider.connect.status.waiting": "Oczekiwanie na autoryzację...", "provider.connect.status.failed": "Autoryzacja nie powiodła się: {{error}}", @@ -150,6 +254,7 @@ export const dict = { "provider.connect.toast.connected.title": "Połączono {{provider}}", "provider.connect.toast.connected.description": "Modele {{provider}} są teraz dostępne do użycia.", "provider.custom.title": "Dostawca niestandardowy", + "provider.custom.unavailable": "Niestandardowi dostawcy są niedostępni na tym serwerze", "provider.custom.description.prefix": "Skonfiguruj dostawcę zgodnego z OpenAI. Zobacz ", "provider.custom.description.link": "dokumentację konfiguracji dostawcy", "provider.custom.description.suffix": ".", @@ -405,6 +510,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "aplikacja komputerowa {{version}}", "wsl.onboarding.versionMismatch": "Zainstalowana wersja nie jest zgodna z wersją aplikacji komputerowej.", "wsl.onboarding.adding": "Dodawanie...", + "help.tabs.toast.ariaLabel": "Przedstawiamy karty. Organizuj pracę i aktywne sesje za pomocą kart", + "help.tabs.toast.dismiss": "Odrzuć informacje o kartach", + "help.tabs.title": "Przedstawiamy karty", + "help.tabs.description": "Organizuj pracę i aktywne sesje za pomocą kart", + "help.tabs.date": "14 lipca", + "help.tabs.introduction": "OpenCode Desktop korzysta teraz z układu opartego na kartach.", + "help.tabs.sessions": + "Rozpocznij nową sesję na karcie lub otwórz istniejącą sesję z dowolnego projektu. Otwórz nową kartę, gdy zaczynasz coś nowego, i zamknij ją po zakończeniu.", + "help.tabs.organize": + "Kilka otwartych kart ułatwia organizowanie aktywnych sesji. Jeśli chcesz zachować karty na dłużej, nadaj im łatwe do zapamiętania nazwy.", + "help.tabs.home": + "Wszystkie sesje i projekty znajdziesz na nowym ekranie głównym. Wybranie sesji powoduje otwarcie jej na karcie.", + "help.tabs.persistence": "Po ponownym otwarciu aplikacji karty pozostają otwarte.", + "help.tabs.worktrees": + "Nowy układ nie obsługuje jeszcze drzew roboczych Git, ale ta funkcja pojawi się wkrótce. Jeśli wolisz nadal korzystać z poprzedniego układu, możesz przełączać układy w Ustawieniach. Pamiętaj jednak, że za kilka tygodni nowy układ stanie się jedynym dostępnym.", "dialog.project.edit.title": "Edytuj projekt", "dialog.project.edit.name": "Nazwa", "dialog.project.edit.icon": "Ikona", @@ -667,6 +787,12 @@ export const dict = { "terminal.connectionLost.title": "Utracono połączenie", "terminal.connectionLost.description": "Połączenie z terminalem zostało przerwane. Może się to zdarzyć podczas ponownego uruchamiania serwera.", + "terminal.connectTicket.csrfError": + "Bilet połączenia PTY został odrzucony podczas kontroli źródła lub CSRF. Sprawdź konfigurację CORS serwera.", + "terminal.connectTicket.statusError": "Bilet połączenia PTY zakończył się niepowodzeniem ze statusem {{status}}", + + "titlebar.update": "Aktualizuj", + "titlebar.updateVersion": "Aktualizuj {{version}}", "common.closeTab": "Zamknij kartę", "common.dismiss": "Odrzuć", "common.requestFailed": "Żądanie nie powiodło się", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 4e1877cba954..64909b225d1d 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -1,4 +1,106 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Файл", + "desktop.menu.edit": "Правка", + "desktop.menu.view": "Вид", + "desktop.menu.go": "Переход", + "desktop.menu.window": "Окно", + "desktop.menu.help": "Справка", + "desktop.menu.checkForUpdates": "Проверить наличие обновлений...", + "desktop.menu.settings": "Настройки", + "desktop.menu.reloadWebview": "Перезагрузить веб-представление", + "desktop.menu.restart": "Перезапустить", + "desktop.menu.exportLogs": "Экспортировать журналы...", + "desktop.menu.newSession": "Новая сессия", + "desktop.menu.openProject": "Открыть проект...", + "desktop.menu.newWindow": "Новое окно", + "desktop.menu.closeWindow": "Закрыть окно", + "desktop.menu.undo": "Отменить", + "desktop.menu.redo": "Повторить", + "desktop.menu.cut": "Вырезать", + "desktop.menu.copy": "Копировать", + "desktop.menu.paste": "Вставить", + "desktop.menu.delete": "Удалить", + "desktop.menu.selectAll": "Выбрать всё", + "desktop.menu.toggleSidebar": "Переключить боковую панель", + "desktop.menu.toggleTerminal": "Переключить терминал", + "desktop.menu.toggleFileTree": "Переключить дерево файлов", + "desktop.menu.reload": "Перезагрузить", + "desktop.menu.toggleDeveloperTools": "Переключить инструменты разработчика", + "desktop.menu.actualSize": "Фактический размер", + "desktop.menu.zoomIn": "Увеличить", + "desktop.menu.zoomOut": "Уменьшить", + "desktop.menu.toggleFullScreen": "Переключить полноэкранный режим", + "desktop.menu.back": "Назад", + "desktop.menu.forward": "Вперёд", + "desktop.menu.previousSession": "Предыдущая сессия", + "desktop.menu.nextSession": "Следующая сессия", + "desktop.menu.previousProject": "Предыдущий проект", + "desktop.menu.nextProject": "Следующий проект", + "desktop.menu.minimize": "Свернуть", + "desktop.menu.maximize": "Развернуть", + "desktop.menu.documentation": "Документация OpenCode", + "desktop.menu.supportForum": "Форум поддержки", + "desktop.menu.shareFeedback": "Отправить отзыв", + "desktop.menu.reportBug": "Сообщить об ошибке", + "desktop.menu.ariaLabel": "Меню OpenCode", + + "desktop.updater.dialog.checkFailed.message": "Не удалось проверить наличие обновлений.", + "desktop.updater.dialog.checkFailed.title": "Ошибка обновления", + "desktop.updater.dialog.upToDate.message": "У вас установлена последняя версия.", + "desktop.updater.dialog.upToDate.title": "Обновлений нет", + "desktop.updater.dialog.ready.message": "Обновление {{version}} загружено. Перезапустить сейчас?", + "desktop.updater.dialog.ready.title": "Обновление готово", + "desktop.updater.dialog.restart": "Перезапустить", + "desktop.updater.dialog.later": "Позже", + + "desktop.recovery.action.relaunch": "Запустить снова", + "desktop.recovery.action.exportLogs": "Экспортировать журналы", + "desktop.recovery.action.keepWaiting": "Продолжить ожидание", + "desktop.recovery.action.quit": "Выйти", + "desktop.recovery.loadFailed": "Не удалось загрузить OpenCode", + "desktop.recovery.terminated": "Окно OpenCode неожиданно завершило работу", + "desktop.recovery.unresponsive": "OpenCode не отвечает", + "desktop.recovery.unresponsive.detail": + "Можно снова запустить приложение, открыть журналы или продолжить ожидание.", + "desktop.recovery.loadFailed.detail": "Окно: {{window}}\nURL: {{url}}\nОшибка: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Окно: {{window}}\nПричина: {{reason}}\nКод: {{code}}", + "desktop.recovery.unknown": "<неизвестно>", + + "desktop.dialog.chooseFolder": "Выберите папку", + "desktop.dialog.chooseFile": "Выберите файл", + "desktop.dialog.saveFile": "Сохранить файл", + "desktop.dialog.files": "Файлы", + "desktop.server.local": "Локальный сервер", + + "desktop.wsl.error.windowsOnly": "WSL доступна только в Windows", + "desktop.wsl.error.unavailable": "WSL недоступна", + "desktop.wsl.error.listInstalled": "Не удалось получить список установленных дистрибутивов WSL", + "desktop.wsl.error.listOnline": "Не удалось получить список доступных дистрибутивов WSL", + "desktop.wsl.error.executeDistro": "Не удаётся выполнять команды в дистрибутиве", + "desktop.wsl.error.installWsl": "Не удалось установить WSL", + "desktop.wsl.error.installDistro": "Не удалось установить дистрибутив: {{distro}}", + "desktop.wsl.error.installOpencode": "Не удалось установить OpenCode", + "desktop.wsl.error.alreadyAdded": "Дистрибутив {{distro}} уже добавлен", + "desktop.wsl.error.opencodeMissing": "opencode не установлен в этом дистрибутиве", + "desktop.wsl.error.opencodeCannotRun": "opencode установлен, но его не удалось запустить", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode не установлен в {{distro}}", + "desktop.wsl.error.updateVersion": + "Обновление OpenCode завершено, но {{distro}} по-прежнему сообщает версию {{installed}}; ожидалась {{expected}}", + "desktop.wsl.error.noVersion": "нет версии", + "desktop.wsl.error.serverExited": + "Сервер WSL завершил работу после запуска (код={{code}}, сигнал={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Сервер WSL завершил работу до перехода в рабочее состояние (код={{code}}, сигнал={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Истекло время ожидания проверки работоспособности Sidecar для {{distro}} ({{timeout}} мс)", + "desktop.wsl.error.commandTimeout": + "Истекло время ожидания выполнения {{command}} {{args}} ({{timeout}} мс)", + "desktop.wsl.error.failedPort": "Не удалось получить порт", + + "desktop.picker.error.notSelected": "В средстве выбора не выбран файл", + "desktop.picker.error.sizeLimit": "Размер выбранных вложений превышает лимит {{limit}} МБ", + "command.category.suggested": "Предложено", "command.category.view": "Просмотр", "command.category.project": "Проект", @@ -16,6 +118,7 @@ export const dict = { "command.category.permissions": "Разрешения", "command.category.workspace": "Рабочее пространство", "command.category.settings": "Настройки", + "command.logs.export": "Экспортировать журналы", "theme.scheme.system": "Системная", "theme.scheme.light": "Светлая", @@ -131,6 +234,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Войти с помощью Claude Pro/Max", "provider.connect.selectMethod": "Выберите способ входа для {{provider}}.", "provider.connect.method.apiKey": "Ключ API", + "provider.connect.method.browser": "Браузер", + "provider.connect.method.headless": "Без браузера", "provider.connect.status.inProgress": "Авторизация...", "provider.connect.status.waiting": "Ожидание авторизации...", "provider.connect.status.failed": "Ошибка авторизации: {{error}}", @@ -163,6 +268,7 @@ export const dict = { "provider.connect.toast.connected.description": "Модели {{provider}} теперь доступны.", "provider.custom.title": "Пользовательский провайдер", + "provider.custom.unavailable": "Пользовательские провайдеры недоступны на этом сервере", "provider.custom.description.prefix": "Настройте провайдера, совместимого с OpenAI. См. ", "provider.custom.description.link": "документацию по настройке провайдера", "provider.custom.description.suffix": ".", @@ -432,6 +538,22 @@ export const dict = { "wsl.onboarding.versionMismatch": "Установленная версия не соответствует версии приложения.", "wsl.onboarding.adding": "Добавление...", + "help.tabs.toast.ariaLabel": "Представляем вкладки. Организуйте работу и активные сессии с помощью вкладок", + "help.tabs.toast.dismiss": "Закрыть сведения о вкладках", + "help.tabs.title": "Представляем вкладки", + "help.tabs.description": "Организуйте работу и активные сессии с помощью вкладок", + "help.tabs.date": "14 июля", + "help.tabs.introduction": "Теперь работа в OpenCode Desktop организована с помощью вкладок.", + "help.tabs.sessions": + "Начните новую сессию во вкладке или откройте существующую сессию из любого проекта. Открывайте новую вкладку, когда начинаете новую задачу, и закрывайте её по завершении.", + "help.tabs.organize": + "Несколько открытых вкладок упрощают организацию активных сессий. Если вы планируете оставить вкладки открытыми, присвойте им запоминающиеся названия.", + "help.tabs.home": + "Все сессии и проекты находятся на новом экране «Главная». При выборе сессии она открывается во вкладке.", + "help.tabs.persistence": "После повторного открытия приложения вкладки останутся открытыми.", + "help.tabs.worktrees": + "Новый дизайн пока не поддерживает Git Worktrees, но эта возможность скоро появится. Если вы предпочитаете прежний макет, переключиться между макетами можно в разделе «Настройки». Учтите, что через несколько недель новый макет станет постоянным.", + "dialog.project.edit.title": "Редактировать проект", "dialog.project.edit.name": "Название", "dialog.project.edit.icon": "Значок", @@ -726,6 +848,12 @@ export const dict = { "terminal.connectionLost.title": "Соединение потеряно", "terminal.connectionLost.description": "Соединение с терминалом прервано. Это может произойти при перезапуске сервера.", + "terminal.connectTicket.csrfError": + "Билет подключения PTY отклонён при проверке источника или CSRF. Проверьте конфигурацию CORS сервера.", + "terminal.connectTicket.statusError": "Не удалось получить билет подключения PTY, статус: {{status}}", + + "titlebar.update": "Обновить", + "titlebar.updateVersion": "Обновить {{version}}", "common.closeTab": "Закрыть вкладку", "common.dismiss": "Закрыть", diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts index 2b76f3e7b6c9..401c47bb4929 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -1,4 +1,97 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Arkiv", + "desktop.menu.edit": "Redigera", + "desktop.menu.view": "Visa", + "desktop.menu.go": "Gå", + "desktop.menu.window": "Fönster", + "desktop.menu.help": "Hjälp", + "desktop.menu.checkForUpdates": "Sök efter uppdateringar...", + "desktop.menu.settings": "Inställningar", + "desktop.menu.reloadWebview": "Läs in webbvyn igen", + "desktop.menu.restart": "Starta om", + "desktop.menu.exportLogs": "Exportera loggar...", + "desktop.menu.newSession": "Ny session", + "desktop.menu.openProject": "Öppna projekt...", + "desktop.menu.newWindow": "Nytt fönster", + "desktop.menu.closeWindow": "Stäng fönster", + "desktop.menu.undo": "Ångra", + "desktop.menu.redo": "Gör om", + "desktop.menu.cut": "Klipp ut", + "desktop.menu.copy": "Kopiera", + "desktop.menu.paste": "Klistra in", + "desktop.menu.delete": "Radera", + "desktop.menu.selectAll": "Markera allt", + "desktop.menu.toggleSidebar": "Växla sidofältet", + "desktop.menu.toggleTerminal": "Växla terminal", + "desktop.menu.toggleFileTree": "Växla filträdet", + "desktop.menu.reload": "Läs in igen", + "desktop.menu.toggleDeveloperTools": "Växla utvecklarverktyg", + "desktop.menu.actualSize": "Verklig storlek", + "desktop.menu.zoomIn": "Zooma in", + "desktop.menu.zoomOut": "Zooma ut", + "desktop.menu.toggleFullScreen": "Växla helskärmsläge", + "desktop.menu.back": "Bakåt", + "desktop.menu.forward": "Framåt", + "desktop.menu.previousSession": "Föregående session", + "desktop.menu.nextSession": "Nästa session", + "desktop.menu.previousProject": "Föregående projekt", + "desktop.menu.nextProject": "Nästa projekt", + "desktop.menu.minimize": "Minimera", + "desktop.menu.maximize": "Maximera", + "desktop.menu.documentation": "OpenCode-dokumentation", + "desktop.menu.supportForum": "Supportforum", + "desktop.menu.shareFeedback": "Skicka feedback", + "desktop.menu.reportBug": "Rapportera ett fel", + "desktop.menu.ariaLabel": "OpenCode-meny", + "desktop.updater.dialog.checkFailed.message": "Det gick inte att söka efter uppdateringar.", + "desktop.updater.dialog.checkFailed.title": "Uppdateringsfel", + "desktop.updater.dialog.upToDate.message": "Du har den senaste versionen.", + "desktop.updater.dialog.upToDate.title": "Inga uppdateringar", + "desktop.updater.dialog.ready.message": "Uppdateringen {{version}} har hämtats. Vill du starta om nu?", + "desktop.updater.dialog.ready.title": "Uppdateringen är klar", + "desktop.updater.dialog.restart": "Starta om", + "desktop.updater.dialog.later": "Senare", + "desktop.recovery.action.relaunch": "Starta om appen", + "desktop.recovery.action.exportLogs": "Exportera loggar", + "desktop.recovery.action.keepWaiting": "Fortsätt vänta", + "desktop.recovery.action.quit": "Avsluta", + "desktop.recovery.loadFailed": "OpenCode kunde inte läsas in", + "desktop.recovery.terminated": "OpenCode-fönstret avslutades oväntat", + "desktop.recovery.unresponsive": "OpenCode svarar inte", + "desktop.recovery.unresponsive.detail": "Du kan starta om appen, öppna loggarna eller fortsätta vänta.", + "desktop.recovery.loadFailed.detail": "Fönster: {{window}}\nURL: {{url}}\nFel: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Fönster: {{window}}\nOrsak: {{reason}}\nKod: {{code}}", + "desktop.recovery.unknown": "", + "desktop.dialog.chooseFolder": "Välj en mapp", + "desktop.dialog.chooseFile": "Välj en fil", + "desktop.dialog.saveFile": "Spara fil", + "desktop.dialog.files": "Filer", + "desktop.server.local": "Lokal server", + "desktop.wsl.error.windowsOnly": "WSL är endast tillgängligt i Windows", + "desktop.wsl.error.unavailable": "WSL är inte tillgängligt", + "desktop.wsl.error.listInstalled": "Det gick inte att lista installerade WSL-distributioner", + "desktop.wsl.error.listOnline": "Det gick inte att lista tillgängliga WSL-distributioner", + "desktop.wsl.error.executeDistro": "Det går inte att köra kommandon i distributionen", + "desktop.wsl.error.installWsl": "Installationen av WSL misslyckades", + "desktop.wsl.error.installDistro": "Det gick inte att installera distributionen: {{distro}}", + "desktop.wsl.error.installOpencode": "Installationen av OpenCode misslyckades", + "desktop.wsl.error.alreadyAdded": "{{distro}} har redan lagts till", + "desktop.wsl.error.opencodeMissing": "opencode är inte installerat i den här distributionen", + "desktop.wsl.error.opencodeCannotRun": "opencode är installerat men kunde inte köras", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode är inte installerat i {{distro}}", + "desktop.wsl.error.updateVersion": + "OpenCode-uppdateringen slutfördes, men {{distro}} rapporterar fortfarande {{installed}}. {{expected}} förväntades", + "desktop.wsl.error.noVersion": "ingen version", + "desktop.wsl.error.serverExited": "WSL-servern avslutades efter start (kod={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL-servern avslutades innan den blev felfri (kod={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Tidsgränsen för hälsokontrollen av sidoprocessen för {{distro}} överskreds efter {{timeout}} ms", + "desktop.wsl.error.commandTimeout": "Tidsgränsen för {{command}} {{args}} överskreds efter {{timeout}} ms", + "desktop.wsl.error.failedPort": "Det gick inte att hämta porten", + "desktop.picker.error.notSelected": "Ingen fil valdes i filväljaren", + "desktop.picker.error.sizeLimit": "De valda bilagorna överskrider gränsen på {{limit}} MB", "command.category.suggested": "Föreslagna", "command.category.view": "Visa", "command.category.project": "Projekt", @@ -16,6 +109,7 @@ export const dict = { "command.category.permissions": "Behörigheter", "command.category.workspace": "Arbetsyta", "command.category.settings": "Inställningar", + "command.logs.export": "Exportera loggar", "theme.scheme.system": "System", "theme.scheme.light": "Ljus", "theme.scheme.dark": "Mörk", @@ -119,6 +213,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Logga in med Claude Pro/Max", "provider.connect.selectMethod": "Välj inloggningsmetod för {{provider}}.", "provider.connect.method.apiKey": "API-nyckel", + "provider.connect.method.browser": "Webbläsare", + "provider.connect.method.headless": "Huvudlös", "provider.connect.status.inProgress": "Auktorisering pågår...", "provider.connect.status.waiting": "Väntar på auktorisering...", "provider.connect.status.failed": "Auktorisering misslyckades: {{error}}", @@ -150,6 +246,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} ansluten", "provider.connect.toast.connected.description": "{{provider}}-modeller är nu tillgängliga.", "provider.custom.title": "Anpassad leverantör", + "provider.custom.unavailable": "Anpassade leverantörer är inte tillgängliga på den här servern", "provider.custom.description.prefix": "Konfigurera en OpenAI-kompatibel leverantör. Se ", "provider.custom.description.link": "dokumentationen om leverantörskonfiguration", "provider.custom.description.suffix": ".", @@ -410,6 +507,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "desktop {{version}}", "wsl.onboarding.versionMismatch": "Den installerade versionen matchar inte versionen av skrivbordsappen.", "wsl.onboarding.adding": "Lägger till...", + "help.tabs.toast.ariaLabel": "Vi presenterar flikar. Ordna ditt arbete och dina aktiva sessioner med flikar", + "help.tabs.toast.dismiss": "Stäng informationen om flikar", + "help.tabs.title": "Vi presenterar flikar", + "help.tabs.description": "Ordna ditt arbete och dina aktiva sessioner med flikar", + "help.tabs.date": "14 juli", + "help.tabs.introduction": "OpenCode Desktop är nu uppbyggt kring flikar.", + "help.tabs.sessions": + "Starta en ny session på en flik eller öppna en befintlig session från något av dina projekt. Öppna en ny flik när du påbörjar något nytt och stäng den när du är klar.", + "help.tabs.organize": + "Genom att hålla några flikar öppna blir det enklare att ordna dina aktiva sessioner. Byt namn på flikarna till något du kommer ihåg om du tänker behålla dem.", + "help.tabs.home": + "Du hittar alla dina sessioner och projekt på den nya hemskärmen. När du väljer en session öppnas den på en flik.", + "help.tabs.persistence": "När du öppnar appen igen är dina flikar fortfarande öppna.", + "help.tabs.worktrees": + "Den nya designen stöder inte Git Worktrees ännu, men stöd kommer snart. Om du föredrar att fortsätta använda den tidigare layouten kan du därför växla mellan layouterna i Inställningar. Tänk bara på att den nya layouten blir permanent om några veckor.", "server.row.noUsername": "inget användarnamn", "dialog.project.edit.title": "Redigera projekt", "dialog.project.edit.name": "Namn", @@ -704,6 +816,11 @@ export const dict = { "terminal.connectionLost.title": "Anslutning förlorad", "terminal.connectionLost.abnormalClose": "WebSocket stängdes onormalt: {{code}}", "terminal.connectionLost.description": "Terminalanslutningen avbröts. Detta kan hända när servern startar om.", + "terminal.connectTicket.csrfError": + "PTY-anslutningsbiljetten avvisades vid ursprungs- eller CSRF-kontrollen. Kontrollera serverns CORS-konfiguration.", + "terminal.connectTicket.statusError": "PTY-anslutningsbiljetten misslyckades med status {{status}}", + "titlebar.update": "Uppdatera", + "titlebar.updateVersion": "Uppdatera {{version}}", "common.closeTab": "Stäng fliken", "common.dismiss": "Avfärda", "common.moreCountSuffix": " (+{{count}} till)", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index 0e203eb53360..c1f6e43378ed 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -1,4 +1,103 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "ไฟล์", + "desktop.menu.edit": "แก้ไข", + "desktop.menu.view": "มุมมอง", + "desktop.menu.go": "ไป", + "desktop.menu.window": "หน้าต่าง", + "desktop.menu.help": "วิธีใช้", + "desktop.menu.checkForUpdates": "ตรวจหาการอัปเดต...", + "desktop.menu.settings": "การตั้งค่า", + "desktop.menu.reloadWebview": "โหลด Webview ใหม่", + "desktop.menu.restart": "เริ่มการทำงานใหม่", + "desktop.menu.exportLogs": "ส่งออกบันทึก...", + "desktop.menu.newSession": "เซสชันใหม่", + "desktop.menu.openProject": "เปิดโปรเจกต์...", + "desktop.menu.newWindow": "หน้าต่างใหม่", + "desktop.menu.closeWindow": "ปิดหน้าต่าง", + "desktop.menu.undo": "เลิกทำ", + "desktop.menu.redo": "ทำซ้ำ", + "desktop.menu.cut": "ตัด", + "desktop.menu.copy": "คัดลอก", + "desktop.menu.paste": "วาง", + "desktop.menu.delete": "ลบ", + "desktop.menu.selectAll": "เลือกทั้งหมด", + "desktop.menu.toggleSidebar": "สลับแถบด้านข้าง", + "desktop.menu.toggleTerminal": "สลับเทอร์มินัล", + "desktop.menu.toggleFileTree": "สลับแผนผังไฟล์", + "desktop.menu.reload": "โหลดใหม่", + "desktop.menu.toggleDeveloperTools": "สลับเครื่องมือสำหรับนักพัฒนา", + "desktop.menu.actualSize": "ขนาดจริง", + "desktop.menu.zoomIn": "ขยาย", + "desktop.menu.zoomOut": "ย่อ", + "desktop.menu.toggleFullScreen": "สลับโหมดเต็มหน้าจอ", + "desktop.menu.back": "ย้อนกลับ", + "desktop.menu.forward": "ไปข้างหน้า", + "desktop.menu.previousSession": "เซสชันก่อนหน้า", + "desktop.menu.nextSession": "เซสชันถัดไป", + "desktop.menu.previousProject": "โปรเจกต์ก่อนหน้า", + "desktop.menu.nextProject": "โปรเจกต์ถัดไป", + "desktop.menu.minimize": "ย่อหน้าต่าง", + "desktop.menu.maximize": "ขยายหน้าต่าง", + "desktop.menu.documentation": "เอกสาร OpenCode", + "desktop.menu.supportForum": "ฟอรัมสนับสนุน", + "desktop.menu.shareFeedback": "ส่งข้อคิดเห็น", + "desktop.menu.reportBug": "รายงานข้อบกพร่อง", + "desktop.menu.ariaLabel": "เมนู OpenCode", + + "desktop.updater.dialog.checkFailed.message": "การตรวจหาการอัปเดตล้มเหลว", + "desktop.updater.dialog.checkFailed.title": "ข้อผิดพลาดในการอัปเดต", + "desktop.updater.dialog.upToDate.message": "คุณกำลังใช้เวอร์ชันล่าสุด", + "desktop.updater.dialog.upToDate.title": "ไม่มีการอัปเดต", + "desktop.updater.dialog.ready.message": "ดาวน์โหลดการอัปเดต {{version}} แล้ว เริ่มการทำงานใหม่ตอนนี้หรือไม่", + "desktop.updater.dialog.ready.title": "พร้อมอัปเดต", + "desktop.updater.dialog.restart": "เริ่มการทำงานใหม่", + "desktop.updater.dialog.later": "ภายหลัง", + + "desktop.recovery.action.relaunch": "เปิดอีกครั้ง", + "desktop.recovery.action.exportLogs": "ส่งออกบันทึก", + "desktop.recovery.action.keepWaiting": "รอต่อไป", + "desktop.recovery.action.quit": "ออก", + "desktop.recovery.loadFailed": "โหลด OpenCode ไม่สำเร็จ", + "desktop.recovery.terminated": "หน้าต่าง OpenCode หยุดทำงานโดยไม่คาดคิด", + "desktop.recovery.unresponsive": "OpenCode ไม่ตอบสนอง", + "desktop.recovery.unresponsive.detail": "คุณสามารถเปิดแอปอีกครั้ง เปิดบันทึก หรือรอต่อไป", + "desktop.recovery.loadFailed.detail": "หน้าต่าง: {{window}}\nURL: {{url}}\nข้อผิดพลาด: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "หน้าต่าง: {{window}}\nสาเหตุ: {{reason}}\nรหัส: {{code}}", + "desktop.recovery.unknown": "<ไม่ทราบ>", + + "desktop.dialog.chooseFolder": "เลือกโฟลเดอร์", + "desktop.dialog.chooseFile": "เลือกไฟล์", + "desktop.dialog.saveFile": "บันทึกไฟล์", + "desktop.dialog.files": "ไฟล์", + "desktop.server.local": "เซิร์ฟเวอร์ภายในเครื่อง", + + "desktop.wsl.error.windowsOnly": "WSL ใช้งานได้เฉพาะบน Windows เท่านั้น", + "desktop.wsl.error.unavailable": "WSL ไม่พร้อมใช้งาน", + "desktop.wsl.error.listInstalled": "ไม่สามารถแสดงรายการดิสโทร WSL ที่ติดตั้งแล้วได้", + "desktop.wsl.error.listOnline": "ไม่สามารถแสดงรายการดิสโทร WSL แบบออนไลน์ได้", + "desktop.wsl.error.executeDistro": "ไม่สามารถเรียกใช้คำสั่งในดิสโทรได้", + "desktop.wsl.error.installWsl": "การติดตั้ง WSL ล้มเหลว", + "desktop.wsl.error.installDistro": "ไม่สามารถติดตั้งดิสโทรได้: {{distro}}", + "desktop.wsl.error.installOpencode": "การติดตั้ง OpenCode ล้มเหลว", + "desktop.wsl.error.alreadyAdded": "เพิ่ม {{distro}} แล้ว", + "desktop.wsl.error.opencodeMissing": "ไม่ได้ติดตั้ง opencode ในดิสโทรนี้", + "desktop.wsl.error.opencodeCannotRun": "ติดตั้ง opencode แล้ว แต่ไม่สามารถเรียกใช้ได้", + "desktop.wsl.error.opencodeNotInstalled": "ไม่ได้ติดตั้ง OpenCode ใน {{distro}}", + "desktop.wsl.error.updateVersion": + "อัปเดต OpenCode เสร็จแล้ว แต่ {{distro}} ยังคงรายงานเวอร์ชัน {{installed}} ทั้งที่ควรเป็น {{expected}}", + "desktop.wsl.error.noVersion": "ไม่มีเวอร์ชัน", + "desktop.wsl.error.serverExited": "เซิร์ฟเวอร์ WSL หยุดทำงานหลังเริ่มต้น (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "เซิร์ฟเวอร์ WSL หยุดทำงานก่อนพร้อมใช้งาน (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "การตรวจสอบสถานะ Sidecar สำหรับ {{distro}} หมดเวลาหลังจาก {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} หมดเวลาหลังจาก {{timeout}}ms", + "desktop.wsl.error.failedPort": "ไม่สามารถรับพอร์ตได้", + + "desktop.picker.error.notSelected": "ไม่ได้เลือกไฟล์จากตัวเลือกไฟล์", + "desktop.picker.error.sizeLimit": "ไฟล์แนบที่เลือกมีขนาดเกินขีดจำกัด {{limit}} MB", + "command.category.suggested": "แนะนำ", "command.category.view": "มุมมอง", "command.category.project": "โปรเจกต์", @@ -16,6 +115,7 @@ export const dict = { "command.category.permissions": "สิทธิ์", "command.category.workspace": "พื้นที่ทำงาน", "command.category.settings": "การตั้งค่า", + "command.logs.export": "ส่งออกบันทึก", "theme.scheme.system": "ระบบ", "theme.scheme.light": "สว่าง", @@ -131,6 +231,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "เข้าสู่ระบบด้วย Claude Pro/Max", "provider.connect.selectMethod": "เลือกวิธีการเข้าสู่ระบบสำหรับ {{provider}}", "provider.connect.method.apiKey": "คีย์ API", + "provider.connect.method.browser": "เบราว์เซอร์", + "provider.connect.method.headless": "ไม่ใช้เบราว์เซอร์", "provider.connect.status.inProgress": "กำลังอนุญาต...", "provider.connect.status.waiting": "รอการอนุญาต...", "provider.connect.status.failed": "การอนุญาตล้มเหลว: {{error}}", @@ -163,6 +265,7 @@ export const dict = { "provider.connect.toast.connected.description": "โมเดล {{provider}} พร้อมใช้งานแล้ว", "provider.custom.title": "ผู้ให้บริการที่กำหนดเอง", + "provider.custom.unavailable": "ผู้ให้บริการที่กำหนดเองไม่พร้อมใช้งานบนเซิร์ฟเวอร์นี้", "provider.custom.description.prefix": "กำหนดค่าผู้ให้บริการที่เข้ากันได้กับ OpenAI ดู ", "provider.custom.description.link": "เอกสารการกำหนดค่าผู้ให้บริการ", "provider.custom.description.suffix": "", @@ -432,6 +535,22 @@ export const dict = { "wsl.onboarding.versionMismatch": "เวอร์ชันที่ติดตั้งไม่ตรงกับเวอร์ชันของแอปเดสก์ท็อป", "wsl.onboarding.adding": "กำลังเพิ่ม...", + "help.tabs.toast.ariaLabel": "ขอแนะนำแท็บ จัดระเบียบงานและเซสชันที่ใช้งานอยู่ด้วยแท็บ", + "help.tabs.toast.dismiss": "ปิดข้อมูลเกี่ยวกับแท็บ", + "help.tabs.title": "ขอแนะนำแท็บ", + "help.tabs.description": "จัดระเบียบงานและเซสชันที่ใช้งานอยู่ด้วยแท็บ", + "help.tabs.date": "14 กรกฎาคม", + "help.tabs.introduction": "ตอนนี้ OpenCode Desktop สร้างขึ้นโดยใช้แท็บเป็นหลัก", + "help.tabs.sessions": + "เริ่มเซสชันใหม่ในแท็บ หรือเปิดเซสชันที่มีอยู่จากโปรเจกต์ใดก็ได้ เปิดแท็บใหม่เมื่อเริ่มงานใหม่ และปิดเมื่อทำงานเสร็จ", + "help.tabs.organize": + "การเปิดแท็บไว้สองสามแท็บช่วยให้จัดระเบียบเซสชันที่ใช้งานอยู่ได้ง่ายขึ้น เปลี่ยนชื่อแท็บให้จดจำได้ง่ายหากต้องการเก็บแท็บไว้", + "help.tabs.home": + "คุณจะพบเซสชันและโปรเจกต์ทั้งหมดในหน้าจอหน้าหลักแบบใหม่ เมื่อเลือกเซสชัน เซสชันนั้นจะเปิดในแท็บ", + "help.tabs.persistence": "เมื่อเปิดแอปอีกครั้ง แท็บของคุณจะยังคงเปิดอยู่", + "help.tabs.worktrees": + "การออกแบบใหม่ยังไม่รองรับ Git Worktrees แต่จะรองรับเร็ว ๆ นี้ ดังนั้น หากต้องการใช้เค้าโครงก่อนหน้าต่อ คุณสามารถสลับเค้าโครงได้ในการตั้งค่า โปรดทราบว่าเค้าโครงใหม่จะกลายเป็นเค้าโครงถาวรภายในอีกไม่กี่สัปดาห์", + "dialog.project.edit.title": "แก้ไขโปรเจกต์", "dialog.project.edit.name": "ชื่อ", "dialog.project.edit.icon": "ไอคอน", @@ -713,6 +832,12 @@ export const dict = { "terminal.close": "ปิดเทอร์มินัล", "terminal.connectionLost.title": "การเชื่อมต่อขาดหาย", "terminal.connectionLost.description": "การเชื่อมต่อเทอร์มินัลถูกขัดจังหวะ อาจเกิดขึ้นเมื่อเซิร์ฟเวอร์รีสตาร์ท", + "terminal.connectTicket.csrfError": + "ตั๋วเชื่อมต่อ PTY ถูกปฏิเสธโดยการตรวจสอบต้นทางหรือ CSRF โปรดตรวจสอบการกำหนดค่า CORS ของเซิร์ฟเวอร์", + "terminal.connectTicket.statusError": "ตั๋วเชื่อมต่อ PTY ล้มเหลวโดยมีสถานะ {{status}}", + + "titlebar.update": "อัปเดต", + "titlebar.updateVersion": "อัปเดต {{version}}", "common.closeTab": "ปิดแท็บ", "common.dismiss": "ปิด", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index 045f3690d10d..a60f72507744 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -3,6 +3,106 @@ import { dict as en } from "./en" type Keys = keyof typeof en export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Dosya", + "desktop.menu.edit": "Düzen", + "desktop.menu.view": "Görünüm", + "desktop.menu.go": "Git", + "desktop.menu.window": "Pencere", + "desktop.menu.help": "Yardım", + "desktop.menu.checkForUpdates": "Güncellemeleri kontrol et...", + "desktop.menu.settings": "Ayarlar", + "desktop.menu.reloadWebview": "Web görünümünü yeniden yükle", + "desktop.menu.restart": "Yeniden başlat", + "desktop.menu.exportLogs": "Günlükleri dışa aktar...", + "desktop.menu.newSession": "Yeni oturum", + "desktop.menu.openProject": "Proje aç...", + "desktop.menu.newWindow": "Yeni pencere", + "desktop.menu.closeWindow": "Pencereyi kapat", + "desktop.menu.undo": "Geri al", + "desktop.menu.redo": "Yinele", + "desktop.menu.cut": "Kes", + "desktop.menu.copy": "Kopyala", + "desktop.menu.paste": "Yapıştır", + "desktop.menu.delete": "Sil", + "desktop.menu.selectAll": "Tümünü seç", + "desktop.menu.toggleSidebar": "Kenar çubuğunu aç/kapat", + "desktop.menu.toggleTerminal": "Terminali aç/kapat", + "desktop.menu.toggleFileTree": "Dosya ağacını aç/kapat", + "desktop.menu.reload": "Yeniden yükle", + "desktop.menu.toggleDeveloperTools": "Geliştirici araçlarını aç/kapat", + "desktop.menu.actualSize": "Gerçek boyut", + "desktop.menu.zoomIn": "Yakınlaştır", + "desktop.menu.zoomOut": "Uzaklaştır", + "desktop.menu.toggleFullScreen": "Tam ekranı aç/kapat", + "desktop.menu.back": "Geri", + "desktop.menu.forward": "İleri", + "desktop.menu.previousSession": "Önceki oturum", + "desktop.menu.nextSession": "Sonraki oturum", + "desktop.menu.previousProject": "Önceki proje", + "desktop.menu.nextProject": "Sonraki proje", + "desktop.menu.minimize": "Simge durumuna küçült", + "desktop.menu.maximize": "Ekranı kapla", + "desktop.menu.documentation": "OpenCode belgeleri", + "desktop.menu.supportForum": "Destek forumu", + "desktop.menu.shareFeedback": "Geri bildirim paylaş", + "desktop.menu.reportBug": "Hata bildir", + "desktop.menu.ariaLabel": "OpenCode menüsü", + + "desktop.updater.dialog.checkFailed.message": "Güncellemeler kontrol edilemedi.", + "desktop.updater.dialog.checkFailed.title": "Güncelleme hatası", + "desktop.updater.dialog.upToDate.message": "En son sürümü kullanıyorsunuz.", + "desktop.updater.dialog.upToDate.title": "Güncelleme yok", + "desktop.updater.dialog.ready.message": "{{version}} güncellemesi indirildi. Şimdi yeniden başlatılsın mı?", + "desktop.updater.dialog.ready.title": "Güncelleme hazır", + "desktop.updater.dialog.restart": "Yeniden başlat", + "desktop.updater.dialog.later": "Daha sonra", + + "desktop.recovery.action.relaunch": "Uygulamayı yeniden başlat", + "desktop.recovery.action.exportLogs": "Günlükleri dışa aktar", + "desktop.recovery.action.keepWaiting": "Beklemeye devam et", + "desktop.recovery.action.quit": "Çık", + "desktop.recovery.loadFailed": "OpenCode yüklenemedi", + "desktop.recovery.terminated": "OpenCode penceresi beklenmedik şekilde sonlandırıldı", + "desktop.recovery.unresponsive": "OpenCode yanıt vermiyor", + "desktop.recovery.unresponsive.detail": + "Uygulamayı yeniden başlatabilir, günlükleri açabilir veya beklemeye devam edebilirsiniz.", + "desktop.recovery.loadFailed.detail": "Pencere: {{window}}\nURL: {{url}}\nHata: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Pencere: {{window}}\nNeden: {{reason}}\nKod: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Bir klasör seçin", + "desktop.dialog.chooseFile": "Bir dosya seçin", + "desktop.dialog.saveFile": "Dosyayı kaydedin", + "desktop.dialog.files": "Dosyalar", + "desktop.server.local": "Yerel sunucu", + + "desktop.wsl.error.windowsOnly": "WSL yalnızca Windows'ta kullanılabilir", + "desktop.wsl.error.unavailable": "WSL kullanılamıyor", + "desktop.wsl.error.listInstalled": "Kurulu WSL dağıtımları listelenemedi", + "desktop.wsl.error.listOnline": "Çevrimiçi WSL dağıtımları listelenemedi", + "desktop.wsl.error.executeDistro": "Dağıtımda komutlar çalıştırılamıyor", + "desktop.wsl.error.installWsl": "WSL kurulamadı", + "desktop.wsl.error.installDistro": "Dağıtım kurulamadı: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode kurulamadı", + "desktop.wsl.error.alreadyAdded": "{{distro}} zaten eklendi", + "desktop.wsl.error.opencodeMissing": "opencode bu dağıtımda kurulu değil", + "desktop.wsl.error.opencodeCannotRun": "opencode kurulu ancak çalıştırılamadı", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode, {{distro}} içinde kurulu değil", + "desktop.wsl.error.updateVersion": + "OpenCode güncellemesi tamamlandı ancak {{distro}} hâlâ {{installed}} sürümünü bildiriyor; beklenen sürüm: {{expected}}", + "desktop.wsl.error.noVersion": "sürüm yok", + "desktop.wsl.error.serverExited": "WSL sunucusu başlatıldıktan sonra kapandı (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL sunucusu kullanıma hazır duruma gelmeden kapandı (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "{{distro}} Sidecar sistem durumu denetimi {{timeout}} ms sonra zaman aşımına uğradı", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} komutu {{timeout}} ms sonra zaman aşımına uğradı", + "desktop.wsl.error.failedPort": "Bağlantı noktası alınamadı", + + "desktop.picker.error.notSelected": "Dosya seçici tarafından dosya seçilmedi", + "desktop.picker.error.sizeLimit": "Seçilen ekler {{limit}} MB sınırını aşıyor", + "command.category.suggested": "Önerilen", "command.category.view": "Görünüm", "command.category.project": "Proje", @@ -20,6 +120,7 @@ export const dict = { "command.category.permissions": "İzinler", "command.category.workspace": "Çalışma Alanı", "command.category.settings": "Ayarlar", + "command.logs.export": "Günlükleri dışa aktar", "theme.scheme.system": "Sistem", "theme.scheme.light": "Açık", @@ -135,6 +236,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude Pro/Max ile giriş yap", "provider.connect.selectMethod": "{{provider}} için giriş yöntemini seçin.", "provider.connect.method.apiKey": "API anahtarı", + "provider.connect.method.browser": "Tarayıcı", + "provider.connect.method.headless": "Tarayıcısız", "provider.connect.status.inProgress": "Yetkilendirme devam ediyor...", "provider.connect.status.waiting": "Yetkilendirme bekleniyor...", "provider.connect.status.failed": "Yetkilendirme başarısız: {{error}}", @@ -167,6 +270,7 @@ export const dict = { "provider.connect.toast.connected.description": "{{provider}} modelleri artık kullanımda.", "provider.custom.title": "Özel sağlayıcı", + "provider.custom.unavailable": "Özel sağlayıcılar bu sunucuda kullanılamıyor", "provider.custom.description.prefix": "OpenAI uyumlu bir sağlayıcı yapılandırın. ", "provider.custom.description.link": "sağlayıcı yapılandırma dokümanları", "provider.custom.description.suffix": " sayfasına bakın.", @@ -437,6 +541,22 @@ export const dict = { "wsl.onboarding.versionMismatch": "Kurulu sürüm masaüstü uygulamasının sürümüyle eşleşmiyor.", "wsl.onboarding.adding": "Ekleniyor...", + "help.tabs.toast.ariaLabel": "Sekmelerle tanışın. Çalışmalarınızı ve etkin oturumlarınızı sekmelerle düzenleyin", + "help.tabs.toast.dismiss": "Sekmeler bilgilerini kapat", + "help.tabs.title": "Sekmelerle tanışın", + "help.tabs.description": "Çalışmalarınızı ve etkin oturumlarınızı sekmelerle düzenleyin", + "help.tabs.date": "14 Temmuz", + "help.tabs.introduction": "OpenCode Desktop artık sekmeler üzerine kurulu.", + "help.tabs.sessions": + "Bir sekmede yeni oturum başlatın veya projelerinizden mevcut bir oturumu açın. Yeni bir işe başlarken yeni sekme açın, işiniz bittiğinde sekmeyi kapatın.", + "help.tabs.organize": + "Birkaç sekmeyi açık tutmak, etkin oturumlarınızı düzenlemenizi kolaylaştırır. Sekmeleri açık tutmayı planlıyorsanız onlara akılda kalıcı adlar verin.", + "help.tabs.home": + "Tüm oturumlarınızı ve projelerinizi yeni Ana Sayfa ekranında bulabilirsiniz. Bir oturum seçtiğinizde oturum bir sekmede açılır.", + "help.tabs.persistence": "Uygulamayı yeniden açtığınızda sekmeleriniz açık kalır.", + "help.tabs.worktrees": + "Yeni tasarım henüz Git Worktrees'i desteklemiyor; bu özellik yakında kullanıma sunulacak. Önceki düzeni kullanmaya devam etmek isterseniz Ayarlar'da düzenler arasında geçiş yapabilirsiniz. Yeni düzenin birkaç hafta içinde kalıcı olacağını unutmayın.", + "dialog.project.edit.title": "Projeyi düzenle", "dialog.project.edit.name": "Ad", "dialog.project.edit.icon": "Simge", @@ -728,6 +848,12 @@ export const dict = { "terminal.connectionLost.title": "Bağlantı kesildi", "terminal.connectionLost.description": "Terminal bağlantısı kesildi. Bu durum sunucu yeniden başladığında oluşabilir.", + "terminal.connectTicket.csrfError": + "PTY bağlantı bileti, kaynak veya CSRF denetimleri tarafından reddedildi. Sunucunun CORS yapılandırmasını kontrol edin.", + "terminal.connectTicket.statusError": "PTY bağlantı bileti {{status}} durumuyla başarısız oldu", + + "titlebar.update": "Güncelle", + "titlebar.updateVersion": "Güncelle {{version}}", "common.closeTab": "Sekmeyi kapat", "common.dismiss": "Kapat", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index db176eec2cc3..4a439e920e1d 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -1,4 +1,104 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Файл", + "desktop.menu.edit": "Редагування", + "desktop.menu.view": "Вигляд", + "desktop.menu.go": "Перехід", + "desktop.menu.window": "Вікно", + "desktop.menu.help": "Довідка", + "desktop.menu.checkForUpdates": "Перевірити наявність оновлень...", + "desktop.menu.settings": "Налаштування", + "desktop.menu.reloadWebview": "Перезавантажити вебподання", + "desktop.menu.restart": "Перезапустити", + "desktop.menu.exportLogs": "Експортувати журнали...", + "desktop.menu.newSession": "Нова сесія", + "desktop.menu.openProject": "Відкрити проєкт...", + "desktop.menu.newWindow": "Нове вікно", + "desktop.menu.closeWindow": "Закрити вікно", + "desktop.menu.undo": "Скасувати", + "desktop.menu.redo": "Повторити", + "desktop.menu.cut": "Вирізати", + "desktop.menu.copy": "Копіювати", + "desktop.menu.paste": "Вставити", + "desktop.menu.delete": "Видалити", + "desktop.menu.selectAll": "Вибрати все", + "desktop.menu.toggleSidebar": "Перемкнути бічну панель", + "desktop.menu.toggleTerminal": "Перемкнути термінал", + "desktop.menu.toggleFileTree": "Перемкнути дерево файлів", + "desktop.menu.reload": "Перезавантажити", + "desktop.menu.toggleDeveloperTools": "Перемкнути інструменти розробника", + "desktop.menu.actualSize": "Фактичний розмір", + "desktop.menu.zoomIn": "Збільшити", + "desktop.menu.zoomOut": "Зменшити", + "desktop.menu.toggleFullScreen": "Перемкнути повноекранний режим", + "desktop.menu.back": "Назад", + "desktop.menu.forward": "Вперед", + "desktop.menu.previousSession": "Попередня сесія", + "desktop.menu.nextSession": "Наступна сесія", + "desktop.menu.previousProject": "Попередній проєкт", + "desktop.menu.nextProject": "Наступний проєкт", + "desktop.menu.minimize": "Згорнути", + "desktop.menu.maximize": "Розгорнути", + "desktop.menu.documentation": "Документація OpenCode", + "desktop.menu.supportForum": "Форум підтримки", + "desktop.menu.shareFeedback": "Надіслати відгук", + "desktop.menu.reportBug": "Повідомити про помилку", + "desktop.menu.ariaLabel": "Меню OpenCode", + + "desktop.updater.dialog.checkFailed.message": "Не вдалося перевірити наявність оновлень.", + "desktop.updater.dialog.checkFailed.title": "Помилка оновлення", + "desktop.updater.dialog.upToDate.message": "У вас найновіша версія.", + "desktop.updater.dialog.upToDate.title": "Оновлень немає", + "desktop.updater.dialog.ready.message": "Оновлення {{version}} завантажено. Перезапустити зараз?", + "desktop.updater.dialog.ready.title": "Оновлення готове", + "desktop.updater.dialog.restart": "Перезапустити", + "desktop.updater.dialog.later": "Пізніше", + + "desktop.recovery.action.relaunch": "Запустити повторно", + "desktop.recovery.action.exportLogs": "Експортувати журнали", + "desktop.recovery.action.keepWaiting": "Продовжити очікування", + "desktop.recovery.action.quit": "Завершити роботу", + "desktop.recovery.loadFailed": "Не вдалося завантажити OpenCode", + "desktop.recovery.terminated": "Роботу вікна OpenCode несподівано завершено", + "desktop.recovery.unresponsive": "OpenCode не відповідає", + "desktop.recovery.unresponsive.detail": + "Ви можете повторно запустити програму, відкрити журнали або продовжити очікування.", + "desktop.recovery.loadFailed.detail": "Вікно: {{window}}\nURL: {{url}}\nПомилка: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Вікно: {{window}}\nПричина: {{reason}}\nКод: {{code}}", + "desktop.recovery.unknown": "<невідомо>", + + "desktop.dialog.chooseFolder": "Виберіть папку", + "desktop.dialog.chooseFile": "Виберіть файл", + "desktop.dialog.saveFile": "Зберегти файл", + "desktop.dialog.files": "Файли", + "desktop.server.local": "Локальний сервер", + + "desktop.wsl.error.windowsOnly": "WSL доступна лише у Windows", + "desktop.wsl.error.unavailable": "WSL недоступна", + "desktop.wsl.error.listInstalled": "Не вдалося отримати список встановлених дистрибутивів WSL", + "desktop.wsl.error.listOnline": "Не вдалося отримати список доступних онлайн дистрибутивів WSL", + "desktop.wsl.error.executeDistro": "Не вдалося виконати команди в дистрибутиві", + "desktop.wsl.error.installWsl": "Не вдалося встановити WSL", + "desktop.wsl.error.installDistro": "Не вдалося встановити дистрибутив: {{distro}}", + "desktop.wsl.error.installOpencode": "Не вдалося встановити OpenCode", + "desktop.wsl.error.alreadyAdded": "{{distro}} уже додано", + "desktop.wsl.error.opencodeMissing": "opencode не встановлено в цьому дистрибутиві", + "desktop.wsl.error.opencodeCannotRun": "opencode встановлено, але його не вдалося запустити", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode не встановлено в {{distro}}", + "desktop.wsl.error.updateVersion": + "Оновлення OpenCode завершено, але {{distro}} усе ще повідомляє про версію {{installed}}; очікувалася {{expected}}", + "desktop.wsl.error.noVersion": "версію не вказано", + "desktop.wsl.error.serverExited": "Сервер WSL завершив роботу після запуску (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Сервер WSL завершив роботу, не досягнувши працездатного стану (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Час очікування перевірки працездатності sidecar для {{distro}} минув через {{timeout}}мс", + "desktop.wsl.error.commandTimeout": "Час очікування {{command}} {{args}} минув через {{timeout}}мс", + "desktop.wsl.error.failedPort": "Не вдалося отримати порт", + + "desktop.picker.error.notSelected": "Засіб вибору не повернув вибраний файл", + "desktop.picker.error.sizeLimit": "Вибрані вкладення перевищують обмеження в {{limit}} МБ", + "command.category.suggested": "Рекомендовані", "command.category.view": "Вигляд", "command.category.project": "Проєкт", @@ -16,6 +116,7 @@ export const dict = { "command.category.permissions": "Дозволи", "command.category.workspace": "Робоча область", "command.category.settings": "Налаштування", + "command.logs.export": "Експортувати журнали", "theme.scheme.system": "Системна", "theme.scheme.light": "Світла", @@ -131,6 +232,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Увійти з Claude Pro/Max", "provider.connect.selectMethod": "Виберіть спосіб входу для {{provider}}.", "provider.connect.method.apiKey": "Ключ API", + "provider.connect.method.browser": "Браузер", + "provider.connect.method.headless": "Без графічного інтерфейсу", "provider.connect.status.inProgress": "Авторизація виконується...", "provider.connect.status.waiting": "Очікування авторизації...", "provider.connect.status.failed": "Авторизація не вдалася: {{error}}", @@ -163,6 +266,7 @@ export const dict = { "provider.connect.toast.connected.description": "Моделі {{provider}} тепер доступні для використання.", "provider.custom.title": "Користувацький провайдер", + "provider.custom.unavailable": "Користувацькі провайдери недоступні на цьому сервері", "provider.custom.description.prefix": "Налаштуйте провайдера, сумісного з OpenAI. Перегляньте ", "provider.custom.description.link": "документацію з налаштування провайдера", "provider.custom.description.suffix": ".", @@ -439,6 +543,22 @@ export const dict = { "wsl.onboarding.desktopVersion": "десктопний застосунок {{version}}", "wsl.onboarding.versionMismatch": "Встановлена версія не відповідає версії десктопного застосунку.", "wsl.onboarding.adding": "Додавання...", + + "help.tabs.toast.ariaLabel": "Представляємо вкладки. Упорядковуйте роботу й активні сесії за допомогою вкладок", + "help.tabs.toast.dismiss": "Закрити інформацію про вкладки", + "help.tabs.title": "Представляємо вкладки", + "help.tabs.description": "Упорядковуйте роботу й активні сесії за допомогою вкладок", + "help.tabs.date": "14 липня", + "help.tabs.introduction": "OpenCode Desktop тепер побудовано навколо вкладок.", + "help.tabs.sessions": + "Почніть нову сесію у вкладці або відкрийте наявну сесію з будь-якого свого проєкту. Відкривайте нову вкладку, коли починаєте щось нове, і закривайте її після завершення.", + "help.tabs.organize": + "Кілька відкритих вкладок полегшують упорядкування активних сесій. Якщо плануєте залишити вкладки відкритими, перейменуйте їх на щось пам'ятне.", + "help.tabs.home": + "Усі свої сесії та проєкти ви знайдете на новому екрані «Головна». Вибрана сесія відкриється у вкладці.", + "help.tabs.persistence": "Після повторного відкриття програми ваші вкладки залишаться відкритими.", + "help.tabs.worktrees": + "Новий дизайн поки не підтримує робочі дерева Git, але ця можливість незабаром з'явиться. Тож якщо ви бажаєте й надалі користуватися попереднім макетом, перемикатися між макетами можна в налаштуваннях. Зауважте, що за кілька тижнів новий макет стане постійним.", "server.row.noUsername": "без імені користувача", "dialog.project.edit.title": "Редагувати проєкт", @@ -776,6 +896,12 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket закрито аномально: {{code}}", "terminal.connectionLost.description": "З'єднання з терміналом було перервано. Це може статися під час перезапуску сервера.", + "terminal.connectTicket.csrfError": + "Квиток підключення PTY відхилено через перевірку джерела або CSRF. Перевірте конфігурацію CORS сервера.", + "terminal.connectTicket.statusError": "Помилка квитка підключення PTY зі статусом {{status}}", + + "titlebar.update": "Оновити", + "titlebar.updateVersion": "Оновити {{version}}", "common.closeTab": "Закрити вкладку", "common.dismiss": "Відхилити", diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts index 883553791c98..67f13676d36e 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -1,4 +1,104 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "فائل", + "desktop.menu.edit": "ترمیم", + "desktop.menu.view": "دیکھیں", + "desktop.menu.go": "جائیں", + "desktop.menu.window": "ونڈو", + "desktop.menu.help": "مدد", + "desktop.menu.checkForUpdates": "اپ ڈیٹس کی جانچ کریں...", + "desktop.menu.settings": "ترتیبات", + "desktop.menu.reloadWebview": "ویب ویو دوبارہ لوڈ کریں", + "desktop.menu.restart": "دوبارہ شروع کریں", + "desktop.menu.exportLogs": "لاگز برآمد کریں...", + "desktop.menu.newSession": "نیا سیشن", + "desktop.menu.openProject": "پروجیکٹ کھولیں...", + "desktop.menu.newWindow": "نئی ونڈو", + "desktop.menu.closeWindow": "ونڈو بند کریں", + "desktop.menu.undo": "کالعدم کریں", + "desktop.menu.redo": "دوبارہ کریں", + "desktop.menu.cut": "کاٹیں", + "desktop.menu.copy": "کاپی کریں", + "desktop.menu.paste": "چسپاں کریں", + "desktop.menu.delete": "حذف کریں", + "desktop.menu.selectAll": "سبھی منتخب کریں", + "desktop.menu.toggleSidebar": "سائیڈ بار دکھائیں یا چھپائیں", + "desktop.menu.toggleTerminal": "ٹرمینل دکھائیں یا چھپائیں", + "desktop.menu.toggleFileTree": "فائل ٹری دکھائیں یا چھپائیں", + "desktop.menu.reload": "دوبارہ لوڈ کریں", + "desktop.menu.toggleDeveloperTools": "ڈویلپر ٹولز دکھائیں یا چھپائیں", + "desktop.menu.actualSize": "اصل سائز", + "desktop.menu.zoomIn": "بڑا کریں", + "desktop.menu.zoomOut": "چھوٹا کریں", + "desktop.menu.toggleFullScreen": "پوری اسکرین دکھائیں یا چھپائیں", + "desktop.menu.back": "پیچھے", + "desktop.menu.forward": "آگے", + "desktop.menu.previousSession": "پچھلا سیشن", + "desktop.menu.nextSession": "اگلا سیشن", + "desktop.menu.previousProject": "پچھلا پروجیکٹ", + "desktop.menu.nextProject": "اگلا پروجیکٹ", + "desktop.menu.minimize": "چھوٹا کریں", + "desktop.menu.maximize": "بڑا کریں", + "desktop.menu.documentation": "OpenCode دستاویزات", + "desktop.menu.supportForum": "معاونتی فورم", + "desktop.menu.shareFeedback": "رائے کا اشتراک کریں", + "desktop.menu.reportBug": "خرابی کی اطلاع دیں", + "desktop.menu.ariaLabel": "OpenCode مینو", + + "desktop.updater.dialog.checkFailed.message": "اپ ڈیٹ کی جانچ ناکام ہو گئی۔", + "desktop.updater.dialog.checkFailed.title": "اپ ڈیٹ کی خرابی", + "desktop.updater.dialog.upToDate.message": "آپ کا سافٹ ویئر تازہ ترین ہے۔", + "desktop.updater.dialog.upToDate.title": "کوئی اپ ڈیٹ نہیں", + "desktop.updater.dialog.ready.message": "اپ ڈیٹ {{version}} ڈاؤن لوڈ ہو گئی ہے۔ ابھی دوبارہ شروع کریں؟", + "desktop.updater.dialog.ready.title": "اپ ڈیٹ تیار ہے", + "desktop.updater.dialog.restart": "دوبارہ شروع کریں", + "desktop.updater.dialog.later": "بعد میں", + + "desktop.recovery.action.relaunch": "دوبارہ چلائیں", + "desktop.recovery.action.exportLogs": "لاگز برآمد کریں", + "desktop.recovery.action.keepWaiting": "انتظار جاری رکھیں", + "desktop.recovery.action.quit": "بند کریں", + "desktop.recovery.loadFailed": "OpenCode لوڈ نہیں ہو سکا", + "desktop.recovery.terminated": "OpenCode ونڈو غیر متوقع طور پر بند ہو گئی", + "desktop.recovery.unresponsive": "OpenCode جواب نہیں دے رہا", + "desktop.recovery.unresponsive.detail": + "آپ ایپ کو دوبارہ چلا سکتے ہیں، لاگز کھول سکتے ہیں یا انتظار جاری رکھ سکتے ہیں۔", + "desktop.recovery.loadFailed.detail": "ونڈو: {{window}}\nURL: {{url}}\nخرابی: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "ونڈو: {{window}}\nوجہ: {{reason}}\nکوڈ: {{code}}", + "desktop.recovery.unknown": "<نامعلوم>", + + "desktop.dialog.chooseFolder": "فولڈر منتخب کریں", + "desktop.dialog.chooseFile": "فائل منتخب کریں", + "desktop.dialog.saveFile": "فائل محفوظ کریں", + "desktop.dialog.files": "فائلیں", + "desktop.server.local": "مقامی سرور", + + "desktop.wsl.error.windowsOnly": "WSL صرف Windows پر دستیاب ہے", + "desktop.wsl.error.unavailable": "WSL دستیاب نہیں ہے", + "desktop.wsl.error.listInstalled": "انسٹال شدہ WSL ڈسٹری بیوشنز کی فہرست حاصل نہیں ہو سکی", + "desktop.wsl.error.listOnline": "آن لائن WSL ڈسٹری بیوشنز کی فہرست حاصل نہیں ہو سکی", + "desktop.wsl.error.executeDistro": "ڈسٹری بیوشن میں کمانڈز نہیں چلائی جا سکتیں", + "desktop.wsl.error.installWsl": "WSL کی تنصیب ناکام ہو گئی", + "desktop.wsl.error.installDistro": "ڈسٹری بیوشن انسٹال نہیں ہو سکی: {{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode کی تنصیب ناکام ہو گئی", + "desktop.wsl.error.alreadyAdded": "{{distro}} پہلے ہی شامل ہے", + "desktop.wsl.error.opencodeMissing": "اس ڈسٹری بیوشن میں opencode انسٹال نہیں ہے", + "desktop.wsl.error.opencodeCannotRun": "opencode انسٹال ہے لیکن چل نہیں سکا", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}} میں OpenCode انسٹال نہیں ہے", + "desktop.wsl.error.updateVersion": + "OpenCode کی اپ ڈیٹ مکمل ہو گئی لیکن {{distro}} اب بھی {{installed}} کی اطلاع دے رہا ہے؛ متوقع ورژن {{expected}} ہے", + "desktop.wsl.error.noVersion": "کوئی ورژن نہیں", + "desktop.wsl.error.serverExited": "WSL سرور شروع ہونے کے بعد بند ہو گیا (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL سرور فعال ہونے سے پہلے بند ہو گیا (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "{{distro}} کے سائیڈ کار کی صحت کی جانچ {{timeout}}ms کے بعد مقررہ وقت سے تجاوز کر گئی", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} نے {{timeout}}ms کے بعد مقررہ وقت سے تجاوز کر دیا", + "desktop.wsl.error.failedPort": "پورٹ حاصل نہیں ہو سکی", + + "desktop.picker.error.notSelected": "فائل چنندہ میں کوئی فائل منتخب نہیں کی گئی", + "desktop.picker.error.sizeLimit": "منتخب منسلکات {{limit}} MB کی حد سے زیادہ ہیں", + "command.category.suggested": "تجویز کردہ", "command.category.view": "دیکھیں", "command.category.project": "پروجیکٹ", @@ -16,6 +116,7 @@ export const dict = { "command.category.permissions": "اجازتیں", "command.category.workspace": "کام کی جگہ", "command.category.settings": "ترتیبات", + "command.logs.export": "لاگز برآمد کریں", "theme.scheme.system": "سسٹم", "theme.scheme.light": "روشن", "theme.scheme.dark": "تاریک", @@ -119,6 +220,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Claude Pro/Max کے ساتھ لاگ ان کریں۔", "provider.connect.selectMethod": "{{provider}} کے لیے لاگ ان کا طریقہ منتخب کریں۔", "provider.connect.method.apiKey": "API کلید", + "provider.connect.method.browser": "براؤزر", + "provider.connect.method.headless": "ہیڈ لیس", "provider.connect.status.inProgress": "اجازت حاصل کی جا رہی ہے...", "provider.connect.status.waiting": "اجازت کا انتظار ہے...", "provider.connect.status.failed": "اجازت حاصل نہیں ہو سکی: {{error}}", @@ -186,6 +289,7 @@ export const dict = { "provider.custom.error.baseURL.format": "http:// یا https:// سے شروع ہونا چاہیے", "provider.custom.error.required": "درکار ہے۔", "provider.custom.error.duplicate": "مکرر", + "provider.custom.unavailable": "اس سرور پر اپنی مرضی کے مطابق فراہم کنندگان دستیاب نہیں ہیں۔", "provider.disconnect.toast.disconnected.title": "{{provider}} منقطع ہو گیا۔", "provider.disconnect.toast.disconnected.description": "{{provider}} ماڈلز اب دستیاب نہیں ہیں۔", "model.tag.free": "مفت", @@ -411,6 +515,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "ڈیسک ٹاپ {{version}}", "wsl.onboarding.versionMismatch": "انسٹال کردہ ورژن ڈیسک ٹاپ ایپ ورژن سے مماثل نہیں ہے۔", "wsl.onboarding.adding": "شامل کیا جا رہا ہے...", + "help.tabs.toast.ariaLabel": "ٹیبز کا تعارف۔ اپنے کام اور فعال سیشنز کو ٹیبز کے ذریعے منظم کریں", + "help.tabs.toast.dismiss": "ٹیبز کی معلومات بند کریں", + "help.tabs.title": "ٹیبز کا تعارف", + "help.tabs.description": "اپنے کام اور فعال سیشنز کو ٹیبز کے ذریعے منظم کریں", + "help.tabs.date": "14 جولائی", + "help.tabs.introduction": "OpenCode Desktop اب ٹیبز پر مبنی ہے۔", + "help.tabs.sessions": + "ٹیب میں نیا سیشن شروع کریں یا اپنے کسی بھی پروجیکٹ سے موجودہ سیشن کھولیں۔ کوئی نیا کام شروع کرتے وقت نیا ٹیب کھولیں اور کام مکمل ہونے پر اسے بند کر دیں۔", + "help.tabs.organize": + "چند ٹیبز کھلے رکھنے سے فعال سیشنز کو منظم کرنا آسان ہو جاتا ہے۔ اگر آپ کسی ٹیب کو برقرار رکھنا چاہتے ہیں تو اسے کوئی یاد رہنے والا نام دے دیں۔", + "help.tabs.home": + "نئی ہوم اسکرین پر آپ کو اپنے تمام سیشنز اور پروجیکٹس ملیں گے۔ کسی سیشن کو منتخب کرنے سے وہ ٹیب میں کھل جائے گا۔", + "help.tabs.persistence": "ایپ دوبارہ کھولنے پر آپ کے ٹیبز کھلے رہیں گے۔", + "help.tabs.worktrees": + "نیا ڈیزائن ابھی Git Worktrees کو سپورٹ نہیں کرتا، یہ سہولت جلد آ رہی ہے۔ اگر آپ پچھلا لے آؤٹ استعمال کرنا جاری رکھنا چاہتے ہیں تو ترتیبات میں لے آؤٹس کے درمیان سوئچ کر سکتے ہیں۔ یاد رہے کہ چند ہفتوں میں نیا لے آؤٹ مستقل ہو جائے گا۔", "server.row.noUsername": "کوئی صارف نام نہیں", "dialog.project.edit.title": "پروجیکٹ میں ترمیم کریں۔", "dialog.project.edit.name": "نام", @@ -703,6 +822,11 @@ export const dict = { "terminal.connectionLost.title": "کنکشن ختم ہو گیا۔", "terminal.connectionLost.abnormalClose": "WebSocket غیر معمولی طور پر بند ہوا: {{code}}", "terminal.connectionLost.description": "ٹرمینل کنکشن میں خلل پڑا۔ یہ سرور کے دوبارہ شروع ہونے پر ہوسکتا ہے۔", + "terminal.connectTicket.csrfError": + "PTY کنکشن ٹکٹ کو ماخذ یا CSRF جانچ نے مسترد کر دیا۔ سرور کی CORS تشکیل چیک کریں۔", + "terminal.connectTicket.statusError": "PTY کنکشن ٹکٹ {{status}} اسٹیٹس کے ساتھ ناکام ہو گیا۔", + "titlebar.update": "اپ ڈیٹ کریں", + "titlebar.updateVersion": "اپ ڈیٹ کریں {{version}}", "common.closeTab": "ٹیب بند کریں۔", "common.dismiss": "نظر انداز کریں", "common.moreCountSuffix": " (+{{count}} مزید)", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index 0e846d0fd50d..e0787217d3c4 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -1,4 +1,103 @@ export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "Tệp", + "desktop.menu.edit": "Chỉnh sửa", + "desktop.menu.view": "Xem", + "desktop.menu.go": "Đi", + "desktop.menu.window": "Cửa sổ", + "desktop.menu.help": "Trợ giúp", + "desktop.menu.checkForUpdates": "Kiểm tra bản cập nhật...", + "desktop.menu.settings": "Cài đặt", + "desktop.menu.reloadWebview": "Tải lại Webview", + "desktop.menu.restart": "Khởi động lại", + "desktop.menu.exportLogs": "Xuất nhật ký...", + "desktop.menu.newSession": "Phiên mới", + "desktop.menu.openProject": "Mở dự án...", + "desktop.menu.newWindow": "Cửa sổ mới", + "desktop.menu.closeWindow": "Đóng cửa sổ", + "desktop.menu.undo": "Hoàn tác", + "desktop.menu.redo": "Làm lại", + "desktop.menu.cut": "Cắt", + "desktop.menu.copy": "Sao chép", + "desktop.menu.paste": "Dán", + "desktop.menu.delete": "Xóa", + "desktop.menu.selectAll": "Chọn tất cả", + "desktop.menu.toggleSidebar": "Bật/tắt thanh bên", + "desktop.menu.toggleTerminal": "Bật/tắt terminal", + "desktop.menu.toggleFileTree": "Bật/tắt cây tệp", + "desktop.menu.reload": "Tải lại", + "desktop.menu.toggleDeveloperTools": "Bật/tắt công cụ dành cho nhà phát triển", + "desktop.menu.actualSize": "Kích thước thực", + "desktop.menu.zoomIn": "Phóng to", + "desktop.menu.zoomOut": "Thu nhỏ", + "desktop.menu.toggleFullScreen": "Bật/tắt toàn màn hình", + "desktop.menu.back": "Quay lại", + "desktop.menu.forward": "Tiến tới", + "desktop.menu.previousSession": "Phiên trước", + "desktop.menu.nextSession": "Phiên tiếp theo", + "desktop.menu.previousProject": "Dự án trước", + "desktop.menu.nextProject": "Dự án tiếp theo", + "desktop.menu.minimize": "Thu nhỏ", + "desktop.menu.maximize": "Phóng to", + "desktop.menu.documentation": "Tài liệu OpenCode", + "desktop.menu.supportForum": "Diễn đàn hỗ trợ", + "desktop.menu.shareFeedback": "Chia sẻ phản hồi", + "desktop.menu.reportBug": "Báo cáo lỗi", + "desktop.menu.ariaLabel": "Menu OpenCode", + + "desktop.updater.dialog.checkFailed.message": "Không kiểm tra được bản cập nhật.", + "desktop.updater.dialog.checkFailed.title": "Lỗi cập nhật", + "desktop.updater.dialog.upToDate.message": "Bạn đang dùng phiên bản mới nhất.", + "desktop.updater.dialog.upToDate.title": "Không có bản cập nhật", + "desktop.updater.dialog.ready.message": "Đã tải xuống bản cập nhật {{version}}. Khởi động lại ngay?", + "desktop.updater.dialog.ready.title": "Bản cập nhật đã sẵn sàng", + "desktop.updater.dialog.restart": "Khởi động lại", + "desktop.updater.dialog.later": "Để sau", + + "desktop.recovery.action.relaunch": "Khởi chạy lại", + "desktop.recovery.action.exportLogs": "Xuất nhật ký", + "desktop.recovery.action.keepWaiting": "Tiếp tục chờ", + "desktop.recovery.action.quit": "Thoát", + "desktop.recovery.loadFailed": "Không tải được OpenCode", + "desktop.recovery.terminated": "Cửa sổ OpenCode đã bị đóng đột ngột", + "desktop.recovery.unresponsive": "OpenCode không phản hồi", + "desktop.recovery.unresponsive.detail": "Bạn có thể khởi chạy lại ứng dụng, mở nhật ký hoặc tiếp tục chờ.", + "desktop.recovery.loadFailed.detail": "Cửa sổ: {{window}}\nURL: {{url}}\nLỗi: {{code}} {{description}}", + "desktop.recovery.terminated.detail": "Cửa sổ: {{window}}\nLý do: {{reason}}\nMã: {{code}}", + "desktop.recovery.unknown": "", + + "desktop.dialog.chooseFolder": "Chọn thư mục", + "desktop.dialog.chooseFile": "Chọn tệp", + "desktop.dialog.saveFile": "Lưu tệp", + "desktop.dialog.files": "Tệp", + "desktop.server.local": "Máy chủ cục bộ", + + "desktop.wsl.error.windowsOnly": "WSL chỉ khả dụng trên Windows", + "desktop.wsl.error.unavailable": "WSL không khả dụng", + "desktop.wsl.error.listInstalled": "Không liệt kê được các bản phân phối WSL đã cài đặt", + "desktop.wsl.error.listOnline": "Không liệt kê được các bản phân phối WSL trực tuyến", + "desktop.wsl.error.executeDistro": "Không thể thực thi lệnh trong bản phân phối", + "desktop.wsl.error.installWsl": "Không cài đặt được WSL", + "desktop.wsl.error.installDistro": "Không cài đặt được bản phân phối: {{distro}}", + "desktop.wsl.error.installOpencode": "Không cài đặt được OpenCode", + "desktop.wsl.error.alreadyAdded": "{{distro}} đã được thêm", + "desktop.wsl.error.opencodeMissing": "opencode chưa được cài đặt trong bản phân phối này", + "desktop.wsl.error.opencodeCannotRun": "opencode đã được cài đặt nhưng không thể chạy", + "desktop.wsl.error.opencodeNotInstalled": "OpenCode chưa được cài đặt trong {{distro}}", + "desktop.wsl.error.updateVersion": + "Đã cập nhật OpenCode nhưng {{distro}} vẫn báo phiên bản {{installed}}; phiên bản dự kiến là {{expected}}", + "desktop.wsl.error.noVersion": "không có phiên bản", + "desktop.wsl.error.serverExited": "Máy chủ WSL đã thoát sau khi khởi động (mã={{code}} tín hiệu={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "Máy chủ WSL đã thoát trước khi sẵn sàng (mã={{code}} tín hiệu={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": + "Sidecar kiểm tra tình trạng của {{distro}} đã hết thời gian chờ sau {{timeout}}ms", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} đã hết thời gian chờ sau {{timeout}}ms", + "desktop.wsl.error.failedPort": "Không lấy được cổng", + + "desktop.picker.error.notSelected": "Không có tệp nào được chọn trong bộ chọn tệp", + "desktop.picker.error.sizeLimit": "Các tệp đính kèm đã chọn vượt quá giới hạn {{limit}} MB", + "command.category.suggested": "Đề xuất", "command.category.view": "Xem", "command.category.project": "Dự án", @@ -16,6 +115,7 @@ export const dict = { "command.category.permissions": "Quyền", "command.category.workspace": "Không gian làm việc", "command.category.settings": "Cài đặt", + "command.logs.export": "Xuất nhật ký", "theme.scheme.system": "Hệ thống", "theme.scheme.light": "Sáng", "theme.scheme.dark": "Tối", @@ -119,6 +219,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "Đăng nhập bằng Claude Pro/Max", "provider.connect.selectMethod": "Chọn phương thức đăng nhập cho {{provider}}.", "provider.connect.method.apiKey": "Khóa API", + "provider.connect.method.browser": "Trình duyệt", + "provider.connect.method.headless": "Không có giao diện người dùng", "provider.connect.status.inProgress": "Đang ủy quyền...", "provider.connect.status.waiting": "Đang chờ ủy quyền...", "provider.connect.status.failed": "Ủy quyền thất bại: {{error}}", @@ -150,6 +252,7 @@ export const dict = { "provider.connect.toast.connected.title": "{{provider}} đã kết nối", "provider.connect.toast.connected.description": "Các mô hình {{provider}} hiện đã sẵn sàng để sử dụng.", "provider.custom.title": "Nhà cung cấp tùy chỉnh", + "provider.custom.unavailable": "Nhà cung cấp tùy chỉnh không khả dụng trên máy chủ này", "provider.custom.description.prefix": "Định cấu hình nhà cung cấp tương thích OpenAI. Xem ", "provider.custom.description.link": "tài liệu cấu hình nhà cung cấp", "provider.custom.description.suffix": ".", @@ -409,6 +512,21 @@ export const dict = { "wsl.onboarding.desktopVersion": "Desktop {{version}}", "wsl.onboarding.versionMismatch": "Phiên bản đã cài đặt không khớp với phiên bản ứng dụng desktop.", "wsl.onboarding.adding": "Đang thêm...", + "help.tabs.toast.ariaLabel": "Giới thiệu về tab. Sắp xếp công việc và các phiên đang hoạt động bằng tab", + "help.tabs.toast.dismiss": "Bỏ qua thông tin về tab", + "help.tabs.title": "Giới thiệu về tab", + "help.tabs.description": "Sắp xếp công việc và các phiên đang hoạt động bằng tab", + "help.tabs.date": "14 tháng 7", + "help.tabs.introduction": "OpenCode Desktop nay được xây dựng xoay quanh các tab.", + "help.tabs.sessions": + "Bắt đầu phiên mới trong một tab hoặc mở phiên hiện có từ bất kỳ dự án nào của bạn. Hãy mở tab mới khi bắt đầu công việc mới và đóng tab khi hoàn tất.", + "help.tabs.organize": + "Việc mở một vài tab giúp bạn dễ dàng sắp xếp các phiên đang hoạt động. Hãy đổi tên tab thành tên dễ nhớ nếu bạn định giữ tab đó lâu dài.", + "help.tabs.home": + "Bạn sẽ tìm thấy tất cả các phiên và dự án trên màn hình Trang chủ mới. Khi bạn chọn một phiên, phiên đó sẽ mở trong một tab.", + "help.tabs.persistence": "Khi bạn mở lại ứng dụng, các tab vẫn mở.", + "help.tabs.worktrees": + "Thiết kế mới chưa hỗ trợ Git Worktrees, nhưng tính năng này sẽ sớm ra mắt. Vì vậy, nếu muốn tiếp tục sử dụng bố cục trước đó, bạn có thể chuyển đổi giữa các bố cục trong Cài đặt. Xin lưu ý rằng bố cục mới sẽ trở thành cố định sau vài tuần nữa.", "server.row.noUsername": "không có tên người dùng", "dialog.project.edit.title": "Chỉnh sửa dự án", "dialog.project.edit.name": "Tên", @@ -704,6 +822,11 @@ export const dict = { "terminal.connectionLost.abnormalClose": "WebSocket đóng bất thường: {{code}}", "terminal.connectionLost.description": "Kết nối terminal bị gián đoạn. Điều này có thể xảy ra khi máy chủ khởi động lại.", + "terminal.connectTicket.csrfError": + "Vé kết nối PTY bị từ chối do kiểm tra nguồn gốc hoặc CSRF. Hãy kiểm tra cấu hình CORS của máy chủ.", + "terminal.connectTicket.statusError": "Vé kết nối PTY thất bại với trạng thái {{status}}", + "titlebar.update": "Cập nhật", + "titlebar.updateVersion": "Cập nhật {{version}}", "common.closeTab": "Đóng tab", "common.dismiss": "Bỏ qua", "common.moreCountSuffix": " (+{{count}} thêm)", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 46d28c37a092..1a725433afb8 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -3,6 +3,104 @@ import { dict as en } from "./en" type Keys = keyof typeof en export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "文件", + "desktop.menu.edit": "编辑", + "desktop.menu.view": "视图", + "desktop.menu.go": "转到", + "desktop.menu.window": "窗口", + "desktop.menu.help": "帮助", + "desktop.menu.checkForUpdates": "检查更新...", + "desktop.menu.settings": "设置", + "desktop.menu.reloadWebview": "重新加载 Webview", + "desktop.menu.restart": "重启", + "desktop.menu.exportLogs": "导出日志...", + "desktop.menu.newSession": "新建会话", + "desktop.menu.openProject": "打开项目...", + "desktop.menu.newWindow": "新建窗口", + "desktop.menu.closeWindow": "关闭窗口", + "desktop.menu.undo": "撤销", + "desktop.menu.redo": "重做", + "desktop.menu.cut": "剪切", + "desktop.menu.copy": "复制", + "desktop.menu.paste": "粘贴", + "desktop.menu.delete": "删除", + "desktop.menu.selectAll": "全选", + "desktop.menu.toggleSidebar": "切换侧边栏", + "desktop.menu.toggleTerminal": "切换终端", + "desktop.menu.toggleFileTree": "切换文件树", + "desktop.menu.reload": "重新加载", + "desktop.menu.toggleDeveloperTools": "切换开发人员工具", + "desktop.menu.actualSize": "实际大小", + "desktop.menu.zoomIn": "放大", + "desktop.menu.zoomOut": "缩小", + "desktop.menu.toggleFullScreen": "切换全屏显示", + "desktop.menu.back": "后退", + "desktop.menu.forward": "前进", + "desktop.menu.previousSession": "上一个会话", + "desktop.menu.nextSession": "下一个会话", + "desktop.menu.previousProject": "上一个项目", + "desktop.menu.nextProject": "下一个项目", + "desktop.menu.minimize": "最小化", + "desktop.menu.maximize": "最大化", + "desktop.menu.documentation": "OpenCode 文档", + "desktop.menu.supportForum": "支持论坛", + "desktop.menu.shareFeedback": "提供反馈", + "desktop.menu.reportBug": "报告错误", + "desktop.menu.ariaLabel": "OpenCode 菜单", + + "desktop.updater.dialog.checkFailed.message": "检查更新失败。", + "desktop.updater.dialog.checkFailed.title": "更新错误", + "desktop.updater.dialog.upToDate.message": "你使用的是最新版本。", + "desktop.updater.dialog.upToDate.title": "无可用更新", + "desktop.updater.dialog.ready.message": "已下载更新 {{version}}。是否立即重启?", + "desktop.updater.dialog.ready.title": "更新已就绪", + "desktop.updater.dialog.restart": "重启", + "desktop.updater.dialog.later": "稍后", + + "desktop.recovery.action.relaunch": "重新启动", + "desktop.recovery.action.exportLogs": "导出日志", + "desktop.recovery.action.keepWaiting": "继续等待", + "desktop.recovery.action.quit": "退出", + "desktop.recovery.loadFailed": "OpenCode 加载失败", + "desktop.recovery.terminated": "OpenCode 窗口意外终止", + "desktop.recovery.unresponsive": "OpenCode 未响应", + "desktop.recovery.unresponsive.detail": "你可以重新启动应用、打开日志或继续等待。", + "desktop.recovery.loadFailed.detail": "窗口:{{window}}\nURL:{{url}}\n错误:{{code}} {{description}}", + "desktop.recovery.terminated.detail": "窗口:{{window}}\n原因:{{reason}}\n代码:{{code}}", + "desktop.recovery.unknown": "<未知>", + + "desktop.dialog.chooseFolder": "选择文件夹", + "desktop.dialog.chooseFile": "选择文件", + "desktop.dialog.saveFile": "保存文件", + "desktop.dialog.files": "文件", + "desktop.server.local": "本地服务器", + + "desktop.wsl.error.windowsOnly": "WSL 仅在 Windows 上可用", + "desktop.wsl.error.unavailable": "WSL 不可用", + "desktop.wsl.error.listInstalled": "无法列出已安装的 WSL 发行版", + "desktop.wsl.error.listOnline": "无法列出在线 WSL 发行版", + "desktop.wsl.error.executeDistro": "无法在发行版中执行命令", + "desktop.wsl.error.installWsl": "WSL 安装失败", + "desktop.wsl.error.installDistro": "无法安装发行版:{{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode 安装失败", + "desktop.wsl.error.alreadyAdded": "已添加 {{distro}}", + "desktop.wsl.error.opencodeMissing": "此发行版中未安装 opencode", + "desktop.wsl.error.opencodeCannotRun": "已安装 opencode,但无法运行", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}} 中未安装 OpenCode", + "desktop.wsl.error.updateVersion": + "OpenCode 更新已完成,但 {{distro}} 仍报告版本 {{installed}};预期版本为 {{expected}}", + "desktop.wsl.error.noVersion": "无版本信息", + "desktop.wsl.error.serverExited": "WSL 服务器启动后退出(code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL 服务器在进入正常状态前退出(code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": "{{distro}} 的 Sidecar 运行状况检查在 {{timeout}}ms 后超时", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} 在 {{timeout}}ms 后超时", + "desktop.wsl.error.failedPort": "无法获取端口", + + "desktop.picker.error.notSelected": "文件选择器未选择文件", + "desktop.picker.error.sizeLimit": "所选附件超过 {{limit}} MB 的限制", + "command.category.suggested": "建议", "command.category.view": "视图", "command.category.project": "项目", @@ -20,6 +118,7 @@ export const dict = { "command.category.permissions": "权限", "command.category.workspace": "工作区", "command.category.settings": "设置", + "command.logs.export": "导出日志", "theme.scheme.system": "系统", "theme.scheme.light": "浅色", @@ -157,6 +256,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "使用 Claude Pro/Max 登录", "provider.connect.selectMethod": "选择 {{provider}} 的登录方式。", "provider.connect.method.apiKey": "API 密钥", + "provider.connect.method.browser": "浏览器", + "provider.connect.method.headless": "无头模式", "provider.connect.status.inProgress": "正在授权...", "provider.connect.status.waiting": "等待授权...", "provider.connect.status.failed": "授权失败:{{error}}", @@ -185,6 +286,7 @@ export const dict = { "provider.connect.toast.connected.description": "现在可以使用 {{provider}} 模型了。", "provider.custom.title": "自定义提供商", + "provider.custom.unavailable": "此服务器上无法使用自定义提供商", "provider.custom.description.prefix": "配置与 OpenAI 兼容的提供商。请查看", "provider.custom.description.link": "提供商配置文档", "provider.custom.description.suffix": "。", @@ -448,6 +550,21 @@ export const dict = { "wsl.onboarding.versionMismatch": "已安装版本与桌面应用版本不一致。", "wsl.onboarding.adding": "正在添加...", + "help.tabs.toast.ariaLabel": "标签页功能介绍。使用标签页整理工作和进行中的会话", + "help.tabs.toast.dismiss": "关闭标签页功能介绍", + "help.tabs.title": "标签页功能介绍", + "help.tabs.description": "使用标签页整理工作和进行中的会话", + "help.tabs.date": "7 月 14 日", + "help.tabs.introduction": "OpenCode Desktop 现已采用以标签页为核心的设计。", + "help.tabs.sessions": + "在标签页中开始新会话,或从任意项目打开现有会话。开始处理新任务时打开新标签页,完成后将其关闭。", + "help.tabs.organize": + "保持打开几个标签页,可更轻松地整理进行中的会话。如果打算保留某个标签页,请为其指定一个便于记忆的名称。", + "help.tabs.home": "你可以在新的主页中找到所有会话和项目。选择会话即可在标签页中将其打开。", + "help.tabs.persistence": "重新打开应用时,标签页仍会保持打开。", + "help.tabs.worktrees": + "新设计暂不支持 Git Worktrees,此功能即将推出。如果希望继续使用之前的布局,可在“设置”中切换布局。请注意,新布局将在几周后永久启用。", + "dialog.project.edit.title": "编辑项目", "dialog.project.edit.name": "名称", "dialog.project.edit.icon": "图标", @@ -710,6 +827,11 @@ export const dict = { "terminal.close": "关闭终端", "terminal.connectionLost.title": "连接已丢失", "terminal.connectionLost.description": "终端连接已中断。这可能发生在服务器重启时。", + "terminal.connectTicket.csrfError": "PTY 连接票证因来源或 CSRF 检查被拒绝。请检查服务器 CORS 配置。", + "terminal.connectTicket.statusError": "PTY 连接票证失败,状态为 {{status}}", + + "titlebar.update": "更新", + "titlebar.updateVersion": "更新 {{version}}", "common.closeTab": "关闭标签页", "common.dismiss": "忽略", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index f57ebc823a86..b1b85acd2443 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -3,6 +3,104 @@ import { dict as en } from "./en" type Keys = keyof typeof en export const dict = { + "desktop.menu.app": "OpenCode", + "desktop.menu.file": "檔案", + "desktop.menu.edit": "編輯", + "desktop.menu.view": "檢視", + "desktop.menu.go": "前往", + "desktop.menu.window": "視窗", + "desktop.menu.help": "說明", + "desktop.menu.checkForUpdates": "檢查更新...", + "desktop.menu.settings": "設定", + "desktop.menu.reloadWebview": "重新載入 Webview", + "desktop.menu.restart": "重新啟動", + "desktop.menu.exportLogs": "匯出記錄檔...", + "desktop.menu.newSession": "新增工作階段", + "desktop.menu.openProject": "開啟專案...", + "desktop.menu.newWindow": "新增視窗", + "desktop.menu.closeWindow": "關閉視窗", + "desktop.menu.undo": "復原", + "desktop.menu.redo": "重做", + "desktop.menu.cut": "剪下", + "desktop.menu.copy": "複製", + "desktop.menu.paste": "貼上", + "desktop.menu.delete": "刪除", + "desktop.menu.selectAll": "全選", + "desktop.menu.toggleSidebar": "切換側邊欄", + "desktop.menu.toggleTerminal": "切換終端機", + "desktop.menu.toggleFileTree": "切換檔案樹", + "desktop.menu.reload": "重新載入", + "desktop.menu.toggleDeveloperTools": "切換開發人員工具", + "desktop.menu.actualSize": "實際大小", + "desktop.menu.zoomIn": "放大", + "desktop.menu.zoomOut": "縮小", + "desktop.menu.toggleFullScreen": "切換全螢幕", + "desktop.menu.back": "上一頁", + "desktop.menu.forward": "下一頁", + "desktop.menu.previousSession": "上一個工作階段", + "desktop.menu.nextSession": "下一個工作階段", + "desktop.menu.previousProject": "上一個專案", + "desktop.menu.nextProject": "下一個專案", + "desktop.menu.minimize": "最小化", + "desktop.menu.maximize": "最大化", + "desktop.menu.documentation": "OpenCode 文件", + "desktop.menu.supportForum": "支援論壇", + "desktop.menu.shareFeedback": "提供意見回饋", + "desktop.menu.reportBug": "回報錯誤", + "desktop.menu.ariaLabel": "OpenCode 選單", + + "desktop.updater.dialog.checkFailed.message": "檢查更新失敗。", + "desktop.updater.dialog.checkFailed.title": "更新錯誤", + "desktop.updater.dialog.upToDate.message": "目前已是最新版本。", + "desktop.updater.dialog.upToDate.title": "沒有更新", + "desktop.updater.dialog.ready.message": "已下載更新 {{version}}。要立即重新啟動嗎?", + "desktop.updater.dialog.ready.title": "更新已就緒", + "desktop.updater.dialog.restart": "重新啟動", + "desktop.updater.dialog.later": "稍後", + + "desktop.recovery.action.relaunch": "重新啟動應用程式", + "desktop.recovery.action.exportLogs": "匯出記錄檔", + "desktop.recovery.action.keepWaiting": "繼續等待", + "desktop.recovery.action.quit": "結束", + "desktop.recovery.loadFailed": "OpenCode 載入失敗", + "desktop.recovery.terminated": "OpenCode 視窗意外終止", + "desktop.recovery.unresponsive": "OpenCode 沒有回應", + "desktop.recovery.unresponsive.detail": "你可以重新啟動應用程式、開啟記錄檔,或繼續等待。", + "desktop.recovery.loadFailed.detail": "視窗:{{window}}\nURL:{{url}}\n錯誤:{{code}} {{description}}", + "desktop.recovery.terminated.detail": "視窗:{{window}}\n原因:{{reason}}\n代碼:{{code}}", + "desktop.recovery.unknown": "<未知>", + + "desktop.dialog.chooseFolder": "選擇資料夾", + "desktop.dialog.chooseFile": "選擇檔案", + "desktop.dialog.saveFile": "儲存檔案", + "desktop.dialog.files": "檔案", + "desktop.server.local": "本機伺服器", + + "desktop.wsl.error.windowsOnly": "WSL 僅適用於 Windows", + "desktop.wsl.error.unavailable": "WSL 無法使用", + "desktop.wsl.error.listInstalled": "無法列出已安裝的 WSL 發行版", + "desktop.wsl.error.listOnline": "無法列出線上 WSL 發行版", + "desktop.wsl.error.executeDistro": "無法在發行版中執行命令", + "desktop.wsl.error.installWsl": "WSL 安裝失敗", + "desktop.wsl.error.installDistro": "無法安裝發行版:{{distro}}", + "desktop.wsl.error.installOpencode": "OpenCode 安裝失敗", + "desktop.wsl.error.alreadyAdded": "已新增 {{distro}}", + "desktop.wsl.error.opencodeMissing": "此發行版中未安裝 opencode", + "desktop.wsl.error.opencodeCannotRun": "已安裝 opencode,但無法執行", + "desktop.wsl.error.opencodeNotInstalled": "{{distro}} 中未安裝 OpenCode", + "desktop.wsl.error.updateVersion": + "OpenCode 更新完成,但 {{distro}} 回報的版本仍為 {{installed}};預期版本為 {{expected}}", + "desktop.wsl.error.noVersion": "無版本資訊", + "desktop.wsl.error.serverExited": "WSL 伺服器在啟動後結束 (code={{code}} signal={{signal}})", + "desktop.wsl.error.serverExitedBeforeHealthy": + "WSL 伺服器在健全狀態確認前結束 (code={{code}} signal={{signal}}){{output}}", + "desktop.wsl.error.healthTimeout": "{{distro}} 的 Sidecar 健全狀態檢查在 {{timeout}}ms 後逾時", + "desktop.wsl.error.commandTimeout": "{{command}} {{args}} 在 {{timeout}}ms 後逾時", + "desktop.wsl.error.failedPort": "無法取得連接埠", + + "desktop.picker.error.notSelected": "檔案選擇器未選取檔案", + "desktop.picker.error.sizeLimit": "所選附件超過 {{limit}} MB 的限制", + "command.category.suggested": "建議", "command.category.view": "檢視", "command.category.project": "專案", @@ -21,6 +119,7 @@ export const dict = { "command.category.workspace": "工作區", "command.category.settings": "設定", + "command.logs.export": "匯出記錄檔", "theme.scheme.system": "系統", "theme.scheme.light": "淺色", "theme.scheme.dark": "深色", @@ -135,6 +234,8 @@ export const dict = { "provider.connect.title.anthropicProMax": "使用 Claude Pro/Max 登入", "provider.connect.selectMethod": "選擇 {{provider}} 的登入方式。", "provider.connect.method.apiKey": "API 金鑰", + "provider.connect.method.browser": "瀏覽器", + "provider.connect.method.headless": "無頭模式", "provider.connect.status.inProgress": "正在授權...", "provider.connect.status.waiting": "等待授權...", "provider.connect.status.failed": "授權失敗: {{error}}", @@ -164,6 +265,7 @@ export const dict = { "provider.connect.toast.connected.description": "現在可以使用 {{provider}} 模型了。", "provider.custom.title": "自訂提供者", + "provider.custom.unavailable": "此伺服器無法使用自訂提供者", "provider.custom.description.prefix": "設定與 OpenAI 相容的提供者。請參閱", "provider.custom.description.link": "提供者設定文件", "provider.custom.description.suffix": "。", @@ -429,6 +531,21 @@ export const dict = { "wsl.onboarding.versionMismatch": "已安裝版本與桌面應用程式版本不符。", "wsl.onboarding.adding": "新增中...", + "help.tabs.toast.ariaLabel": "分頁功能介紹。使用分頁整理工作和進行中的工作階段", + "help.tabs.toast.dismiss": "關閉分頁功能資訊", + "help.tabs.title": "分頁功能介紹", + "help.tabs.description": "使用分頁整理工作和進行中的工作階段", + "help.tabs.date": "7 月 14 日", + "help.tabs.introduction": "OpenCode Desktop 現在以分頁為核心。", + "help.tabs.sessions": + "在分頁中開始新的工作階段,或從任一專案開啟現有的工作階段。開始新工作時請開啟新分頁,完成後再將其關閉。", + "help.tabs.organize": + "同時開啟幾個分頁,可讓進行中的工作階段更容易整理。如果打算保留分頁,請將其重新命名為容易記住的名稱。", + "help.tabs.home": "你可以在新的首頁找到所有工作階段和專案。選取工作階段即可在分頁中開啟。", + "help.tabs.persistence": "重新開啟應用程式時,分頁仍會保持開啟。", + "help.tabs.worktrees": + "新版設計尚未支援 Git Worktrees,此功能即將推出。如果你偏好繼續使用先前的版面,可以在設定中切換版面。請留意,新版面將在幾週後成為永久版面。", + "dialog.project.edit.title": "編輯專案", "dialog.project.edit.name": "名稱", "dialog.project.edit.icon": "圖示", @@ -708,6 +825,11 @@ export const dict = { "terminal.connectionLost.title": "連線中斷", "terminal.connectionLost.description": "終端機連線已中斷。這可能會在伺服器重新啟動時發生。", + "terminal.connectTicket.csrfError": "PTY 連線票證因來源或 CSRF 檢查而遭拒。請檢查伺服器 CORS 設定。", + "terminal.connectTicket.statusError": "PTY 連線票證失敗,狀態為 {{status}}", + + "titlebar.update": "更新", + "titlebar.updateVersion": "更新 {{version}}", "common.closeTab": "關閉分頁", "common.dismiss": "忽略", "common.requestFailed": "要求失敗", diff --git a/packages/app/src/index.ts b/packages/app/src/index.ts index 8d36086d9fc8..0e0ed0286f8e 100644 --- a/packages/app/src/index.ts +++ b/packages/app/src/index.ts @@ -8,7 +8,7 @@ export { useTabs } from "./context/tabs" export { useProviders } from "./hooks/use-providers" export { ACCEPTED_FILE_EXTENSIONS, ACCEPTED_FILE_TYPES, filePickerFilters } from "./constants/file-picker" export { useCommand } from "./context/command" -export { loadLocaleDict, normalizeLocale, type Locale } from "./context/language" +export { loadLocaleDict, normalizeLocale, type Locale, useLanguage } from "./context/language" export { useWslServers } from "./wsl/context" export { type DisplayBackend, type FatalRendererErrorLog, type Platform, PlatformProvider } from "./context/platform" export { type UpdaterPlatform, type UpdaterState } from "./updater" 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 941424e247cf..1fdcb11ccae6 100644 --- a/packages/app/src/pages/session/composer/session-question-dock.tsx +++ b/packages/app/src/pages/session/composer/session-question-dock.tsx @@ -472,7 +472,7 @@ export const SessionQuestionDock: Component<{ request: QuestionRequest; onSubmit data-answered={answered(i())} disabled={sending()} onClick={() => jump(i())} - aria-label={`${language.t("ui.tool.questions")} ${i() + 1}`} + aria-label={language.t("ui.tool.questions.numbered", { number: i() + 1 })} /> )} diff --git a/packages/app/src/utils/persist.ts b/packages/app/src/utils/persist.ts index a7742c399e5e..ebecde50975c 100644 --- a/packages/app/src/utils/persist.ts +++ b/packages/app/src/utils/persist.ts @@ -568,8 +568,9 @@ export function removePersisted( export function persisted( target: string | PersistTarget, store: [Store, SetStoreFunction], + platformOverride?: Platform, ): PersistedWithReady { - const platform = usePlatform() + const platform = platformOverride ?? usePlatform() const config = resolveTarget(typeof target === "string" ? { key: target } : target, platform) const defaults = snapshot(store[0]) diff --git a/packages/app/src/wsl/settings-model.test.ts b/packages/app/src/wsl/settings-model.test.ts index 812852ff10c0..c1bbe1db57f1 100644 --- a/packages/app/src/wsl/settings-model.test.ts +++ b/packages/app/src/wsl/settings-model.test.ts @@ -48,7 +48,7 @@ describe("WSL server settings presentation", () => { matchesDesktop: null, error: null, }), - ).toBe("Install OpenCode") + ).toBe("wsl.onboarding.installOpencode") expect( wslOpencodeAction({ distro: "Debian", @@ -58,7 +58,7 @@ describe("WSL server settings presentation", () => { matchesDesktop: false, error: null, }), - ).toBe("Update OpenCode") + ).toBe("wsl.onboarding.updateOpencode") expect( wslOpencodeAction({ distro: "Debian", diff --git a/packages/app/src/wsl/settings-model.ts b/packages/app/src/wsl/settings-model.ts index 8a172e0574ed..1a653cddb23b 100644 --- a/packages/app/src/wsl/settings-model.ts +++ b/packages/app/src/wsl/settings-model.ts @@ -53,8 +53,8 @@ export const wslRuntimeRetryable = (runtime: WslServerRuntime) => export function wslOpencodeAction(check?: WslOpencodeCheck) { if (!check) return - if (!check.resolvedPath) return "Install OpenCode" - if (check.matchesDesktop === false) return "Update OpenCode" + if (!check.resolvedPath) return "wsl.onboarding.installOpencode" + if (check.matchesDesktop === false) return "wsl.onboarding.updateOpencode" } export function wslDistroReady(state: WslServersState | undefined, name: string) { diff --git a/packages/app/src/wsl/settings.tsx b/packages/app/src/wsl/settings.tsx index 82616c6dcf7a..901f2476686d 100644 --- a/packages/app/src/wsl/settings.tsx +++ b/packages/app/src/wsl/settings.tsx @@ -124,7 +124,7 @@ export function WslServerSettings(props: { disabled={busy() || request.isPending} onClick={() => api && request.mutate(() => api.installOpencode(item.config.distro))} > - {busy() ? language.t("wsl.server.updating") : label()} + {busy() ? language.t("wsl.server.updating") : language.t(label())} )} diff --git a/packages/app/test-browser/prompt-persistence.test.ts b/packages/app/test-browser/prompt-persistence.test.ts index f3d8f8ae4fbf..7c3d28226f43 100644 --- a/packages/app/test-browser/prompt-persistence.test.ts +++ b/packages/app/test-browser/prompt-persistence.test.ts @@ -1,10 +1,11 @@ -import { beforeAll, describe, expect, mock, test } from "bun:test" +import { describe, expect, test } from "bun:test" import type { AsyncStorage } from "@solid-primitives/storage" import { createEffect, createRoot } from "solid-js" +import type { Platform } from "@/context/platform" +import { createPromptReady, createPromptSession } from "@/context/prompt-state" import { ServerScope } from "@/utils/server-scope" import { createDraftStore } from "@/utils/draft-store" -let Prompt: typeof import("@/context/prompt") let read: ((value: string | null) => void) | undefined const storage: AsyncStorage = { @@ -17,32 +18,25 @@ const storage: AsyncStorage = { length: Promise.resolve(0), } -beforeAll(async () => { - mock.module("@solidjs/router", () => ({ - useParams: () => ({}), - useSearchParams: () => [{}], - useLocation: () => ({ pathname: "", query: {} }), - useNavigate: () => () => undefined, - })) - mock.module("@opencode-ai/ui/context", () => ({ - createSimpleContext: () => ({ - use: () => undefined, - provider: () => undefined, - }), - })) - mock.module("@/context/platform", () => ({ - usePlatform: () => ({ platform: "desktop", storage: () => storage, draftStore: storage }), - })) - - Prompt = await import("@/context/prompt") -}) +const platform: Platform = { + platform: "web", + openExternal: () => undefined, + restart: async () => undefined, + notify: async () => undefined, + draftStore: { + ...storage, + putBlob: async () => { + throw new Error("putBlob is not used by this test") + }, + }, +} describe("prompt persistence", () => { test("waits for an async draft to hydrate before reporting ready", async () => { await new Promise((resolve, reject) => { createRoot((dispose) => { - const session = Prompt.createPromptSession(ServerScope.local, { draftID: "draft-async" }) - const ready = Prompt.createPromptReady(() => session) + const session = createPromptSession(ServerScope.local, { draftID: "draft-async" }, undefined, platform) + const ready = createPromptReady(() => session) expect(ready()).toBe(false) expect(session.current()[0]).toMatchObject({ type: "text", content: "" }) diff --git a/packages/desktop/AGENTS.md b/packages/desktop/AGENTS.md index 7805ea835f5b..72a129af0f07 100644 --- a/packages/desktop/AGENTS.md +++ b/packages/desktop/AGENTS.md @@ -2,3 +2,8 @@ - Renderer process should only call `window.api` from `src/preload`. - Main process should register IPC handlers in `src/main/ipc.ts`. +- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for native menus, picker titles, dialogs, buttons, accessible labels, and displayed errors. +- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. +- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. +- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. +- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. diff --git a/packages/desktop/src/main/attachment-picker.ts b/packages/desktop/src/main/attachment-picker.ts index 52584d2c3b5f..f2080cfe4d5e 100644 --- a/packages/desktop/src/main/attachment-picker.ts +++ b/packages/desktop/src/main/attachment-picker.ts @@ -1,5 +1,6 @@ import { randomUUID } from "node:crypto" import { open } from "node:fs/promises" +import { nativeT } from "./native-translations" export const MAX_ATTACHMENT_BYTES = 20 * 1024 * 1024 @@ -18,7 +19,7 @@ export function createPickedFileAuthorizations( async read(sender: number, token: string, path: string) { const selection = selections.get(token) if (selection?.sender !== sender || !selection.paths.delete(path)) - throw new Error("File was not selected by the picker") + throw new Error(nativeT("desktop.picker.error.notSelected")) const bytes = await read(path, selection.remaining) selection.remaining -= bytes.byteLength if (selection.paths.size === 0) selections.delete(token) @@ -33,7 +34,7 @@ export function createPickedFileAuthorizations( export function assertAttachmentBudget(files: { size: number }[]) { const total = files.reduce((sum, file) => sum + file.size, 0) if (total <= MAX_ATTACHMENT_BYTES) return - throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`) + throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) } export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT_BYTES) { @@ -41,7 +42,7 @@ export async function readAttachment(filePath: string, maxBytes = MAX_ATTACHMENT try { const info = await file.stat() if (info.size > maxBytes) - throw new Error(`Selected attachments exceed the ${MAX_ATTACHMENT_BYTES / 1024 / 1024} MB limit`) + throw new Error(nativeT("desktop.picker.error.sizeLimit", { limit: MAX_ATTACHMENT_BYTES / 1024 / 1024 })) const bytes = Buffer.allocUnsafe(info.size) let offset = 0 while (offset < info.size) { diff --git a/packages/desktop/src/main/index.ts b/packages/desktop/src/main/index.ts index e920b60257e2..7fb56391a6f7 100644 --- a/packages/desktop/src/main/index.ts +++ b/packages/desktop/src/main/index.ts @@ -48,6 +48,7 @@ import { spawnWslSidecar } from "./wsl/sidecar" import { migrate } from "./migrate" import { cleanupStoreFiles } from "./store-cleanup" import { startBackgroundCli } from "./background-cli" +import { setNativeTranslations } from "./native-translations" const APP_NAMES: Record = { dev: "OpenCode Dev", @@ -271,6 +272,14 @@ const main = Effect.gen(function* () { registerRendererProtocol() setDockIcon() const updater = setupAutoUpdater(stopSidecars) + const menuDeps = { + trigger: (id: string) => { + const win = getLastFocusedWindow() + if (win) sendMenuCommand(win, id) + }, + checkForUpdates: () => void showUpdaterDialog(updater, true), + relaunch, + } registerIpcHandlers({ killSidecar: () => killSidecar(), relaunch, @@ -298,6 +307,9 @@ const main = Effect.gen(function* () { setBackgroundColor: (color) => setBackgroundColor(color), exportDebugLogs: () => exportDebugLogs(), recordFatalRendererError: (error) => writeLog("renderer", "fatal renderer error", { ...error }, "error"), + setNativeTranslations: (bundle) => { + if (setNativeTranslations(bundle)) createMenu(menuDeps) + }, }) registerWslIpcHandlers(wslServers) void updater.start() @@ -397,20 +409,7 @@ const main = Effect.gen(function* () { yield* Fiber.await(loadingTask) const windows = restoreMainWindows() - if (windows.length) { - createMenu({ - trigger: (id) => { - const win = getLastFocusedWindow() - if (win) sendMenuCommand(win, id) - }, - checkForUpdates: () => { - void showUpdaterDialog(updater, true) - }, - relaunch: () => { - relaunch() - }, - }) - } + if (windows.length) createMenu(menuDeps) }) Effect.runFork(main) diff --git a/packages/desktop/src/main/ipc.ts b/packages/desktop/src/main/ipc.ts index bd7a3a75540c..84531910f779 100644 --- a/packages/desktop/src/main/ipc.ts +++ b/packages/desktop/src/main/ipc.ts @@ -4,6 +4,7 @@ import { basename, join } from "node:path" import { app, BrowserWindow, clipboard, dialog, ipcMain, shell } from "electron" import type { IpcMainEvent, IpcMainInvokeEvent } from "electron" import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" +import { parseDesktopNativeBundle, type DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" import type { FatalRendererError, ServerReadyData, TitlebarTheme } from "../preload/types" import { runDesktopMenuAction } from "./desktop-menu-actions" @@ -22,10 +23,11 @@ import { import type { UpdaterController } from "./updater-controller" import { createUpdaterSubscriptions } from "./updater-subscriptions" import { createDesktopDraftStore } from "./draft-store" +import { nativeT } from "./native-translations" const pickerFilters = (ext?: string[]) => { if (!ext || ext.length === 0) return undefined - return [{ name: "Files", extensions: ext }] + return [{ name: nativeT("desktop.dialog.files"), extensions: ext }] } const pickedFiles = createPickedFileAuthorizations() @@ -49,6 +51,7 @@ type Deps = { setBackgroundColor: (color: string) => void exportDebugLogs: () => Promise recordFatalRendererError: (error: FatalRendererError) => Promise | void + setNativeTranslations: (bundle: DesktopNativeBundle) => void } export function registerIpcHandlers(deps: Deps) { @@ -99,6 +102,20 @@ export function registerIpcHandlers(deps: Deps) { ipcMain.handle("record-fatal-renderer-error", (_event: IpcMainInvokeEvent, error: FatalRendererError) => deps.recordFatalRendererError(error), ) + ipcMain.handle("set-native-translations", (event: IpcMainInvokeEvent, value: unknown) => { + const win = BrowserWindow.fromWebContents(event.sender) + if ( + !win || + win.isDestroyed() || + win.webContents !== event.sender || + event.senderFrame !== event.sender.mainFrame + ) { + throw new Error("Invalid native translation sender") + } + const bundle = parseDesktopNativeBundle(value) + if (!bundle) throw new Error("Invalid native translation bundle") + deps.setNativeTranslations(bundle) + }) ipcMain.handle("store-get", (_event: IpcMainInvokeEvent, name: string, key: string) => { try { const store = getStore(name) @@ -142,7 +159,7 @@ export function registerIpcHandlers(deps: Deps) { async (_event: IpcMainInvokeEvent, opts?: { multiple?: boolean; title?: string; defaultPath?: string }) => { const result = await dialog.showOpenDialog({ properties: ["openDirectory", ...(opts?.multiple ? ["multiSelections" as const] : []), "createDirectory"], - title: opts?.title ?? "Choose a folder", + title: opts?.title ?? nativeT("desktop.dialog.chooseFolder"), defaultPath: opts?.defaultPath, }) if (result.canceled) return null @@ -158,7 +175,7 @@ export function registerIpcHandlers(deps: Deps) { ) => { const result = await dialog.showOpenDialog({ properties: ["openFile", ...(opts?.multiple ? ["multiSelections" as const] : [])], - title: opts?.title ?? "Choose a file", + title: opts?.title ?? nativeT("desktop.dialog.chooseFile"), defaultPath: opts?.defaultPath, filters: pickerFilters(opts?.extensions), }) @@ -188,7 +205,7 @@ export function registerIpcHandlers(deps: Deps) { "save-file-picker", async (_event: IpcMainInvokeEvent, opts?: { title?: string; defaultPath?: string }) => { const result = await dialog.showSaveDialog({ - title: opts?.title ?? "Save file", + title: opts?.title ?? nativeT("desktop.dialog.saveFile"), defaultPath: opts?.defaultPath, }) if (result.canceled) return null diff --git a/packages/desktop/src/main/menu.ts b/packages/desktop/src/main/menu.ts index 757583ceba49..ac2da7e547c1 100644 --- a/packages/desktop/src/main/menu.ts +++ b/packages/desktop/src/main/menu.ts @@ -10,6 +10,7 @@ import { import { UPDATER_ENABLED } from "./constants" import { runDesktopMenuAction } from "./desktop-menu-actions" import { openExternalURL } from "./windows" +import { nativeT } from "./native-translations" type Deps = { trigger: (id: string) => void @@ -21,9 +22,9 @@ export function createMenu(deps: Deps) { if (process.platform !== "darwin") return const template = DESKTOP_MENU.filter((menu) => desktopMenuVisible(menu, "macos")).map((menu) => { - if (menu.role) return { role: nativeRole(menu.role) } + if (menu.role) return { role: nativeRole(menu.role), label: nativeT(menu.labelKey) } return { - label: menu.label, + label: nativeT(menu.labelKey), submenu: menu.items ?.filter((entry) => desktopMenuVisible(entry, "macos")) .map((entry) => nativeItem(entry, deps)), @@ -35,10 +36,10 @@ export function createMenu(deps: Deps) { function nativeItem(entry: DesktopMenuEntry, deps: Deps): MenuItemConstructorOptions { if (entry.type === "separator") return { type: "separator" } - if (entry.role) return { role: nativeRole(entry.role) } + if (entry.role) return { role: nativeRole(entry.role), label: entry.labelKey ? nativeT(entry.labelKey) : undefined } const item: MenuItemConstructorOptions = { - label: entry.label, + label: entry.labelKey ? nativeT(entry.labelKey) : undefined, accelerator: entry.accelerator?.macos, enabled: entry.enabled === "updater" ? UPDATER_ENABLED : undefined, } diff --git a/packages/desktop/src/main/native-translations.ts b/packages/desktop/src/main/native-translations.ts new file mode 100644 index 000000000000..80e5caf412eb --- /dev/null +++ b/packages/desktop/src/main/native-translations.ts @@ -0,0 +1,24 @@ +import { + DESKTOP_NATIVE_ENGLISH, + DESKTOP_NATIVE_KEYS, + formatDesktopNativeMessage, + type DesktopNativeBundle, + type DesktopNativeKey, +} from "@opencode-ai/app/i18n/desktop-native" + +let bundle: DesktopNativeBundle = { locale: "en", messages: { ...DESKTOP_NATIVE_ENGLISH } } + +export function setNativeTranslations(next: DesktopNativeBundle) { + if ( + next.locale === bundle.locale && + DESKTOP_NATIVE_KEYS.every((key) => next.messages[key] === bundle.messages[key]) + ) { + return false + } + bundle = next + return true +} + +export function nativeT(key: DesktopNativeKey, params?: Record) { + return formatDesktopNativeMessage(bundle.messages[key], params) +} diff --git a/packages/desktop/src/main/updater.ts b/packages/desktop/src/main/updater.ts index cd483ed72fea..08350917c1b6 100644 --- a/packages/desktop/src/main/updater.ts +++ b/packages/desktop/src/main/updater.ts @@ -5,6 +5,7 @@ import { createUpdaterController, type UpdaterReadyRecord } from "./updater-cont import { getLogger } from "./logging" import { getStore } from "./store" import { setAppQuitting } from "./windows" +import { nativeT } from "./native-translations" const { autoUpdater } = pkg const key = "ready" @@ -63,21 +64,29 @@ export async function showUpdaterDialog(controller: ReturnType { - if (button === "Export Logs") { + type RecoveryAction = "relaunch" | "export-logs" | "keep-waiting" | "quit" + const handle = async (action: RecoveryAction | undefined, wait: boolean) => { + if (action === "export-logs") { const sampling = sampler.stopAndFlush() await exportDebugLogs().catch((error) => writeLog("main", "failed to export debug logs", { error }, "error")) if (wait && sampling) sampler.start() return true } - if (button === "Relaunch") { + if (action === "relaunch") { sampler.stopAndFlush() relaunchHandler() return false } - if (button === "Quit") { + if (action === "quit") { sampler.stopAndFlush() app.quit() } @@ -369,16 +371,26 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { showing = true try { while (!win.isDestroyed()) { - const buttons = wait ? ["Relaunch", "Export Logs", "Keep Waiting"] : ["Relaunch", "Export Logs", "Quit"] + const actions: { id: RecoveryAction; label: string }[] = wait + ? [ + { id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") }, + { id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") }, + { id: "keep-waiting", label: nativeT("desktop.recovery.action.keepWaiting") }, + ] + : [ + { id: "relaunch", label: nativeT("desktop.recovery.action.relaunch") }, + { id: "export-logs", label: nativeT("desktop.recovery.action.exportLogs") }, + { id: "quit", label: nativeT("desktop.recovery.action.quit") }, + ] const result = await dialog.showMessageBox(win, { type: "warning", - buttons, + buttons: actions.map((action) => action.label), defaultId: 0, cancelId: 2, message, detail, }) - if (await handle(buttons[result.response], wait)) continue + if (await handle(actions[result.response]?.id, wait)) continue return } } finally { @@ -410,8 +422,13 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { if (!isMainFrame || errorCode === -3) return void show( - "OpenCode failed to load", - [`Window: ${name}`, `URL: ${validatedURL}`, `Error: ${errorCode} ${errorDescription}`].join("\n"), + nativeT("desktop.recovery.loadFailed"), + nativeT("desktop.recovery.loadFailed.detail", { + window: name, + url: validatedURL, + code: errorCode, + description: errorDescription, + }), false, ) } @@ -426,15 +443,19 @@ function wireWindowRecovery(win: BrowserWindow, name: string) { sampler.stopAndFlush() writeLog("window", "renderer process gone", { window: name, currentURL: safeWindowURL(win), details }, "error") void show( - "OpenCode window terminated unexpectedly", - [`Window: ${name}`, `Reason: ${details.reason}`, `Code: ${details.exitCode ?? ""}`].join("\n"), + nativeT("desktop.recovery.terminated"), + nativeT("desktop.recovery.terminated.detail", { + window: name, + reason: details.reason, + code: details.exitCode ?? nativeT("desktop.recovery.unknown"), + }), false, ) }) win.on("unresponsive", () => { writeLog("window", "renderer unresponsive", { window: name, currentURL: safeWindowURL(win) }, "error") sampler.start() - void show("OpenCode is not responding", "You can relaunch the app, open the logs, or keep waiting.", true) + void show(nativeT("desktop.recovery.unresponsive"), nativeT("desktop.recovery.unresponsive.detail"), true) }) win.on("responsive", () => { writeLog("window", "renderer responsive", { window: name, currentURL: safeWindowURL(win) }, "error") diff --git a/packages/desktop/src/main/wsl/ipc.ts b/packages/desktop/src/main/wsl/ipc.ts index c3a2271bf6c3..839efad58538 100644 --- a/packages/desktop/src/main/wsl/ipc.ts +++ b/packages/desktop/src/main/wsl/ipc.ts @@ -3,6 +3,7 @@ import type { IpcMainInvokeEvent } from "electron" import type { WslServersController } from "./servers" import { requireWslIpcString, requireWslIpcStrings } from "./policy" import type { WslServersState } from "../../preload/types" +import { nativeT } from "../native-translations" export function registerWslIpcHandlers(controller: WslServersController) { if (process.platform !== "win32") { @@ -68,13 +69,13 @@ export function registerWslIpcHandlers(controller: WslServersController) { function registerUnavailableWslIpcHandlers() { const unavailable = () => { - throw new Error("WSL is only available on Windows") + throw new Error(nativeT("desktop.wsl.error.windowsOnly")) } const state = (): WslServersState => ({ runtime: { available: false, version: null, - error: "WSL is only available on Windows", + error: nativeT("desktop.wsl.error.windowsOnly"), }, installed: [], online: [], diff --git a/packages/desktop/src/main/wsl/runtime.ts b/packages/desktop/src/main/wsl/runtime.ts index 458443731bde..4ddf0eea31fa 100644 --- a/packages/desktop/src/main/wsl/runtime.ts +++ b/packages/desktop/src/main/wsl/runtime.ts @@ -4,6 +4,7 @@ import { join } from "node:path" import * as pty from "@lydell/node-pty" import type { WslDistroProbe, WslInstalledDistro, WslOnlineDistro, WslRuntimeCheck } from "../../preload/types" import { wslTerminalArgs } from "./policy" +import { nativeT } from "../native-translations" export type WslCommandLine = { stream: "stdout" | "stderr" @@ -68,7 +69,11 @@ function runCommand(command: string, args: string[], opts: RunWslOptions = {}) { } catch { /* ignore */ } - reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`)) + reject( + new Error( + nativeT("desktop.wsl.error.commandTimeout", { command, args: args.join(" "), timeout: timeoutMs }), + ), + ) }, timeoutMs) let stdout = "" @@ -139,7 +144,11 @@ function runInteractiveCommand(command: string, args: string[], opts: RunWslOpti if (settled) return settled = true cleanup() - reject(new Error(`${command} ${args.join(" ")} timed out after ${timeoutMs}ms`)) + reject( + new Error( + nativeT("desktop.wsl.error.commandTimeout", { command, args: args.join(" "), timeout: timeoutMs }), + ), + ) }, timeoutMs) const abortHandler = () => { @@ -214,7 +223,7 @@ export async function probeWslRuntime(opts?: RunWslOptions): Promise { const result = await installWslRuntimeElevated({ signal: abort.signal }) if (result.code !== 0) { - const message = summarize(result.stderr || result.stdout) || "WSL installation failed" + const message = summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installWsl") throw new Error(message) } const runtime = await probeWslRuntime({ signal: abort.signal }) @@ -339,7 +340,8 @@ export function createWslServersController( await runJob({ kind: "install-distro", distro: name, startedAt: Date.now() }, async (abort) => { const result = await installWslDistro(name, { signal: abort.signal }) if (result.code !== 0) { - const message = summarize(result.stderr || result.stdout) || `Failed to install distro: ${name}` + const message = + summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installDistro", { distro: name }) throw new Error(message) } const distros = await refreshDistroLists({ signal: abort.signal }) @@ -362,7 +364,7 @@ export function createWslServersController( await runJob({ kind: "install-opencode", distro: name, startedAt: Date.now() }, async (abort) => { const result = await installWslOpencode(appVersion, name, { signal: abort.signal }) if (result.code !== 0) { - throw new Error(summarize(result.stderr || result.stdout) || "OpenCode installation failed") + throw new Error(summarize(result.stderr || result.stdout) || nativeT("desktop.wsl.error.installOpencode")) } await refreshOpencodeCheck(name, { signal: abort.signal }) expectOpencodeVersion(state.opencodeChecks[name]?.version ?? null, appVersion, name) @@ -378,7 +380,7 @@ export function createWslServersController( async addServer(distro: string): Promise { const id = wslServerIdForDistro(distro) if (state.servers.some((item) => item.config.id === id)) { - throw new Error(`${distro} is already added`) + throw new Error(nativeT("desktop.wsl.error.alreadyAdded", { distro })) } const config: WslServerConfig = { id, @@ -475,7 +477,7 @@ function opencodeCheck( version: null, expectedVersion, matchesDesktop: null, - error: "opencode is not installed in this distro", + error: nativeT("desktop.wsl.error.opencodeMissing"), } } if (!version) { @@ -485,7 +487,7 @@ function opencodeCheck( version: null, expectedVersion, matchesDesktop: null, - error: "opencode is installed but could not run", + error: nativeT("desktop.wsl.error.opencodeCannotRun"), } } return { @@ -503,7 +505,7 @@ function distroProbeReady(probe: WslDistroProbe | undefined) { } function startupFailure(code: number | null, signal: NodeJS.Signals | null) { - return `WSL server exited after startup (code=${code ?? "null"} signal=${signal ?? "null"})` + return nativeT("desktop.wsl.error.serverExited", { code: code ?? "null", signal: signal ?? "null" }) } // Re-export types used by callers diff --git a/packages/desktop/src/main/wsl/sidecar.ts b/packages/desktop/src/main/wsl/sidecar.ts index 522e575a47ec..bd052dd84648 100644 --- a/packages/desktop/src/main/wsl/sidecar.ts +++ b/packages/desktop/src/main/wsl/sidecar.ts @@ -5,6 +5,7 @@ import { app } from "electron" import { checkHealth } from "../server" import { type WslCommandLine, resolveWslOpencode, shellEscape, wslArgs } from "./runtime" import { pollWslHealth } from "./startup" +import { nativeT } from "../native-translations" export type WslSidecar = { listener: { stop: () => void; onExit: (cb: (code: number | null, signal: NodeJS.Signals | null) => void) => void } @@ -18,7 +19,7 @@ export async function spawnWslSidecar( opts: { onLine?: (line: WslCommandLine) => void; healthTimeoutMs?: number } = {}, ): Promise { const opencode = await resolveWslOpencode(distro) - if (!opencode) throw new Error(`OpenCode is not installed in ${distro}`) + if (!opencode) throw new Error(nativeT("desktop.wsl.error.opencodeNotInstalled", { distro })) const port = await allocatePort() const password = randomUUID() @@ -64,7 +65,7 @@ export async function spawnWslSidecar( const timedOut = new Promise( (_, reject) => (timeout = setTimeout( - () => reject(new Error(`Sidecar for ${distro} health check timed out after ${timeoutMs}ms`)), + () => reject(new Error(nativeT("desktop.wsl.error.healthTimeout", { distro, timeout: timeoutMs }))), timeoutMs, )), ) @@ -97,7 +98,7 @@ function allocatePort() { const address = server.address() if (typeof address !== "object" || !address) { server.close() - reject(new Error("Failed to get port")) + reject(new Error(nativeT("desktop.wsl.error.failedPort"))) return } server.close(() => resolve(address.port)) @@ -125,5 +126,9 @@ function forwardLines( function startupFailure(code: number | null, signal: NodeJS.Signals | null, recentOutput: string[]) { const suffix = recentOutput.length ? `\n${recentOutput.join("\n")}` : "" - return `WSL server exited before becoming healthy (code=${code ?? "null"} signal=${signal ?? "null"})${suffix}` + return nativeT("desktop.wsl.error.serverExitedBeforeHealthy", { + code: code ?? "null", + signal: signal ?? "null", + output: suffix, + }) } diff --git a/packages/desktop/src/main/wsl/startup.ts b/packages/desktop/src/main/wsl/startup.ts index bfe7accfed7c..7a80c3d613f4 100644 --- a/packages/desktop/src/main/wsl/startup.ts +++ b/packages/desktop/src/main/wsl/startup.ts @@ -1,3 +1,5 @@ +import { nativeT } from "../native-translations" + export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) { return servers.map((server) => server.id) } @@ -5,7 +7,11 @@ export function wslServerIdsToStartOnInitialize(servers: { id: string }[]) { export function expectOpencodeVersion(installed: string | null, expected: string, distro = "Debian") { if (installed === expected) return throw new Error( - `OpenCode update finished but ${distro} still reports ${installed ?? "no version"}; expected ${expected}`, + nativeT("desktop.wsl.error.updateVersion", { + distro, + installed: installed ?? nativeT("desktop.wsl.error.noVersion"), + expected, + }), ) } diff --git a/packages/desktop/src/preload/index.ts b/packages/desktop/src/preload/index.ts index 6b8bd4dfa7da..dae18b0716a4 100644 --- a/packages/desktop/src/preload/index.ts +++ b/packages/desktop/src/preload/index.ts @@ -132,6 +132,7 @@ const api: ElectronAPI = { exportDebugLogs: () => ipcRenderer.invoke("export-debug-logs"), setForceFocus: (enabled) => ipcRenderer.invoke("set-force-focus", enabled), recordFatalRendererError: (error) => ipcRenderer.invoke("record-fatal-renderer-error", error), + setNativeTranslations: (bundle) => ipcRenderer.invoke("set-native-translations", bundle), } contextBridge.exposeInMainWorld("api", api) diff --git a/packages/desktop/src/preload/types.ts b/packages/desktop/src/preload/types.ts index fbf58f1fd6dd..20c39097d31d 100644 --- a/packages/desktop/src/preload/types.ts +++ b/packages/desktop/src/preload/types.ts @@ -1,6 +1,7 @@ import type { DesktopMenuAction } from "@opencode-ai/app/desktop-menu" import type { WslServersPlatform } from "@opencode-ai/app/wsl/types" import type { UpdaterState } from "@opencode-ai/app/updater" +import type { DesktopNativeBundle } from "@opencode-ai/app/i18n/desktop-native" export type { WslDistroProbe, WslInstalledDistro, @@ -111,4 +112,5 @@ export type ElectronAPI = { exportDebugLogs: () => Promise setForceFocus: (enabled: boolean) => Promise recordFatalRendererError: (error: FatalRendererError) => Promise + setNativeTranslations: (bundle: DesktopNativeBundle) => Promise } diff --git a/packages/desktop/src/renderer/i18n/index.ts b/packages/desktop/src/renderer/i18n/index.ts index 79cd8978f0a9..e9ffa1e4c9db 100644 --- a/packages/desktop/src/renderer/i18n/index.ts +++ b/packages/desktop/src/renderer/i18n/index.ts @@ -89,7 +89,7 @@ export type Locale = | "th" type RawDictionary = typeof appEn & typeof desktopEn -type Dictionary = i18n.Flatten +type Dictionary = Record, string> const LOCALES: readonly Locale[] = [ "en", diff --git a/packages/desktop/src/renderer/index.tsx b/packages/desktop/src/renderer/index.tsx index e1ff8fbfc2e2..b5c0254c0e40 100644 --- a/packages/desktop/src/renderer/index.tsx +++ b/packages/desktop/src/renderer/index.tsx @@ -13,6 +13,7 @@ import { ServerConnection, useCommand, useWslServers, + useLanguage, } from "@opencode-ai/app" import type { UpdaterState } from "@opencode-ai/app/updater" import * as Sentry from "@sentry/solid" @@ -21,7 +22,7 @@ import { createMemoryHistory, MemoryRouter, type BaseRouterProps } from "@solidj import { createEffect, createMemo, createResource, createSignal, onCleanup, Show } from "solid-js" import { render } from "solid-js/web" import pkg from "../../package.json" -import { initI18n, t } from "./i18n" +import { t } from "./i18n" import { initializationData } from "./initialization" import { DesktopFirstLaunchOnboarding } from "./onboarding" import { resetZoom, setPinchZoomEnabled, webviewZoom, zoomIn, zoomOut } from "./webview-zoom" @@ -59,8 +60,6 @@ if (import.meta.env.VITE_SENTRY_DSN) { }) } -void initI18n() - const [updaterState, setUpdaterState] = createSignal({ status: "disabled" }) void window.api.updater.subscribe(setUpdaterState) @@ -175,14 +174,14 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { async openDirectoryPickerDialog(opts) { return window.api.openDirectoryPicker({ multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFolder"), + title: opts?.title, }) }, async openAttachmentPickerDialog(opts, onFile) { const result = await window.api.openFilePicker({ multiple: opts?.multiple ?? false, - title: opts?.title ?? t("desktop.dialog.chooseFile"), + title: opts?.title, defaultPath: opts?.defaultPath, extensions: opts?.extensions ?? ACCEPTED_FILE_EXTENSIONS, }) @@ -204,7 +203,7 @@ const createPlatform = (windowState: DesktopWindowState): Platform => { async saveFilePickerDialog(opts) { return window.api.saveFilePicker({ - title: opts?.title ?? t("desktop.dialog.saveFile"), + title: opts?.title, defaultPath: opts?.defaultPath, }) }, @@ -376,6 +375,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { function App() { const wslServers = useWslServers() + const language = useLanguage() const ready = createMemo( () => !defaultServer.loading && !sidecar.loading && !locale.loading && !wslServers.isLoading, ) @@ -384,7 +384,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { const list: ServerConnection.Any[] = [] if (data) { list.push({ - displayName: "Local Server", + displayName: language.t("desktop.server.local"), type: "sidecar", variant: "base", http: { @@ -394,7 +394,7 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { }, }) } - list.push(...readyWslConnections(wslServers.data)) + list.push(...readyWslConnections(wslServers.data, language.t("wsl.server.label"))) return list }) const effectiveDefaultServer = createMemo(() => @@ -426,7 +426,10 @@ function DesktopRoot(props: { windowState: DesktopWindowState }) { return ( - + void window.api.setNativeTranslations(bundle).catch(() => undefined)} + > {(_) => } diff --git a/packages/desktop/src/renderer/wsl/connections.test.ts b/packages/desktop/src/renderer/wsl/connections.test.ts index 341241a6b6dd..057ed51e1d2e 100644 --- a/packages/desktop/src/renderer/wsl/connections.test.ts +++ b/packages/desktop/src/renderer/wsl/connections.test.ts @@ -34,6 +34,10 @@ describe("WSL desktop connections", () => { ]) }) + test("uses the renderer translation for the WSL connection label", () => { + expect(readyWslConnections(state("ready"), "Translated WSL")[0]?.label).toBe("Translated WSL") + }) + test("does not block desktop startup on a configured WSL default", () => { const key = "wsl:Debian" expect(availableStartupServer(key, undefined)).toBe("sidecar") diff --git a/packages/desktop/src/renderer/wsl/connections.ts b/packages/desktop/src/renderer/wsl/connections.ts index 177624753432..f5ef6a279f5f 100644 --- a/packages/desktop/src/renderer/wsl/connections.ts +++ b/packages/desktop/src/renderer/wsl/connections.ts @@ -1,12 +1,12 @@ import type { WslServersState } from "@opencode-ai/app/wsl/types" -export function readyWslConnections(state?: WslServersState) { +export function readyWslConnections(state?: WslServersState, label = "WSL") { return (state?.servers ?? []).flatMap((item) => { if (item.runtime.kind !== "ready") return [] return [ { displayName: item.config.distro, - label: "WSL", + label, type: "sidecar" as const, variant: "wsl" as const, distro: item.config.distro, diff --git a/packages/enterprise/src/app.tsx b/packages/enterprise/src/app.tsx index 68737ed273c3..68510109ee03 100644 --- a/packages/enterprise/src/app.tsx +++ b/packages/enterprise/src/app.tsx @@ -4,13 +4,13 @@ import { Font } from "@opencode-ai/ui/font" import { MetaProvider } from "@solidjs/meta" import { MarkedProvider } from "@opencode-ai/ui/context/marked" import { DialogProvider } from "@opencode-ai/ui/context/dialog" +import { I18nProvider } from "@opencode-ai/ui/context" import { - I18nProvider, pluralCategory, pluralKey, type UiI18nParams, type UiI18nPluralKey, -} from "@opencode-ai/ui/context" +} from "@opencode-ai/ui/context/i18n" import { dict as uiEn } from "@opencode-ai/ui/i18n/en" import { dict as uiZh } from "@opencode-ai/ui/i18n/zh" import { createEffect, createMemo, Suspense, type ParentProps } from "solid-js" diff --git a/packages/session-ui/AGENTS.md b/packages/session-ui/AGENTS.md new file mode 100644 index 000000000000..678ae1bf90d8 --- /dev/null +++ b/packages/session-ui/AGENTS.md @@ -0,0 +1,7 @@ +## Localization + +- NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, empty states, and displayed errors. +- When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. +- NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. +- Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. +- Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. diff --git a/packages/session-ui/src/components/message-file.test.ts b/packages/session-ui/src/components/message-file.test.ts index a769ae01bb7b..8b6872381ed6 100644 --- a/packages/session-ui/src/components/message-file.test.ts +++ b/packages/session-ui/src/components/message-file.test.ts @@ -52,12 +52,12 @@ describe("message-file", () => { }) test("labels attachment types from the basename extension", () => { - expect(typeLabel("list.md", "text/plain")).toBe("Markdown") - expect(typeLabel("/repo/src/main.ts", "text/plain")).toBe("TypeScript") - expect(typeLabel("/tmp/report.pdf", "application/pdf")).toBe("PDF") - expect(typeLabel("notes.xyz", "text/plain")).toBe("XYZ") - expect(typeLabel("/home/user/my.project/Makefile", "text/plain")).toBe("File") - expect(typeLabel(".gitignore", "text/plain")).toBe("File") - expect(typeLabel("/repo/.env", "text/plain")).toBe("File") + expect(typeLabel("list.md", "text/plain", "File")).toBe("Markdown") + expect(typeLabel("/repo/src/main.ts", "text/plain", "File")).toBe("TypeScript") + expect(typeLabel("/tmp/report.pdf", "application/pdf", "File")).toBe("PDF") + expect(typeLabel("notes.xyz", "text/plain", "File")).toBe("XYZ") + expect(typeLabel("/home/user/my.project/Makefile", "text/plain", "File")).toBe("File") + expect(typeLabel(".gitignore", "text/plain", "File")).toBe("File") + expect(typeLabel("/repo/.env", "text/plain", "File")).toBe("File") }) }) diff --git a/packages/session-ui/src/components/message-file.ts b/packages/session-ui/src/components/message-file.ts index e09269f33f02..0b0a6fc3b5fa 100644 --- a/packages/session-ui/src/components/message-file.ts +++ b/packages/session-ui/src/components/message-file.ts @@ -23,12 +23,12 @@ const LANGUAGE_NAMES = new Map( // attachments carry text/plain for all text files, so the label comes from the extension; // filename may be an absolute path, so extract the basename before looking for one -export function typeLabel(filename: string, mime: string) { +export function typeLabel(filename: string, mime: string, fallback: string) { if (mime === "application/pdf") return "PDF" const base = getFilename(filename) // idx 0 is a dotfile like .gitignore, not an extension const idx = base.lastIndexOf(".") const suffix = idx <= 0 ? "" : base.slice(idx + 1).toLowerCase() - if (!suffix) return "File" + if (!suffix) return fallback return LANGUAGE_NAMES.get(suffix) ?? suffix.toUpperCase() } diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index 5760e0e5717a..71ba48c982d4 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -459,10 +459,10 @@ function newLayout() { return typeof document !== "undefined" && document.body.hasAttribute("data-new-layout") } -function webSearchProviderLabel(provider: unknown) { - if (provider === "parallel") return "Parallel Web Search" - if (provider === "exa") return "Exa Web Search" - return "Web Search" +function webSearchProviderLabel(provider: unknown, i18n: ReturnType) { + const name = provider === "parallel" ? "Parallel" : provider === "exa" ? "Exa" : undefined + if (name) return i18n.t("ui.tool.websearch.provider", { provider: name }) + return i18n.t("ui.tool.websearch") } export function getToolInfo( @@ -505,7 +505,7 @@ export function getToolInfo( case "websearch": return { icon: "window-cursor", - title: webSearchProviderLabel(metadata?.provider), + title: webSearchProviderLabel(metadata?.provider, i18n), subtitle: input.query, } case "task": { @@ -1309,7 +1309,7 @@ export function UserMessageDisplay(props: { clickable={!!props.actions?.openAttachment} onClick={() => props.actions?.openAttachment?.(file)} > - {typeLabel(name, file.mime)} + {typeLabel(name, file.mime, i18n.t("ui.common.file"))} ) @@ -1594,7 +1594,11 @@ PART_MAPPING["tool"] = function ToolPartDisplay(props) { { const value = props.input.query if (typeof value !== "string") return "" return value }) - const title = createMemo(() => webSearchProviderLabel(props.metadata.provider)) + const title = createMemo(() => webSearchProviderLabel(props.metadata.provider, i18n)) return (

    - Drop files to attach + {i18n.t("ui.promptInput.dropFiles")}
    @@ -133,7 +135,7 @@ export function PromptInputV2(props: PromptInputV2Props) { attachments={props.controller.attachments()} comments={props.controller.comments()} activeCommentID={state.activeContextID} - removeLabel="Remove attachment" + removeLabel={i18n.t("ui.promptInput.removeAttachment")} onAttachmentClick={props.controller.openAttachment} onAttachmentRemove={(attachment) => props.controller.removeAttachment(attachment.id)} onCommentClick={(comment) => props.controller.toggleContext(comment.key)} @@ -151,7 +153,7 @@ export function PromptInputV2(props: PromptInputV2Props) { data-component="prompt-input" role="textbox" aria-multiline="true" - aria-label="Prompt" + aria-label={i18n.t("ui.promptInput.label")} contenteditable={!props.disabled && !props.readOnly} autocapitalize={state.mode === "normal" ? "sentences" : "off"} autocorrect={state.mode === "normal" ? "on" : "off"} @@ -186,7 +188,11 @@ export function PromptInputV2(props: PromptInputV2Props) { classList={{ "font-mono!": state.mode === "shell" }} > {view.placeholder?.() ?? - (state.mode === "shell" ? "Enter shell command..." : "Ask anything, / for commands, @ for context...")} + (state.mode === "shell" ? ( + i18n.t("ui.promptInput.placeholder.shell") + ) : ( + i18n.t("ui.promptInput.placeholder.normal", { slash: "/", at: "@" }) + ))}
    @@ -200,13 +206,13 @@ export function PromptInputV2(props: PromptInputV2Props) { > {(control) => ( - + )} {(control) => ( ( 1}> @@ -250,8 +260,8 @@ export function PromptInputV2(props: PromptInputV2Props) { mode={state.mode} stopping={view.submit.stopping()} disabled={!props.controller.canSubmit()} - sendLabel="Send" - stopLabel="Stop" + sendLabel={i18n.t("ui.promptInput.send")} + stopLabel={i18n.t("ui.promptInput.stop")} onSubmit={props.controller.submit} onStop={props.controller.stop} /> @@ -377,6 +387,7 @@ export function PromptInputV2Attachments(props: { onCommentClick?: (comment: PromptInputV2Comment) => void onCommentRemove?: (comment: PromptInputV2Comment) => void }) { + const i18n = useI18n() return ( 0 || (props.comments?.length ?? 0) > 0}>
    @@ -404,7 +415,7 @@ export function PromptInputV2Attachments(props: {
    @@ -571,7 +582,7 @@ export function PromptInputV2Select(props: { {props.options.find((option) => option.id === props.current)?.label ?? props.current} - + @@ -637,7 +648,7 @@ export function PromptInputV2Popover(props: {
    diff --git a/packages/ui/src/i18n/fr.ts b/packages/ui/src/i18n/fr.ts index e998e14340cc..5e9a54708f31 100644 --- a/packages/ui/src/i18n/fr.ts +++ b/packages/ui/src/i18n/fr.ts @@ -126,7 +126,8 @@ export const dict = { "ui.promptInput.removeAttachment": "Supprimer la pièce jointe", "ui.promptInput.label": "Invite", "ui.promptInput.placeholder.shell": "Entrez une commande shell...", - "ui.promptInput.placeholder.normal": "Demandez n'importe quoi, {{slash}} pour les commandes, {{at}} pour le contexte...", + "ui.promptInput.placeholder.normal": + "Demandez n'importe quoi, {{slash}} pour les commandes, {{at}} pour le contexte...", "ui.promptInput.add": "Ajouter des images et des fichiers", "ui.promptInput.attachments": "Images et fichiers", "ui.promptInput.context": "Contexte", diff --git a/packages/ui/src/i18n/pl.ts b/packages/ui/src/i18n/pl.ts index 867e6045df77..680eadf7b9f2 100644 --- a/packages/ui/src/i18n/pl.ts +++ b/packages/ui/src/i18n/pl.ts @@ -128,7 +128,8 @@ export const dict = { "ui.promptInput.removeAttachment": "Usuń załącznik", "ui.promptInput.label": "Prompt", "ui.promptInput.placeholder.shell": "Wpisz polecenie powłoki...", - "ui.promptInput.placeholder.normal": "Zapytaj o cokolwiek, {{slash}} aby wyświetlić polecenia, {{at}} aby wyświetlić kontekst...", + "ui.promptInput.placeholder.normal": + "Zapytaj o cokolwiek, {{slash}} aby wyświetlić polecenia, {{at}} aby wyświetlić kontekst...", "ui.promptInput.add": "Dodaj obrazy i pliki", "ui.promptInput.attachments": "Obrazy i pliki", "ui.promptInput.context": "Kontekst", From c75e3b5bae57206eac3f3e3986568d7f7a87acb5 Mon Sep 17 00:00:00 2001 From: Brendan Allan <14191578+Brendonovich@users.noreply.github.com> Date: Tue, 4 Aug 2026 15:09:29 +0800 Subject: [PATCH 210/333] fix(app): supply fallback session titles (#40385) --- packages/app/src/utils/session-title.ts | 12 ++++++++++++ packages/app/src/utils/session.test.ts | 20 ++++++++++++++++++++ packages/app/src/utils/session.ts | 3 ++- 3 files changed, 34 insertions(+), 1 deletion(-) diff --git a/packages/app/src/utils/session-title.ts b/packages/app/src/utils/session-title.ts index ca04c010470c..9d4c544b990c 100644 --- a/packages/app/src/utils/session-title.ts +++ b/packages/app/src/utils/session-title.ts @@ -1,5 +1,17 @@ const pattern = /^(New session|Child session) - \d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/ +interface Info { + readonly title?: string + readonly parentID?: string + readonly time: { + readonly created: number + } +} + +export function withTimestampedFallback(info: Info) { + return info.title ?? `${info.parentID ? "Child" : "New"} session - ${new Date(info.time.created).toISOString()}` +} + export function sessionTitle(title?: string) { if (!title) return title const match = title.match(pattern) diff --git a/packages/app/src/utils/session.test.ts b/packages/app/src/utils/session.test.ts index b15c23b66048..836a990a5653 100644 --- a/packages/app/src/utils/session.test.ts +++ b/packages/app/src/utils/session.test.ts @@ -36,6 +36,14 @@ describe("normalizeSessionInfo", () => { revert: { messageID: "message-1", partID: "part-1", snapshot: "snapshot" }, }) }) + + test("supplies timestamped titles for untitled current sessions", () => { + const root = currentSession("session-1") + const child = currentSession("session-2", "session-1") + + expect(normalizeSessionInfo(root).title).toBe("New session - 1970-01-01T00:00:00.000Z") + expect(normalizeSessionInfo(child).title).toBe("Child session - 1970-01-01T00:00:00.000Z") + }) }) describe("listAllSessions", () => { @@ -92,3 +100,15 @@ function sessionInfo(id: string, archived = false) { location: { directory: "/repo" }, } as SessionInfo } + +function currentSession(id: string, parentID?: string) { + return { + id, + parentID, + projectID: "project-1", + cost: 0, + tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } }, + time: { created: 0, updated: 0 }, + location: { directory: "/repo" }, + } as SessionInfo +} diff --git a/packages/app/src/utils/session.ts b/packages/app/src/utils/session.ts index faf847967bdd..976ecb9bd67d 100644 --- a/packages/app/src/utils/session.ts +++ b/packages/app/src/utils/session.ts @@ -1,5 +1,6 @@ import type { SessionApi, SessionInfo, SessionListInput } from "@opencode-ai/client/promise" import type { Session } from "@opencode-ai/sdk/v2/client" +import { withTimestampedFallback } from "./session-title" export function normalizeSessionInfo(input: SessionInfo | Session): Session { if (!("location" in input)) return input @@ -13,7 +14,7 @@ export function normalizeSessionInfo(input: SessionInfo | Session): Session { parentID: input.parentID, cost: input.cost, tokens: input.tokens, - title: input.title, + title: withTimestampedFallback(input), agent: input.agent, model: input.model, version: "", From 8721a2710b37290041b17476e8c1f8bfcd49cbc9 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 07:38:56 +0000 Subject: [PATCH 211/333] fix(app): complete shared RTL primitives (#40388) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- packages/app/src/components/help-button.tsx | 17 +++++++++++------ packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/components/ui/drawer.tsx | 2 +- packages/ui/src/components/icon.css | 5 +++++ packages/ui/src/components/icon.tsx | 6 +++++- packages/ui/src/components/toast.css | 2 +- packages/ui/src/context/i18n.tsx | 11 +++++++++-- packages/ui/src/v2/components/toast-v2.tsx | 5 +++-- 8 files changed, 36 insertions(+), 14 deletions(-) diff --git a/packages/app/src/components/help-button.tsx b/packages/app/src/components/help-button.tsx index 4926a4ef81ff..e12b9e2145eb 100644 --- a/packages/app/src/components/help-button.tsx +++ b/packages/app/src/components/help-button.tsx @@ -1,5 +1,6 @@ import { Icon as IconV2 } from "@opencode-ai/ui/v2/icon" import { IconButtonV2 } from "@opencode-ai/ui/v2/icon-button-v2" +import { isRTL } from "@kobalte/core/i18n" import { createSignal, Show } from "solid-js" import { Drawer, DrawerClose, DrawerContent } from "@/components/ui/drawer" import { usePlatform } from "@/context/platform" @@ -19,18 +20,19 @@ export function TabsInfoPopup() { const language = useLanguage() const [drawerOpen, setDrawerOpen] = createSignal(false) const windows = () => platform.platform === "desktop" && platform.os === "windows" + const rtl = () => isRTL(language.intl()) return ( - +
    ) if (props.inline) { return ( - + {content()} ) } return ( - + {content()} ) @@ -475,7 +479,7 @@ export function DebugBar(props: { inline?: boolean } = {}) { "gap-[9px]": !!props.inline, "gap-px": !props.inline, "flex w-full flex-nowrap items-center justify-start": !!props.inline, - "grid-cols-5": !props.inline, + "grid-cols-4": !props.inline, grid: !props.inline, }} > @@ -557,10 +561,25 @@ export function DebugBar(props: { inline?: boolean } = {}) { bad={bad(heap(), 0.8)} dim={state.heap.used === undefined} inline={props.inline} - wide={!platform.setForceFocus} + span={platform.setForceFocus ? 2 : 3} + /> + language.setDirection(language.direction() === "rtl" ? "ltr" : "rtl")} /> {platform.setForceFocus && ( - void toggleFocus()} /> + void toggleFocus()} + /> )}
    diff --git a/packages/app/src/context/language.tsx b/packages/app/src/context/language.tsx index adab9ddb72fa..8772fd3111a9 100644 --- a/packages/app/src/context/language.tsx +++ b/packages/app/src/context/language.tsx @@ -15,9 +15,14 @@ import { } from "@/i18n/desktop-native" export type Locale = DesktopNativeLocale +export type Direction = "ltr" | "rtl" const RTL_LOCALES: ReadonlySet = new Set(["ar", "ur", "pa"]) +function localeDirection(locale: Locale): Direction { + return RTL_LOCALES.has(locale) ? "rtl" : "ltr" +} + type RawDictionary = typeof en & typeof uiEn type Dictionary = i18n.Flatten type PluralKey = @@ -242,6 +247,13 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont const locale = createMemo(() => normalizeLocale(store.locale)) const intl = createMemo(() => INTL[locale()]) + const [layout, setLayout] = createStore({ direction: undefined as Direction | undefined }) + const direction = createMemo(() => layout.direction ?? localeDirection(locale())) + const layoutLocale = createMemo(() => { + if (!layout.direction) return intl() + // Kobalte derives menu direction from locale rather than accepting a direction override. + return layout.direction === "rtl" ? "ar" : "en" + }) const [dict] = createResource(locale, loadDict, { initialValue: dicts.get(initial) ?? base, @@ -270,7 +282,7 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont if (typeof document !== "object") return const value = locale() document.documentElement.lang = value - document.documentElement.dir = RTL_LOCALES.has(value) ? "rtl" : "ltr" + document.documentElement.dir = direction() document.cookie = cookie(value) }) @@ -287,6 +299,8 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont ready, locale, intl, + direction, + layoutLocale, locales: LOCALES, label, t, @@ -294,6 +308,9 @@ export const { use: useLanguage, provider: LanguageProvider } = createSimpleCont setLocale(next: Locale) { setStore("locale", normalizeLocale(next)) }, + setDirection(next: Direction) { + setLayout("direction", next === localeDirection(locale()) ? undefined : next) + }, } }, }) diff --git a/packages/app/src/i18n/ar.ts b/packages/app/src/i18n/ar.ts index 1d2e2ee80f66..f916bb487ae1 100644 --- a/packages/app/src/i18n/ar.ts +++ b/packages/app/src/i18n/ar.ts @@ -1124,6 +1124,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "كومة JS المستخدمة مقابل حد الكومة. Chromium فقط.", "debugBar.mem.tip": "كومة JS المستخدمة مقابل حد الكومة. {{used}} من {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "فرض أنماط التركيز على جميع العناصر التفاعلية", + "debugBar.focus.on": "تشغيل", + "debugBar.focus.off": "إيقاف", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "فرض اتجاه التخطيط على التطبيق بالكامل دون تغيير اللغة المحددة", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/az.ts b/packages/app/src/i18n/az.ts index 66f4b72ab001..df3506c3d551 100644 --- a/packages/app/src/i18n/az.ts +++ b/packages/app/src/i18n/az.ts @@ -896,6 +896,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "İstifadə olunmuş JS yığını və yığın limiti. Yalnız Chromium.", "debugBar.mem.tip": "İstifadə olunmuş JS yığını və yığın limiti. {{used}} və {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Bütün interaktiv elementlərə fokus üslublarını məcburi tətbiq et", + "debugBar.focus.on": "AÇIQ", + "debugBar.focus.off": "QAPALI", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Seçilmiş dili dəyişmədən bütün tətbiq üçün tərtibat istiqamətini məcburi təyin et", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Masaüstü", "settings.section.server": "Server", diff --git a/packages/app/src/i18n/br.ts b/packages/app/src/i18n/br.ts index 19cb9a4cab4f..90acd2603707 100644 --- a/packages/app/src/i18n/br.ts +++ b/packages/app/src/i18n/br.ts @@ -1134,6 +1134,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Heap JS usado vs limite de heap. Apenas Chromium.", "debugBar.mem.tip": "Heap JS usado vs limite de heap. {{used}} de {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Forçar estilos de foco em todos os elementos interativos", + "debugBar.focus.on": "LIGADO", + "debugBar.focus.off": "DESLIGADO", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Forçar a direção do layout de todo o aplicativo sem alterar o idioma selecionado", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/bs.ts b/packages/app/src/i18n/bs.ts index 207acfc96094..6be962720b8d 100644 --- a/packages/app/src/i18n/bs.ts +++ b/packages/app/src/i18n/bs.ts @@ -1210,6 +1210,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Korišteni JS heap naspram limita heapa. Samo Chromium.", "debugBar.mem.tip": "Korišteni JS heap naspram limita heapa. {{used}} od {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Prisilno primijeni stilove fokusa na sve interaktivne elemente", + "debugBar.focus.on": "UKLJUČENO", + "debugBar.focus.off": "ISKLJUČENO", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Prisilno postavi smjer rasporeda cijele aplikacije bez promjene odabranog jezika", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/da.ts b/packages/app/src/i18n/da.ts index f822b6d02e7e..8221b31af8ea 100644 --- a/packages/app/src/i18n/da.ts +++ b/packages/app/src/i18n/da.ts @@ -1084,6 +1084,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Brugt JS-heap sammenholdt med heapgrænsen. Kun Chromium.", "debugBar.mem.tip": "Brugt JS-heap sammenholdt med heapgrænsen. {{used}} af {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Gennemtving fokusstile for alle interaktive elementer", + "debugBar.focus.on": "TIL", + "debugBar.focus.off": "FRA", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Gennemtving layoutretningen for hele appen uden at ændre det valgte sprog", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/de.ts b/packages/app/src/i18n/de.ts index 359d6ff318f8..60be89a70a97 100644 --- a/packages/app/src/i18n/de.ts +++ b/packages/app/src/i18n/de.ts @@ -1029,6 +1029,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Belegter JS-Heap im Verhältnis zum Heap-Limit. Nur Chromium.", "debugBar.mem.tip": "Belegter JS-Heap im Verhältnis zum Heap-Limit. {{used}} von {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Fokusstile für alle interaktiven Elemente erzwingen", + "debugBar.focus.on": "EIN", + "debugBar.focus.off": "AUS", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Layoutrichtung für die gesamte App erzwingen, ohne die ausgewählte Sprache zu ändern", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Strg", "common.key.alt": "Alt", "common.key.shift": "Umschalt", diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index 5e24547d74ff..c37c9c21161a 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -868,6 +868,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Used JS heap vs heap limit. Chromium only.", "debugBar.mem.tip": "Used JS heap vs heap limit. {{used}} of {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Force focus styles on all interactive elements", + "debugBar.focus.on": "ON", + "debugBar.focus.off": "OFF", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Force the full app layout direction without changing the selected language", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", diff --git a/packages/app/src/i18n/es.ts b/packages/app/src/i18n/es.ts index 37d79400639e..ef728ec08491 100644 --- a/packages/app/src/i18n/es.ts +++ b/packages/app/src/i18n/es.ts @@ -1218,6 +1218,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Heap JS usado vs límite de heap. Solo Chromium.", "debugBar.mem.tip": "Heap JS usado vs límite de heap. {{used}} de {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Forzar los estilos de foco en todos los elementos interactivos", + "debugBar.focus.on": "SÍ", + "debugBar.focus.off": "NO", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Forzar la dirección del diseño de toda la aplicación sin cambiar el idioma seleccionado", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Mayús", diff --git a/packages/app/src/i18n/fi.ts b/packages/app/src/i18n/fi.ts index 90e14265e46b..7955cda59a66 100644 --- a/packages/app/src/i18n/fi.ts +++ b/packages/app/src/i18n/fi.ts @@ -786,6 +786,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Käytetty JS-keko suhteessa keon rajaan. Vain Chromium.", "debugBar.mem.tip": "Käytetty JS-keko suhteessa keon rajaan. {{used}} / {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Pakota kohdistustyylit kaikkiin vuorovaikutteisiin elementteihin", + "debugBar.focus.on": "KÄYTÖSSÄ", + "debugBar.focus.off": "EI KÄYTÖSSÄ", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Pakota koko sovelluksen asettelun suunta valittua kieltä muuttamatta", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Työpöytä", "settings.section.server": "Palvelin", diff --git a/packages/app/src/i18n/fr.ts b/packages/app/src/i18n/fr.ts index e6347550fca9..c628ec2f79fd 100644 --- a/packages/app/src/i18n/fr.ts +++ b/packages/app/src/i18n/fr.ts @@ -1145,6 +1145,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Tas JS utilisé vs limite de tas. Chromium uniquement.", "debugBar.mem.tip": "Tas JS utilisé vs limite de tas. {{used}} sur {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Forcer les styles de focus sur tous les éléments interactifs", + "debugBar.focus.on": "ACTIVÉ", + "debugBar.focus.off": "DÉSACTIVÉ", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Forcer le sens de disposition de toute l’application sans changer la langue sélectionnée", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Maj", diff --git a/packages/app/src/i18n/hi.ts b/packages/app/src/i18n/hi.ts index 9def5f9121da..a704e6233ef2 100644 --- a/packages/app/src/i18n/hi.ts +++ b/packages/app/src/i18n/hi.ts @@ -895,6 +895,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "उपयोग किया गया JS हीप बनाम हीप सीमा। केवल Chromium।", "debugBar.mem.tip": "उपयोग किया गया JS हीप बनाम हीप सीमा। {{limit}} में से {{used}}।", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "सभी इंटरैक्टिव एलिमेंट पर फ़ोकस स्टाइल ज़बरदस्ती लागू करें", + "debugBar.focus.on": "चालू", + "debugBar.focus.off": "बंद", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "चुनी गई भाषा बदले बिना पूरे ऐप की लेआउट दिशा ज़बरदस्ती सेट करें", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "डेस्कटॉप", "settings.section.server": "सर्वर", diff --git a/packages/app/src/i18n/id.ts b/packages/app/src/i18n/id.ts index 76b117a7f542..e693869089ef 100644 --- a/packages/app/src/i18n/id.ts +++ b/packages/app/src/i18n/id.ts @@ -963,6 +963,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Heap JS terpakai vs batas heap. Khusus Chromium.", "debugBar.mem.tip": "Heap JS terpakai vs batas heap. {{used}} dari {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Paksa gaya fokus pada semua elemen interaktif", + "debugBar.focus.on": "AKTIF", + "debugBar.focus.off": "NONAKTIF", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Paksa arah tata letak seluruh aplikasi tanpa mengubah bahasa yang dipilih", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", diff --git a/packages/app/src/i18n/it.ts b/packages/app/src/i18n/it.ts index 56397ce62945..3f4089018dd1 100644 --- a/packages/app/src/i18n/it.ts +++ b/packages/app/src/i18n/it.ts @@ -807,6 +807,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Heap JS utilizzato rispetto al limite dell'heap. Solo Chromium.", "debugBar.mem.tip": "Heap JS utilizzato rispetto al limite dell'heap. {{used}} di {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Forza gli stili di focus su tutti gli elementi interattivi", + "debugBar.focus.on": "ON", + "debugBar.focus.off": "OFF", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Forza la direzione del layout dell’intera app senza cambiare la lingua selezionata", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", diff --git a/packages/app/src/i18n/ja.ts b/packages/app/src/i18n/ja.ts index 2178234b174c..37f6d5c5f69d 100644 --- a/packages/app/src/i18n/ja.ts +++ b/packages/app/src/i18n/ja.ts @@ -1116,6 +1116,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "使用中の JS ヒープ対ヒープ制限。Chromium のみ。", "debugBar.mem.tip": "使用中の JS ヒープ対ヒープ制限。{{limit}} 中 {{used}}。", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "すべてのインタラクティブ要素にフォーカススタイルを強制適用", + "debugBar.focus.on": "オン", + "debugBar.focus.off": "オフ", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "選択中の言語を変更せずに、アプリ全体のレイアウト方向を強制設定", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/ko.ts b/packages/app/src/i18n/ko.ts index 7f950ede61b8..9d3821660cb1 100644 --- a/packages/app/src/i18n/ko.ts +++ b/packages/app/src/i18n/ko.ts @@ -840,6 +840,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "사용된 JS 힙 대 힙 제한. Chromium 전용.", "debugBar.mem.tip": "사용된 JS 힙 대 힙 제한. {{limit}} 중 {{used}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "모든 대화형 요소에 포커스 스타일 강제 적용", + "debugBar.focus.on": "켬", + "debugBar.focus.off": "끔", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "선택한 언어를 변경하지 않고 전체 앱의 레이아웃 방향 강제 설정", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/nl.ts b/packages/app/src/i18n/nl.ts index ffdfdd9cfb54..7ac999c7a795 100644 --- a/packages/app/src/i18n/nl.ts +++ b/packages/app/src/i18n/nl.ts @@ -896,6 +896,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Gebruikte JS-heap versus heap-limiet. Alleen Chromium.", "debugBar.mem.tip": "Gebruikte JS-heap versus heaplimiet. {{used}} van {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Dwing focusstijlen af voor alle interactieve elementen", + "debugBar.focus.on": "AAN", + "debugBar.focus.off": "UIT", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Dwing de lay-outrichting voor de hele app af zonder de geselecteerde taal te wijzigen", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Server", diff --git a/packages/app/src/i18n/no.ts b/packages/app/src/i18n/no.ts index f2048f3bd88b..7ff1ac5dc1da 100644 --- a/packages/app/src/i18n/no.ts +++ b/packages/app/src/i18n/no.ts @@ -1037,6 +1037,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Brukt JS-heap kontra heap-grense. Kun Chromium.", "debugBar.mem.tip": "Brukt JS-heap kontra heap-grense. {{used}} av {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Tving fokusstiler på alle interaktive elementer", + "debugBar.focus.on": "PÅ", + "debugBar.focus.off": "AV", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Tving layoutretningen for hele appen uten å endre valgt språk", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/pa.ts b/packages/app/src/i18n/pa.ts index 4c09514872d9..4e0ff16f87e6 100644 --- a/packages/app/src/i18n/pa.ts +++ b/packages/app/src/i18n/pa.ts @@ -893,6 +893,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "جے ایس ہیپ بمقابلہ ہیپ لیمٹ استعمال کیتا گیا۔ صرف Chromium۔", "debugBar.mem.tip": "ورتیا JS ہیپ تے ہیپ دی حد۔ {{limit}} وچوں {{used}}۔", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "سارے انٹرایکٹو عناصر اُتے فوکس سٹائل لازمی لاگو کرو", + "debugBar.focus.on": "چالو", + "debugBar.focus.off": "بند", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "چُنی ہوئی زبان نوں بدلے بغیر پوری ایپ دے لے آؤٹ دی سمت لازمی مقرر کرو", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode ڈیسک ٹاپ", "settings.section.desktop": "ڈیسک ٹاپ", "settings.section.server": "سرور", diff --git a/packages/app/src/i18n/pl.ts b/packages/app/src/i18n/pl.ts index 843a54000614..17c9fb639fb3 100644 --- a/packages/app/src/i18n/pl.ts +++ b/packages/app/src/i18n/pl.ts @@ -1137,6 +1137,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Wykorzystana pamięć sterty JS względem jej limitu. Tylko Chromium.", "debugBar.mem.tip": "Wykorzystana pamięć sterty JS względem jej limitu: {{used}} z {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Wymuś style fokusu dla wszystkich elementów interaktywnych", + "debugBar.focus.on": "WŁ.", + "debugBar.focus.off": "WYŁ.", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Wymuś kierunek układu całej aplikacji bez zmiany wybranego języka", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/ru.ts b/packages/app/src/i18n/ru.ts index 5a113e100f53..6d119926148c 100644 --- a/packages/app/src/i18n/ru.ts +++ b/packages/app/src/i18n/ru.ts @@ -1215,6 +1215,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Используемая куча JS по сравнению с лимитом кучи. Только Chromium.", "debugBar.mem.tip": "Используемая куча JS по сравнению с лимитом кучи. {{used}} из {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Принудительно применить стили фокуса ко всем интерактивным элементам", + "debugBar.focus.on": "ВКЛ.", + "debugBar.focus.off": "ВЫКЛ.", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Принудительно задать направление макета всего приложения, не меняя выбранный язык", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/sv.ts b/packages/app/src/i18n/sv.ts index 401c47bb4929..ab5007238b6d 100644 --- a/packages/app/src/i18n/sv.ts +++ b/packages/app/src/i18n/sv.ts @@ -892,6 +892,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Använd JS-heap jämfört med heapgränsen. Endast Chromium.", "debugBar.mem.tip": "Använd JS-heap jämfört med heapgränsen. {{used}} av {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Tvinga fram fokusstilar på alla interaktiva element", + "debugBar.focus.on": "PÅ", + "debugBar.focus.off": "AV", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Tvinga fram hela appens layoutriktning utan att ändra valt språk", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Skrivbord", "settings.section.server": "Server", diff --git a/packages/app/src/i18n/th.ts b/packages/app/src/i18n/th.ts index b6d5f6ff11ca..e87d0db164d3 100644 --- a/packages/app/src/i18n/th.ts +++ b/packages/app/src/i18n/th.ts @@ -1191,6 +1191,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "JS heap ที่ใช้เทียบกับขีดจำกัด heap เฉพาะ Chromium", "debugBar.mem.tip": "JS heap ที่ใช้เทียบกับขีดจำกัด heap {{used}} จาก {{limit}}", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "บังคับใช้สไตล์โฟกัสกับองค์ประกอบแบบโต้ตอบทั้งหมด", + "debugBar.focus.on": "เปิด", + "debugBar.focus.off": "ปิด", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "บังคับกำหนดทิศทางเค้าโครงของทั้งแอปโดยไม่เปลี่ยนภาษาที่เลือก", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/tr.ts b/packages/app/src/i18n/tr.ts index a60f72507744..d0d3a4950343 100644 --- a/packages/app/src/i18n/tr.ts +++ b/packages/app/src/i18n/tr.ts @@ -1213,6 +1213,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Kullanılan JS yığınının yığın sınırına oranı. Yalnızca Chromium.", "debugBar.mem.tip": "Kullanılan JS yığınının yığın sınırına oranı: {{used}} / {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Tüm etkileşimli öğelere odak stillerini zorla uygula", + "debugBar.focus.on": "AÇIK", + "debugBar.focus.off": "KAPALI", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Seçili dili değiştirmeden tüm uygulamanın düzen yönünü zorla ayarla", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/uk.ts b/packages/app/src/i18n/uk.ts index 4a439e920e1d..fdeb0b83fb95 100644 --- a/packages/app/src/i18n/uk.ts +++ b/packages/app/src/i18n/uk.ts @@ -977,6 +977,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Використана купа JS проти ліміту купи. Тільки Chromium.", "debugBar.mem.tip": "Використана купа JS проти ліміту купи. {{used}} з {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Примусово застосувати стилі фокуса до всіх інтерактивних елементів", + "debugBar.focus.on": "УВІМК.", + "debugBar.focus.off": "ВИМК.", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Примусово задати напрямок макета всієї програми, не змінюючи вибрану мову", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", diff --git a/packages/app/src/i18n/ur.ts b/packages/app/src/i18n/ur.ts index 67f13676d36e..661c013ebe76 100644 --- a/packages/app/src/i18n/ur.ts +++ b/packages/app/src/i18n/ur.ts @@ -896,6 +896,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "استعمال شدہ JS ہیپ بمقابلہ ہیپ کی حد۔ صرف Chromium۔", "debugBar.mem.tip": "استعمال شدہ JS ہیپ بمقابلہ ہیپ کی حد۔ {{limit}} میں سے {{used}}۔", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "تمام تفاعلی عناصر پر فوکس اسٹائلز لازماً لاگو کریں", + "debugBar.focus.on": "چالو", + "debugBar.focus.off": "بند", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "منتخب زبان تبدیل کیے بغیر پوری ایپ کے لے آؤٹ کی سمت لازماً مقرر کریں", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode ڈیسک ٹاپ", "settings.section.desktop": "ڈیسک ٹاپ", "settings.section.server": "سرور", diff --git a/packages/app/src/i18n/vi.ts b/packages/app/src/i18n/vi.ts index e0787217d3c4..1e243ba17d6c 100644 --- a/packages/app/src/i18n/vi.ts +++ b/packages/app/src/i18n/vi.ts @@ -898,6 +898,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "Bộ nhớ heap JS đã dùng so với giới hạn heap. Chỉ hỗ trợ Chromium.", "debugBar.mem.tip": "Đã sử dụng JS heap so với giới hạn heap. {{used}} của {{limit}}.", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "Buộc áp dụng kiểu tiêu điểm cho mọi phần tử tương tác", + "debugBar.focus.on": "BẬT", + "debugBar.focus.off": "TẮT", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "Buộc đặt hướng bố cục cho toàn bộ ứng dụng mà không thay đổi ngôn ngữ đã chọn", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "app.name.desktop": "OpenCode Desktop", "settings.section.desktop": "Desktop", "settings.section.server": "Máy chủ", diff --git a/packages/app/src/i18n/zh.ts b/packages/app/src/i18n/zh.ts index 1a725433afb8..08bda71a65e5 100644 --- a/packages/app/src/i18n/zh.ts +++ b/packages/app/src/i18n/zh.ts @@ -1180,6 +1180,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "使用的 JS 堆与堆限制。仅限 Chromium。", "debugBar.mem.tip": "使用的 JS 堆与堆限制。{{used}} / {{limit}}。", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "强制所有交互元素显示焦点样式", + "debugBar.focus.on": "开", + "debugBar.focus.off": "关", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "在不更改所选语言的情况下,强制设置整个应用的布局方向", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/app/src/i18n/zht.ts b/packages/app/src/i18n/zht.ts index b1b85acd2443..78a452070a7b 100644 --- a/packages/app/src/i18n/zht.ts +++ b/packages/app/src/i18n/zht.ts @@ -1176,6 +1176,14 @@ export const dict = { "debugBar.mem.label": "MEM", "debugBar.mem.tipUnavailable": "使用的 JS 堆積與堆積限制。僅限 Chromium。", "debugBar.mem.tip": "使用的 JS 堆積與堆積限制。{{used}} / {{limit}}。", + "debugBar.focus.label": "FOCUS", + "debugBar.focus.tip": "強制所有互動元素顯示焦點樣式", + "debugBar.focus.on": "開啟", + "debugBar.focus.off": "關閉", + "debugBar.direction.label": "DIR", + "debugBar.direction.tip": "在不變更所選語言的情況下,強制設定整個應用程式的版面配置方向", + "debugBar.direction.ltr": "LTR", + "debugBar.direction.rtl": "RTL", "common.key.ctrl": "Ctrl", "common.key.alt": "Alt", "common.key.shift": "Shift", diff --git a/packages/ui/src/context/i18n.tsx b/packages/ui/src/context/i18n.tsx index e6b76ae2ec16..be7a4d4f1e95 100644 --- a/packages/ui/src/context/i18n.tsx +++ b/packages/ui/src/context/i18n.tsx @@ -18,6 +18,7 @@ export type UiI18nParams = Record export type UiI18n = { locale: Accessor + layoutLocale?: Accessor t: (key: UiI18nKey, params?: UiI18nParams) => string plural: (key: UiI18nPluralKey, count: number, params?: UiI18nParams) => string } @@ -60,7 +61,7 @@ const Context = createContext(fallback) function UiI18nProvider(props: ParentProps<{ value: UiI18n }>) { return ( - + {props.children} ) From 6c97a46dae52a4d88893adac9d7be4b43b7d51ea Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 06:26:48 -0500 Subject: [PATCH 213/333] fix(stats): hide zero-usage countries (#40400) --- packages/stats/app/src/routes/[lab]/[model].tsx | 5 +++-- packages/stats/app/src/routes/index.css | 4 ++-- packages/stats/app/src/routes/index.tsx | 5 +++-- 3 files changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 40a0ac9c5056..3b2bf341fdb5 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -988,9 +988,10 @@ function GeoWorldMap(props: { const i18n = useI18n() const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true)) const countryOpacity = (country: CountryEntry | undefined) => { - if (!country) return 0 + if (!country || country.tokens <= 0) return 0 const opacity = opacityScale()(country.tokens) - if (!props.activeCountry || props.activeCountry === country.country) return opacity + if (props.activeCountry === country.country) return 1 + if (!props.activeCountry) return opacity return Math.max(0.18, opacity * 0.36) } diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index b55c3153f2c5..c378688490cd 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -2275,7 +2275,7 @@ body { [data-page="stats"] [data-slot="geo-countries"] path[data-active="true"] { fill: color-mix(in srgb, var(--stats-accent) 70%, var(--stats-text)); - opacity: 1; + opacity: var(--geo-country-opacity); } [data-page="stats"] [data-slot="geo-country-markers"] circle { @@ -2292,7 +2292,7 @@ body { [data-page="stats"] [data-slot="geo-country-markers"] circle[data-active="true"] { fill: color-mix(in srgb, var(--stats-accent) 70%, var(--stats-text)); - opacity: 1; + opacity: var(--geo-country-opacity); } [data-page="stats"] [data-slot="geo-borders"] { diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 7debf5c30b79..c6549b1f6133 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1410,9 +1410,10 @@ function GeoWorldMap(props: { const i18n = useI18n() const opacityScale = createMemo(() => scaleSqrt().domain([0, props.maxTokens]).range([0.26, 0.96]).clamp(true)) const countryOpacity = (country: CountryEntry | undefined) => { - if (!country) return 0 + if (!country || country.tokens <= 0) return 0 const opacity = opacityScale()(country.tokens) - if (!props.activeCountry || props.activeCountry === country.country) return opacity + if (props.activeCountry === country.country) return 1 + if (!props.activeCountry) return opacity return Math.max(0.18, opacity * 0.36) } From de1bd36a47f2e6ade10188b525b4e81781147d3f Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:39:32 +1000 Subject: [PATCH 214/333] fix(app): correct RTL layout interactions (#40410) --- packages/app/src/components/file-tree-v2.tsx | 17 ++-- packages/app/src/components/file-tree.tsx | 4 +- .../components/settings-v2/settings-v2.css | 8 +- .../app/src/components/titlebar-tab-nav.css | 23 +++++- packages/app/src/components/titlebar.tsx | 21 ++++- .../src/v2/components/session-review-v2.css | 26 +++--- .../src/v2/components/session-review-v2.tsx | 12 ++- packages/ui/src/components/dropdown-menu.css | 4 +- packages/ui/src/components/icon.tsx | 9 ++- packages/ui/src/components/resize-handle.css | 16 +++- packages/ui/src/components/resize-handle.tsx | 6 +- packages/ui/src/components/scroll-view.css | 2 +- packages/ui/src/components/select.css | 8 +- packages/ui/src/components/switch.css | 4 + packages/ui/src/components/tabs.css | 80 +++++++++++++------ packages/ui/src/components/tabs.tsx | 1 + packages/ui/src/components/text-field.css | 4 +- .../ui/src/v2/components/file-tree-v2.css | 8 +- packages/ui/src/v2/components/select-v2.css | 21 +++-- packages/ui/src/v2/components/switch-v2.css | 8 ++ .../ui/src/v2/components/text-input-v2.css | 12 +-- 21 files changed, 210 insertions(+), 84 deletions(-) diff --git a/packages/app/src/components/file-tree-v2.tsx b/packages/app/src/components/file-tree-v2.tsx index ba1a7aaab466..091e489c32c4 100644 --- a/packages/app/src/components/file-tree-v2.tsx +++ b/packages/app/src/components/file-tree-v2.tsx @@ -29,14 +29,14 @@ export type { Kind } from "@/components/file-tree" const INDENT_STEP = 16 -function rowPaddingLeft(level: number, type: FileNode["type"]) { +function rowPaddingStart(level: number, type: FileNode["type"]) { if (type === "directory") return 8 + level * INDENT_STEP if (level === 0) return 8 return 8 + level * INDENT_STEP - INDENT_STEP } -function guideLineLeft(level: number) { - return rowPaddingLeft(level, "directory") + 8 +function guideLineStart(level: number) { + return rowPaddingStart(level, "directory") + 8 } export const kindLabel = (kind: Kind) => { @@ -87,7 +87,7 @@ const FileTreeNodeV2 = ( ...local.classList, [local.class ?? ""]: !!local.class, }} - style={`padding-left: ${rowPaddingLeft(local.level, local.node.type)}px`} + style={`padding-inline-start: ${rowPaddingStart(local.level, local.node.type)}px`} draggable={local.draggable} onDragStart={(event: DragEvent) => { if (!local.draggable) return @@ -99,7 +99,9 @@ const FileTreeNodeV2 = ( {...rest} > {local.children} - {local.node.name} + + {local.node.name} + {(() => { const value = kind() if (!value || local.node.type !== "file") return null @@ -116,7 +118,7 @@ const FileTreeNodeV2 = ( function GuideLines(props: { level: number }) { return ( - {(_, index) =>
    } + {(_, index) =>
    } ) } @@ -215,7 +217,6 @@ export default function FileTreeV2(props: {
    { if (!local.draggable) return diff --git a/packages/app/src/components/settings-v2/settings-v2.css b/packages/app/src/components/settings-v2/settings-v2.css index c4d47344a26c..b73269b496df 100644 --- a/packages/app/src/components/settings-v2/settings-v2.css +++ b/packages/app/src/components/settings-v2/settings-v2.css @@ -287,7 +287,7 @@ } .settings-v2-provider-env-hint { - padding-right: 12px; + padding-inline-end: 12px; font-size: 13px; font-weight: 440; line-height: 1; @@ -311,7 +311,7 @@ line-height: 1; color: var(--v2-text-text-accent); cursor: pointer; - text-align: left; + text-align: start; } .settings-v2-providers-view-all:hover { @@ -361,13 +361,13 @@ } .settings-v2-tab-search [data-slot="text-input-v2-input"] { - padding-right: 28px; + padding-inline-end: 28px; } .settings-v2-tab-search-clear { position: absolute; top: 50%; - right: 6px; + inset-inline-end: 6px; z-index: 1; transform: translateY(-50%); } diff --git a/packages/app/src/components/titlebar-tab-nav.css b/packages/app/src/components/titlebar-tab-nav.css index 13dfbe04fc75..93834b5b968d 100644 --- a/packages/app/src/components/titlebar-tab-nav.css +++ b/packages/app/src/components/titlebar-tab-nav.css @@ -1,7 +1,7 @@ [data-titlebar-tab] [data-slot="tab-close"] { position: absolute; top: 4px; - right: 4px; + inset-inline-end: 4px; display: flex; height: 20px; width: 20px; @@ -52,7 +52,7 @@ content: ""; position: absolute; top: 8px; - left: -3.75px; + inset-inline-start: -3.75px; width: 1.5px; height: 12px; border-radius: 9999px; @@ -86,6 +86,23 @@ ); } +[data-titlebar-tab][data-title-overflow="true"]:not([data-editing="true"]):dir(rtl) [data-slot="tab-link"] { + -webkit-mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); + mask-image: linear-gradient( + to left, + black 0, + black calc(100% - var(--tab-title-fade-offset) - 16px), + transparent calc(100% - var(--tab-title-fade-offset)), + transparent 100% + ); +} + [data-titlebar-tab][data-title-overflow="true"]:is(:hover, [data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] { --tab-title-fade-offset: 24px; @@ -93,7 +110,7 @@ [data-titlebar-tab][data-title-overflow="true"]:not(:hover):not([data-active="true"]):not([data-editing="true"]) [data-slot="tab-link"] { - padding-right: 0; + padding-inline-end: 0; } [data-titlebar-tab] [data-slot="tab-title"] { diff --git a/packages/app/src/components/titlebar.tsx b/packages/app/src/components/titlebar.tsx index f24caeb51bd1..64df40685f23 100644 --- a/packages/app/src/components/titlebar.tsx +++ b/packages/app/src/components/titlebar.tsx @@ -1,4 +1,15 @@ -import { createEffect, createMemo, createResource, createSignal, Match, onMount, Show, Switch, untrack } from "solid-js" +import { + createEffect, + createMemo, + createResource, + createSignal, + Match, + on, + onMount, + Show, + Switch, + untrack, +} from "solid-js" import { createStore } from "solid-js/store" import { useLocation, useNavigate, useParams } from "@solidjs/router" import { IconButton } from "@opencode-ai/ui/icon-button" @@ -42,8 +53,11 @@ export type TitlebarUpdate = { } export function useTitlebarRightMount() { + const language = useLanguage() const [mount, setMount] = createSignal(null) - onMount(() => setMount(document.getElementById("opencode-titlebar-right"))) + const sync = () => setMount(document.getElementById("opencode-titlebar-right")) + onMount(sync) + createEffect(on(language.direction, sync, { defer: true })) return mount } @@ -169,7 +183,8 @@ export function Titlebar(props: { update?: TitlebarUpdate; debugTools?: { visibl "padding-left": macTrafficLights() ? `${macTrafficLightsBaseWidth / zoom()}px` : 0, width: windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, "max-width": windows() ? `env(titlebar-area-width, calc(100vw - ${windowsControlsWidth()}))` : undefined, - "align-self": windows() ? "flex-start" : undefined, + // Native Windows caption controls remain on the physical right in both writing directions. + "margin-right": windows() ? "auto" : undefined, }} data-tauri-drag-region > diff --git a/packages/session-ui/src/v2/components/session-review-v2.css b/packages/session-ui/src/v2/components/session-review-v2.css index 557ef16f6db8..ac899be9884c 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.css +++ b/packages/session-ui/src/v2/components/session-review-v2.css @@ -30,19 +30,19 @@ flex-shrink: 0; min-height: 0; overflow: hidden; - border-right: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); + border-inline-end: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); background: var(--v2-background-bg-base); } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][aria-hidden="true"] { - border-right-width: 0; + border-inline-end-width: 0; } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar"][data-transition]:not([data-resizing]) { transition: width 200ms cubic-bezier(0.22, 1, 0.36, 1), - border-right-width 200ms cubic-bezier(0.22, 1, 0.36, 1); + border-inline-end-width 200ms cubic-bezier(0.22, 1, 0.36, 1); } [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-resize"] { @@ -63,7 +63,8 @@ align-items: center; justify-content: space-between; gap: 8px; - padding: 12px 16px 12px 8px; + padding-block: 12px; + padding-inline: 8px 16px; flex-shrink: 0; } @@ -79,7 +80,7 @@ [data-component="session-review-v2-sidebar-root"] [data-slot="session-review-v2-sidebar-title"]:not(:has([data-component="select-v2-root"])) { - margin-left: 8px; + margin-inline-start: 8px; } [data-component="session-review-v2-sidebar-root"] @@ -211,7 +212,7 @@ justify-content: space-between; gap: 8px; padding: 10px 12px; - padding-left: 8px; + padding-inline-start: 8px; flex-shrink: 0; border-bottom: 1px solid var(--border-weaker-base, var(--v2-border-border-weak)); } @@ -231,7 +232,7 @@ align-items: center; gap: 8px; min-width: 0; - padding-right: 8px; + padding-inline-end: 8px; } [data-component="session-review-v2"] [data-slot="session-review-v2-toolbar-title"] { @@ -331,7 +332,7 @@ [data-component="session-review-v2"] [data-slot="session-review-v2-file-diff"] { position: relative; flex-shrink: 0; - margin-left: auto; + margin-inline-start: auto; background-color: var(--v2-background-bg-base); &::before { @@ -339,17 +340,22 @@ position: absolute; top: -8px; bottom: -8px; - right: 100%; + inset-inline-end: 100%; width: 16px; z-index: 1; pointer-events: none; background: linear-gradient(90deg, transparent, var(--v2-background-bg-base)); } + &:dir(rtl)::before { + background: linear-gradient(270deg, transparent, var(--v2-background-bg-base)); + } + &::after { content: ""; position: absolute; - inset: -8px -16px -8px 0; + inset-block: -8px; + inset-inline: 0 -16px; z-index: 0; pointer-events: none; background-color: var(--v2-background-bg-base); diff --git a/packages/session-ui/src/v2/components/session-review-v2.tsx b/packages/session-ui/src/v2/components/session-review-v2.tsx index d2f3b6b8a6ec..cb0a6bd8cb1e 100644 --- a/packages/session-ui/src/v2/components/session-review-v2.tsx +++ b/packages/session-ui/src/v2/components/session-review-v2.tsx @@ -9,6 +9,7 @@ import { TooltipV2 } from "@opencode-ai/ui/v2/tooltip-v2" import type { SessionReviewDiffStyle } from "../../components/session-review" import { ResizeHandle } from "@opencode-ai/ui/resize-handle" import { ScrollView } from "@opencode-ai/ui/scroll-view" +import { useLocale } from "@kobalte/core/i18n" import { makeEventListener } from "@solid-primitives/event-listener" import { Show, createEffect, createMemo, createSignal, type JSX } from "solid-js" import { getWorkerPool } from "../../pierre/worker" @@ -149,6 +150,7 @@ export function SessionReviewV2Sidebar(props: SessionReviewV2SidebarProps) { export function SessionReviewV2(props: SessionReviewV2Props) { const i18n = useI18n() + const locale = useLocale() createEffect(() => { getWorkerPool(props.diffStyle) @@ -175,6 +177,8 @@ export function SessionReviewV2(props: SessionReviewV2Props) { } const canCycle = () => props.files.length > 0 + const previousKey = () => (locale.direction() === "rtl" ? "ArrowRight" : "ArrowLeft") + const nextKey = () => (locale.direction() === "rtl" ? "ArrowLeft" : "ArrowRight") const showCollapsedMeta = () => props.sidebarOpen === false // Memoize slot getters so Show conditions do not instantiate throwaway elements. const title = createMemo(() => props.title) @@ -189,11 +193,11 @@ export function SessionReviewV2(props: SessionReviewV2Props) { // pane is mounted, but never while typing in an input or comment editor. makeEventListener(document, "keydown", (event) => { if (event.defaultPrevented || event.ctrlKey || event.metaKey || event.altKey) return - if (event.key !== "ArrowLeft" && event.key !== "ArrowRight") return + if (event.key !== previousKey() && event.key !== nextKey()) return const target = event.target if (target instanceof HTMLElement && (target.isContentEditable || target.closest("input, textarea, select"))) return if (!props.hasDiffs || !canCycle()) return - const file = event.key === "ArrowLeft" ? prev() : next() + const file = event.key === previousKey() ? prev() : next() if (!file) return event.preventDefault() cycle(file) @@ -221,7 +225,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { value={ <> {i18n.t("ui.sessionReviewV2.previousFile")} - + } > @@ -241,7 +245,7 @@ export function SessionReviewV2(props: SessionReviewV2Props) { value={ <> {i18n.t("ui.sessionReviewV2.nextFile")} - + } > diff --git a/packages/ui/src/components/dropdown-menu.css b/packages/ui/src/components/dropdown-menu.css index edc2eee9a400..f8a9fa60ee35 100644 --- a/packages/ui/src/components/dropdown-menu.css +++ b/packages/ui/src/components/dropdown-menu.css @@ -60,7 +60,7 @@ [data-slot="dropdown-menu-checkbox-item"], [data-slot="dropdown-menu-radio-item"] { - padding-right: 28px; + padding-inline-end: 28px; } [data-slot="dropdown-menu-sub-trigger"] { @@ -76,7 +76,7 @@ width: 16px; height: 16px; position: absolute; - right: 8px; + inset-inline-end: 8px; top: 50%; transform: translateY(-50%); } diff --git a/packages/ui/src/components/icon.tsx b/packages/ui/src/components/icon.tsx index b46cdc833b0b..a16fad0c32aa 100644 --- a/packages/ui/src/components/icon.tsx +++ b/packages/ui/src/components/icon.tsx @@ -154,7 +154,14 @@ export function Icon(props: IconProps) {
    .sticky { - padding-right: 0; + padding-inline-end: 0; [data-component="icon-button-v2"] { position: relative; @@ -800,7 +809,7 @@ body[data-new-layout] &::after { content: ""; position: absolute; - left: 100%; + inset-inline-start: 100%; top: 50%; transform: translateY(-50%); width: 20px; @@ -811,21 +820,35 @@ body[data-new-layout] } } +body[data-new-layout] + #review-panel + [data-component="tabs"] + .session-review-v2-tabs-bar + [data-slot="tabs-list"] + > .sticky:not(.session-review-v2-sidebar-toggle-slot) { + left: auto; + right: auto; + inset-inline-end: 0; +} + body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-tabs-bar [data-slot="tabs-list"] > .session-review-v2-sidebar-toggle-slot.sticky { - padding-right: 0; + left: auto; + right: auto; + inset-inline-start: 0; + padding-inline-end: 0; &::before { content: ""; position: absolute; top: 50%; bottom: auto; - left: auto; - right: 100%; + inset-inline-start: auto; + inset-inline-end: 100%; transform: translateY(-50%); width: 12px; height: 28px; @@ -838,7 +861,8 @@ body[data-new-layout] content: ""; position: absolute; top: 50%; - left: 100%; + inset-inline-start: 100%; + inset-inline-end: auto; transform: translateY(-50%); width: 8px; height: 28px; @@ -846,6 +870,10 @@ body[data-new-layout] background: linear-gradient(90deg, var(--v2-background-bg-base), transparent); } + &:dir(rtl)::after { + background: linear-gradient(270deg, var(--v2-background-bg-base), transparent); + } + [data-component="icon-button-v2"]::after { display: none; } @@ -853,7 +881,7 @@ body[data-new-layout] body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app-slot { flex-shrink: 0; - margin-left: auto; + margin-inline-start: auto; } body[data-new-layout] #review-panel [data-component="tabs"] .session-review-v2-open-in-app { diff --git a/packages/ui/src/components/tabs.tsx b/packages/ui/src/components/tabs.tsx index f46e9bfb30da..0dd5fb3e236e 100644 --- a/packages/ui/src/components/tabs.tsx +++ b/packages/ui/src/components/tabs.tsx @@ -80,6 +80,7 @@ function TabsTrigger(props: ParentProps) { > Date: Tue, 4 Aug 2026 07:43:54 -0400 Subject: [PATCH 215/333] docs: add Poolside provider setup (#39187) --- packages/web/src/content/docs/providers.mdx | 64 +++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 7931d4e03f69..96927f44d2a7 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -1885,6 +1885,70 @@ OpenCode Zen is a list of tested and verified models provided by the OpenCode te --- +### Poolside + +[Poolside](https://poolside.ai) provides access to its models through an OpenAI-compatible API. + +1. Head over to [platform.poolside.ai](https://platform.poolside.ai), sign in, open the **API Keys** tab, and click **New key** to generate an API key. + +2. Run the `/connect` command and search for **Poolside**. + + ```txt + /connect + ``` + +3. Enter your Poolside API key. + + ```txt + ┌ API key + │ + │ + └ enter + ``` + +4. Run the `/models` command to select a model. + + ```txt + /models + ``` + +#### Poolside deployment + +If your organization runs a Poolside deployment, configure it as a custom OpenAI-compatible provider. Store the API key or token from your Poolside administrator in `~/.secrets/poolside-key`. Use a unique provider ID and add the model IDs available from your deployment. + +```json title="opencode.json" +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "poolside-deployment": { + "npm": "@ai-sdk/openai-compatible", + "name": "Poolside deployment", + "options": { + "baseURL": "https://poolside.example.com/openai/v1", + "apiKey": "{file:~/.secrets/poolside-key}" + }, + "models": { + "": { + "name": "Poolside deployment model", + "reasoning": true, + "interleaved": { + "field": "reasoning_content" + } + } + } + } + } +} +``` + +Replace `poolside.example.com` with your deployment's host and `` with a model ID returned by its `/openai/v1/models` endpoint. Model availability and thinking behavior depend on the deployment's configuration. + +:::tip +You can also install [Poolside Agent CLI](https://github.com/poolsideai/pool) to use Poolside agents directly from your terminal. +::: + +--- + ### SAP AI Core SAP AI Core provides access to 40+ models from OpenAI, Anthropic, Google, Amazon, Meta, Mistral, and AI21 through a unified platform. From 44614c79c4a3aee2bd033f5d02b6145efca51ba3 Mon Sep 17 00:00:00 2001 From: Shoubhit Dash Date: Tue, 4 Aug 2026 17:26:33 +0530 Subject: [PATCH 216/333] fix(acp): drain updates before end turn (#40422) --- packages/opencode/src/acp/event.ts | 97 ++++++++++-- packages/opencode/src/acp/service.ts | 70 +++++---- .../opencode/test/acp/service-session.test.ts | 148 ++++++++++++++++-- 3 files changed, 264 insertions(+), 51 deletions(-) diff --git a/packages/opencode/src/acp/event.ts b/packages/opencode/src/acp/event.ts index 7d05fa6ee58c..dec34b8b662a 100644 --- a/packages/opencode/src/acp/event.ts +++ b/packages/opencode/src/acp/event.ts @@ -40,7 +40,10 @@ export class Subscription { private readonly abort = new AbortController() private readonly shellSnapshots = new Map() private readonly toolStarts = new Set() + private readonly connectionWaiters = new Set<() => void>() + private readonly idleWaiters = new Map>>() private readonly permission: ACPPermission.Handler + private connected = false private started = false constructor( @@ -63,10 +66,35 @@ export class Subscription { stop() { this.abort.abort() + this.disconnected() + for (const resolve of this.connectionWaiters) resolve() + this.connectionWaiters.clear() + } + + async runUntilIdle(sessionId: string, request: () => Promise) { + await this.waitUntilConnected() + const waiter = signal() + const waiters = this.idleWaiters.get(sessionId) ?? new Set() + waiters.add(waiter) + this.idleWaiters.set(sessionId, waiters) + + try { + // Idle is queued after the turn's events, and this subscription awaits each update in order. + void waiter.promise.catch(() => {}) + const response = await request() + await waiter.promise + return response + } finally { + waiters.delete(waiter) + if (waiters.size === 0) this.idleWaiters.delete(sessionId) + } } async handle(event: Event) { switch (event.type) { + case "session.status": + if (event.properties.status.type === "idle") this.idle(event.properties.sessionID) + return case "permission.asked": this.permission.handle(event) return @@ -115,19 +143,51 @@ export class Subscription { private async run() { while (!this.abort.signal.aborted) { - const events = (await this.input.sdk.global.event({ - signal: this.abort.signal, - })) as GlobalEventStream - - for await (const event of events.stream) { - if (this.abort.signal.aborted) return - if (!event.payload) continue - await this.handle(event.payload).catch(() => {}) - } + await this.consume().catch(() => {}) + this.disconnected() if (!this.abort.signal.aborted) await new Promise((resolve) => setTimeout(resolve, 1000)) } } + private async consume() { + const events = (await this.input.sdk.global.event({ + signal: this.abort.signal, + })) as GlobalEventStream + this.connected = true + for (const resolve of this.connectionWaiters) resolve() + this.connectionWaiters.clear() + + for await (const event of events.stream) { + if (this.abort.signal.aborted) return + if (!event.payload) continue + await this.handle(event.payload).catch(() => {}) + } + } + + private async waitUntilConnected() { + while (!this.connected) { + if (this.abort.signal.aborted) throw new Error("ACP event subscription stopped") + await new Promise((resolve) => this.connectionWaiters.add(resolve)) + } + } + + private disconnected() { + if (!this.connected) return + this.connected = false + const error = new Error("ACP event stream disconnected") + for (const waiters of this.idleWaiters.values()) { + for (const waiter of waiters) waiter.reject(error) + } + this.idleWaiters.clear() + } + + private idle(sessionId: string) { + const waiters = this.idleWaiters.get(sessionId) + if (!waiters) return + this.idleWaiters.delete(sessionId) + for (const waiter of waiters) waiter.resolve() + } + private async handlePartUpdated(event: EventMessagePartUpdated) { const part = event.properties.part const sessionId = part.sessionID || event.properties.sessionID @@ -339,4 +399,23 @@ export class Subscription { } } +function signal() { + const state: { + resolve: () => void + reject: (reason?: unknown) => void + } = { + resolve: () => {}, + reject: () => {}, + } + const promise = new Promise((resolve, reject) => { + state.resolve = resolve + state.reject = reject + }) + return { + promise, + resolve: () => state.resolve(), + reject: (reason?: unknown) => state.reject(reason), + } +} + export * as ACPEvent from "./event" diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index 7257efa7658c..fe2959189780 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -88,6 +88,8 @@ export function make(input: { ? ACPEvent.start({ sdk: input.sdk, connection: input.connection, session }) : undefined if (events) input.eventSubscription?.(events) + const runUntilIdle = (sessionId: string, fn: () => Promise) => + events ? events.runUntilIdle(sessionId, fn) : fn() const initialize = Effect.fn("ACP.initialize")(function* (params: InitializeRequest) { const started = performance.now() @@ -504,19 +506,21 @@ export function make(input: { if (!command) { const response = yield* request( () => - input.sdk.session.prompt( - { - sessionID: current.id, - model: { - providerID: selected.providerID, - modelID: selected.modelID, + runUntilIdle(current.id, () => + input.sdk.session.prompt( + { + sessionID: current.id, + model: { + providerID: selected.providerID, + modelID: selected.modelID, + }, + ...(variant ? { variant } : {}), + parts, + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, }, - ...(variant ? { variant } : {}), - parts, - ...(modeId ? { agent: modeId } : {}), - directory: current.cwd, - }, - { throwOnError: true }, + { throwOnError: true }, + ), ), "session", ) @@ -528,17 +532,19 @@ export function make(input: { if (known) { const response = yield* request( () => - input.sdk.session.command( - { - sessionID: current.id, - command: known.name, - arguments: command.args, - model: `${selected.providerID}/${selected.modelID}`, - ...(variant ? { variant } : {}), - ...(modeId ? { agent: modeId } : {}), - directory: current.cwd, - }, - { throwOnError: true }, + runUntilIdle(current.id, () => + input.sdk.session.command( + { + sessionID: current.id, + command: known.name, + arguments: command.args, + model: `${selected.providerID}/${selected.modelID}`, + ...(variant ? { variant } : {}), + ...(modeId ? { agent: modeId } : {}), + directory: current.cwd, + }, + { throwOnError: true }, + ), ), "session", ) @@ -549,14 +555,16 @@ export function make(input: { if (command.name === "compact") { yield* request( () => - input.sdk.session.summarize( - { - sessionID: current.id, - directory: current.cwd, - providerID: selected.providerID, - modelID: selected.modelID, - }, - { throwOnError: true }, + runUntilIdle(current.id, () => + input.sdk.session.summarize( + { + sessionID: current.id, + directory: current.cwd, + providerID: selected.providerID, + modelID: selected.modelID, + }, + { throwOnError: true }, + ), ), "session", ) diff --git a/packages/opencode/test/acp/service-session.test.ts b/packages/opencode/test/acp/service-session.test.ts index 8dd25492c093..d784709dbb2a 100644 --- a/packages/opencode/test/acp/service-session.test.ts +++ b/packages/opencode/test/acp/service-session.test.ts @@ -10,7 +10,7 @@ import type { SessionConfigSelectOption, SetSessionConfigOptionResponse, } from "@agentclientprotocol/sdk" -import type { AssistantMessage, OpencodeClient } from "@opencode-ai/sdk/v2" +import type { AssistantMessage, Event, OpencodeClient } from "@opencode-ai/sdk/v2" import { ProviderV2 } from "@opencode-ai/core/provider" import { ModelV2 } from "@opencode-ai/core/model" import { Effect } from "effect" @@ -24,6 +24,54 @@ const modelID = ModelV2.ID.make("test-model") const configuredModelID = ModelV2.ID.make("configured-model") const secondModelID = ModelV2.ID.make("second-model") +function createEventStream() { + const queue: Event[] = [] + const waiters: Array<(event: Event | undefined) => void> = [] + const push = (event: Event) => { + const waiter = waiters.shift() + if (waiter) return waiter(event) + queue.push(event) + } + const stream = async function* (signal?: AbortSignal) { + while (!signal?.aborted) { + const event = queue.shift() + if (event) { + yield { payload: event } + continue + } + const next = await new Promise((resolve) => { + waiters.push(resolve) + signal?.addEventListener("abort", () => resolve(undefined), { once: true }) + }) + if (!next) return + yield { payload: next } + } + } + return { push, stream } +} + +function idleEvent(sessionID: string): Event { + return { + id: `evt_idle_${sessionID}`, + type: "session.status", + properties: { + sessionID, + status: { type: "idle" }, + }, + } +} + +function deferred() { + const state: { resolve?: (value: A) => void } = {} + const promise = new Promise((resolve) => { + state.resolve = resolve + }) + return { + promise, + resolve: (value: A) => state.resolve?.(value), + } +} + const provider: Provider.Info = { id: providerID, name: "Test", @@ -147,6 +195,7 @@ describe("ACP service sessions", () => { options?: { abort?: (input: { sessionID: string }) => Promise<{ data: boolean }> prompt?: (input: unknown) => Promise<{ data: { info: ReturnType } }> + sessionUpdate?: (update: SessionNotification) => Promise }, ) => { const updates: SessionNotification[] = [] @@ -157,6 +206,7 @@ describe("ACP service sessions", () => { const commands: unknown[] = [] const summarizes: unknown[] = [] const usageUpdates: string[] = [] + const events = createEventStream() const sessions = Array.from({ length: 102 }, (_, index) => ({ id: `ses_${index + 1}`, directory: index % 2 === 0 ? "/workspace" : "/other", @@ -164,6 +214,9 @@ describe("ACP service sessions", () => { time: { created: index + 1, updated: index + 1 }, })) const sdk = { + global: { + event: (input?: { signal?: AbortSignal }) => Promise.resolve({ stream: events.stream(input?.signal) }), + }, config: { providers: () => Promise.resolve({ data: { providers: [provider], default: { test: modelID } } }), get: () => Promise.resolve({ data: {} }), @@ -196,11 +249,9 @@ describe("ACP service sessions", () => { data: input.directory ? sessions.filter((session) => session.directory === input.directory) : sessions, }), messages: () => Promise.resolve({ data: messages }), - prompt: - options?.prompt ?? - ((input: unknown) => { - prompts.push(input) - return Promise.resolve({ + prompt: async (input: { sessionID: string }) => { + const response = await (options?.prompt?.(input) ?? + Promise.resolve({ data: { info: assistantInfo({ input: 100, @@ -209,10 +260,14 @@ describe("ACP service sessions", () => { cache: { read: 11, write: 13 }, }), }, - }) - }), - command: (input: unknown) => { + })) + prompts.push(input) + events.push(idleEvent(input.sessionID)) + return response + }, + command: (input: { sessionID: string }) => { commands.push(input) + events.push(idleEvent(input.sessionID)) return Promise.resolve({ data: { info: assistantInfo({ @@ -224,8 +279,9 @@ describe("ACP service sessions", () => { }, }) }, - summarize: (input: unknown) => { + summarize: (input: { sessionID: string }) => { summarizes.push(input) + events.push(idleEvent(input.sessionID)) return Promise.resolve({ data: true }) }, abort: @@ -249,7 +305,7 @@ describe("ACP service sessions", () => { const connection = { sessionUpdate: (update: SessionNotification) => { updates.push(update) - return Promise.resolve() + return options?.sessionUpdate?.(update) ?? Promise.resolve() }, } as Pick const usage = UsageService.Service.of({ @@ -273,6 +329,7 @@ describe("ACP service sessions", () => { commands, summarizes, usageUpdates, + events, } } @@ -1018,6 +1075,75 @@ describe("ACP service sessions", () => { expect(usageUpdates).toEqual([session.sessionId]) }) + it("waits for queued session updates before returning end_turn", async () => { + const called = deferred() + const response = deferred<{ data: { info: ReturnType } }>() + const update = deferred() + const release = deferred() + const order: string[] = [] + const fixture = makeService([], { + prompt: () => { + called.resolve(undefined) + return response.promise + }, + sessionUpdate: (notification) => { + if (notification.update.sessionUpdate !== "agent_thought_chunk") return Promise.resolve() + update.resolve(undefined) + return release.promise.then(() => { + order.push("update") + }) + }, + }) + const session = await Effect.runPromise(fixture.service.newSession({ cwd: "/workspace", mcpServers: [] })) + const result = Effect.runPromise( + fixture.service.prompt({ sessionId: session.sessionId, prompt: [{ type: "text", text: "hello" }] }), + ).then((value) => { + order.push("response") + return value + }) + + await called.promise + fixture.events.push({ + id: "evt_part", + type: "message.part.updated", + properties: { + sessionID: session.sessionId, + time: Date.now(), + part: { + id: "part_reasoning", + sessionID: session.sessionId, + messageID: "msg_assistant", + type: "reasoning", + text: "", + time: { start: Date.now() }, + }, + }, + }) + fixture.events.push({ + id: "evt_delta", + type: "message.part.delta", + properties: { + sessionID: session.sessionId, + messageID: "msg_assistant", + partID: "part_reasoning", + field: "text", + delta: "thinking", + }, + }) + response.resolve({ + data: { + info: assistantInfo({ input: 1, output: 1, reasoning: 1, cache: { read: 0, write: 0 } }), + }, + }) + + await update.promise + expect(order).toEqual([]) + + release.resolve(undefined) + expect((await result).stopReason).toBe("end_turn") + expect(order).toEqual(["update", "response"]) + }) + it("maps assistant prompt errors to request errors instead of end turn", async () => { const { service } = makeService([], { prompt: () => From aefaf140c19e25494da27739ae979f31b8cfe474 Mon Sep 17 00:00:00 2001 From: opencode Date: Tue, 4 Aug 2026 12:12:30 +0000 Subject: [PATCH 217/333] sync release versions for v1.18.13 --- bun.lock | 56 ++++++++++----------- packages/app/package.json | 2 +- packages/cli/package.json | 2 +- packages/codemode/package.json | 2 +- packages/console/app/package.json | 2 +- packages/console/core/package.json | 2 +- packages/console/function/package.json | 2 +- packages/console/mail/package.json | 2 +- packages/console/support/package.json | 2 +- packages/core/package.json | 2 +- packages/desktop/package.json | 2 +- packages/effect-drizzle-sqlite/package.json | 2 +- packages/effect-sqlite-node/package.json | 2 +- packages/enterprise/package.json | 2 +- packages/function/package.json | 2 +- packages/http-recorder/package.json | 2 +- packages/llm/package.json | 2 +- packages/opencode/package.json | 2 +- packages/plugin/package.json | 2 +- packages/sdk/js/package.json | 2 +- packages/server/package.json | 2 +- packages/session-ui/package.json | 2 +- packages/slack/package.json | 2 +- packages/stats/app/package.json | 2 +- packages/stats/core/package.json | 2 +- packages/stats/server/package.json | 2 +- packages/tui/package.json | 2 +- packages/ui/package.json | 2 +- packages/web/package.json | 2 +- sdks/vscode/package.json | 2 +- 30 files changed, 57 insertions(+), 57 deletions(-) diff --git a/bun.lock b/bun.lock index 62dbf794652c..0d08cb8a95ab 100644 --- a/bun.lock +++ b/bun.lock @@ -29,7 +29,7 @@ }, "packages/app": { "name": "@opencode-ai/app", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@corvu/drawer": "catalog:", "@dnd-kit/abstract": "0.5.0", @@ -96,7 +96,7 @@ }, "packages/cli": { "name": "@opencode-ai/cli", - "version": "1.18.12", + "version": "1.18.13", "bin": { "lildax": "./bin/lildax.cjs", }, @@ -144,7 +144,7 @@ }, "packages/codemode": { "name": "@opencode-ai/codemode", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "acorn": "8.15.0", "effect": "catalog:", @@ -158,7 +158,7 @@ }, "packages/console/app": { "name": "@opencode-ai/console-app", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@ibm/plex": "6.4.1", @@ -194,7 +194,7 @@ }, "packages/console/core": { "name": "@opencode-ai/console-core", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@aws-sdk/client-sts": "3.782.0", "@jsx-email/render": "1.1.1", @@ -221,7 +221,7 @@ }, "packages/console/function": { "name": "@opencode-ai/console-function", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@ai-sdk/anthropic": "3.0.82", "@ai-sdk/openai": "3.0.48", @@ -243,7 +243,7 @@ }, "packages/console/mail": { "name": "@opencode-ai/console-mail", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", @@ -267,7 +267,7 @@ }, "packages/console/support": { "name": "@opencode-ai/console-support", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@cloudflare/vite-plugin": "1.15.2", "@opencode-ai/console-core": "workspace:*", @@ -287,7 +287,7 @@ }, "packages/core": { "name": "@opencode-ai/core", - "version": "1.18.12", + "version": "1.18.13", "bin": { "opencode": "./bin/opencode", }, @@ -381,7 +381,7 @@ }, "packages/desktop": { "name": "@opencode-ai/desktop", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@zip.js/zip.js": "2.7.62", "drizzle-orm": "catalog:", @@ -435,7 +435,7 @@ }, "packages/effect-drizzle-sqlite": { "name": "@opencode-ai/effect-drizzle-sqlite", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "drizzle-orm": "catalog:", "effect": "catalog:", @@ -449,7 +449,7 @@ }, "packages/effect-sqlite-node": { "name": "@opencode-ai/effect-sqlite-node", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "effect": "catalog:", }, @@ -461,7 +461,7 @@ }, "packages/enterprise": { "name": "@opencode-ai/enterprise", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@hono/standard-validator": "catalog:", "@opencode-ai/core": "workspace:*", @@ -493,7 +493,7 @@ }, "packages/function": { "name": "@opencode-ai/function", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@octokit/auth-app": "8.0.1", "@octokit/rest": "catalog:", @@ -509,7 +509,7 @@ }, "packages/http-recorder": { "name": "@opencode-ai/http-recorder", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@effect/platform-node": "4.0.0-beta.83", "@effect/platform-node-shared": "4.0.0-beta.83", @@ -540,7 +540,7 @@ }, "packages/llm": { "name": "@opencode-ai/llm", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@opencode-ai/schema": "workspace:*", "@smithy/eventstream-codec": "4.2.14", @@ -559,7 +559,7 @@ }, "packages/opencode": { "name": "opencode", - "version": "1.18.12", + "version": "1.18.13", "bin": { "opencode": "./bin/opencode", }, @@ -690,7 +690,7 @@ }, "packages/plugin": { "name": "@opencode-ai/plugin", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@ai-sdk/provider": "3.0.8", "@opencode-ai/sdk": "workspace:*", @@ -766,7 +766,7 @@ }, "packages/sdk/js": { "name": "@opencode-ai/sdk", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "cross-spawn": "catalog:", }, @@ -781,7 +781,7 @@ }, "packages/server": { "name": "@opencode-ai/server", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/protocol": "workspace:*", @@ -796,7 +796,7 @@ }, "packages/session-ui": { "name": "@opencode-ai/session-ui", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/client": "file:../app/vendor/opencode-ai-client-1.17.13-v2.tgz", @@ -836,7 +836,7 @@ }, "packages/slack": { "name": "@opencode-ai/slack", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@opencode-ai/sdk": "workspace:*", "@slack/bolt": "^3.17.1", @@ -849,7 +849,7 @@ }, "packages/stats/app": { "name": "@opencode-ai/stats-app", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@ibm/plex": "6.4.1", "@kobalte/core": "catalog:", @@ -883,7 +883,7 @@ }, "packages/stats/core": { "name": "@opencode-ai/stats-core", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@aws-sdk/client-athena": "3.933.0", "@planetscale/database": "1.19.0", @@ -902,7 +902,7 @@ }, "packages/stats/server": { "name": "@opencode-ai/stats-server", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@aws-sdk/client-firehose": "3.933.0", "@effect/platform-node": "catalog:", @@ -944,7 +944,7 @@ }, "packages/tui": { "name": "@opencode-ai/tui", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@opencode-ai/core": "workspace:*", "@opencode-ai/plugin": "workspace:*", @@ -971,7 +971,7 @@ }, "packages/ui": { "name": "@opencode-ai/ui", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@kobalte/core": "catalog:", "@pierre/diffs": "catalog:", @@ -1022,7 +1022,7 @@ }, "packages/web": { "name": "@opencode-ai/web", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@astrojs/cloudflare": "12.6.3", "@astrojs/markdown-remark": "6.3.1", diff --git a/packages/app/package.json b/packages/app/package.json index 7eb870b58548..1ad643f07a6a 100644 --- a/packages/app/package.json +++ b/packages/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/app", - "version": "1.18.12", + "version": "1.18.13", "description": "", "type": "module", "exports": { diff --git a/packages/cli/package.json b/packages/cli/package.json index 1f2ff003a165..66953ce4a967 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/cli", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "bin": { diff --git a/packages/codemode/package.json b/packages/codemode/package.json index 9430fb40a8f1..ee9ccd423c28 100644 --- a/packages/codemode/package.json +++ b/packages/codemode/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/codemode", - "version": "1.18.12", + "version": "1.18.13", "description": "Effect-native confined code execution over schema-described tools", "private": true, "type": "module", diff --git a/packages/console/app/package.json b/packages/console/app/package.json index ac5acecc7add..6cb9d9dfa666 100644 --- a/packages/console/app/package.json +++ b/packages/console/app/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-app", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/console/core/package.json b/packages/console/core/package.json index 8da3fdbfc2f6..21948517f68f 100644 --- a/packages/console/core/package.json +++ b/packages/console/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/console-core", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/console/function/package.json b/packages/console/function/package.json index aec90aede308..f9e93a0f4eed 100644 --- a/packages/console/function/package.json +++ b/packages/console/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-function", - "version": "1.18.12", + "version": "1.18.13", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/console/mail/package.json b/packages/console/mail/package.json index 0787476c4a42..925f89a2e708 100644 --- a/packages/console/mail/package.json +++ b/packages/console/mail/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-mail", - "version": "1.18.12", + "version": "1.18.13", "dependencies": { "@jsx-email/all": "2.2.3", "@jsx-email/cli": "1.4.3", diff --git a/packages/console/support/package.json b/packages/console/support/package.json index 23d05176581f..72d109561299 100644 --- a/packages/console/support/package.json +++ b/packages/console/support/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/console-support", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/core/package.json b/packages/core/package.json index 3dffa1fcb3d9..31ae85dfa560 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", + "version": "1.18.13", "name": "@opencode-ai/core", "type": "module", "license": "MIT", diff --git a/packages/desktop/package.json b/packages/desktop/package.json index f7828298f420..22a4a4e63766 100644 --- a/packages/desktop/package.json +++ b/packages/desktop/package.json @@ -1,7 +1,7 @@ { "name": "@opencode-ai/desktop", "private": true, - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "homepage": "https://opencode.ai", diff --git a/packages/effect-drizzle-sqlite/package.json b/packages/effect-drizzle-sqlite/package.json index 60c48a8c7c12..6061ac103f58 100644 --- a/packages/effect-drizzle-sqlite/package.json +++ b/packages/effect-drizzle-sqlite/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", + "version": "1.18.13", "name": "@opencode-ai/effect-drizzle-sqlite", "type": "module", "license": "MIT", diff --git a/packages/effect-sqlite-node/package.json b/packages/effect-sqlite-node/package.json index f43872597468..ab44b9add752 100644 --- a/packages/effect-sqlite-node/package.json +++ b/packages/effect-sqlite-node/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", + "version": "1.18.13", "name": "@opencode-ai/effect-sqlite-node", "type": "module", "license": "MIT", diff --git a/packages/enterprise/package.json b/packages/enterprise/package.json index 492176fb66b1..d5d71410634e 100644 --- a/packages/enterprise/package.json +++ b/packages/enterprise/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/enterprise", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/function/package.json b/packages/function/package.json index c06de12d562f..94f66edfe568 100644 --- a/packages/function/package.json +++ b/packages/function/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/function", - "version": "1.18.12", + "version": "1.18.13", "$schema": "https://json.schemastore.org/package.json", "private": true, "type": "module", diff --git a/packages/http-recorder/package.json b/packages/http-recorder/package.json index aeab78cb4a66..f5120021ac22 100644 --- a/packages/http-recorder/package.json +++ b/packages/http-recorder/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", + "version": "1.18.13", "name": "@opencode-ai/http-recorder", "description": "Record and replay Effect HTTP client traffic with deterministic cassettes", "type": "module", diff --git a/packages/llm/package.json b/packages/llm/package.json index 28f9751ac353..50f6cf568f6a 100644 --- a/packages/llm/package.json +++ b/packages/llm/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", + "version": "1.18.13", "name": "@opencode-ai/llm", "type": "module", "license": "MIT", diff --git a/packages/opencode/package.json b/packages/opencode/package.json index 663df3898dac..ed11f8ca9330 100644 --- a/packages/opencode/package.json +++ b/packages/opencode/package.json @@ -1,6 +1,6 @@ { "$schema": "https://json.schemastore.org/package.json", - "version": "1.18.12", + "version": "1.18.13", "name": "opencode", "type": "module", "license": "MIT", diff --git a/packages/plugin/package.json b/packages/plugin/package.json index 60918bdcfc2c..7b46a9ccf398 100644 --- a/packages/plugin/package.json +++ b/packages/plugin/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/plugin", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/sdk/js/package.json b/packages/sdk/js/package.json index a42f99152516..007e3f571ac3 100644 --- a/packages/sdk/js/package.json +++ b/packages/sdk/js/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/sdk", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/server/package.json b/packages/server/package.json index 0d8d4cf0366f..f7c7a0f3037b 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/server", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/session-ui/package.json b/packages/session-ui/package.json index da940b879e59..1fbf52314a9b 100644 --- a/packages/session-ui/package.json +++ b/packages/session-ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/session-ui", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/slack/package.json b/packages/slack/package.json index 2f5eb2544fdd..af88eb2aec4a 100644 --- a/packages/slack/package.json +++ b/packages/slack/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/slack", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "scripts": { diff --git a/packages/stats/app/package.json b/packages/stats/app/package.json index 6aba2a05a526..af8f31e739c7 100644 --- a/packages/stats/app/package.json +++ b/packages/stats/app/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-app", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/core/package.json b/packages/stats/core/package.json index d650e092ee2c..a1c22694fde7 100644 --- a/packages/stats/core/package.json +++ b/packages/stats/core/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-core", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/stats/server/package.json b/packages/stats/server/package.json index 725d79f33559..811a597fb619 100644 --- a/packages/stats/server/package.json +++ b/packages/stats/server/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/stats-server", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/tui/package.json b/packages/tui/package.json index 5021769870f0..43c9cd641828 100644 --- a/packages/tui/package.json +++ b/packages/tui/package.json @@ -1,7 +1,7 @@ { "$schema": "https://json.schemastore.org/package.json", "name": "@opencode-ai/tui", - "version": "1.18.12", + "version": "1.18.13", "private": true, "type": "module", "license": "MIT", diff --git a/packages/ui/package.json b/packages/ui/package.json index 7409562b7e6c..ae2a57b4362f 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@opencode-ai/ui", - "version": "1.18.12", + "version": "1.18.13", "type": "module", "license": "MIT", "repository": { diff --git a/packages/web/package.json b/packages/web/package.json index ceb878ef2803..4f781d1588bc 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -2,7 +2,7 @@ "name": "@opencode-ai/web", "type": "module", "license": "MIT", - "version": "1.18.12", + "version": "1.18.13", "scripts": { "dev": "astro dev", "dev:remote": "VITE_API_URL=https://api.opencode.ai astro dev", diff --git a/sdks/vscode/package.json b/sdks/vscode/package.json index ac8ab9e3670d..49b3369e244f 100644 --- a/sdks/vscode/package.json +++ b/sdks/vscode/package.json @@ -2,7 +2,7 @@ "name": "opencode", "displayName": "opencode", "description": "opencode for VS Code", - "version": "1.18.12", + "version": "1.18.13", "publisher": "sst-dev", "repository": { "type": "git", From 703d09f3069e9338f701b7ceee319a41c7dccb9c Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 4 Aug 2026 20:43:45 +0700 Subject: [PATCH 218/333] fix(server): don't forward host directory to remote workspace (#40136) Co-authored-by: Claude Opus 4.8 --- packages/opencode/src/server/shared/workspace-routing.ts | 7 +++++++ packages/opencode/test/server/workspace-routing.test.ts | 7 +++++++ 2 files changed, 14 insertions(+) diff --git a/packages/opencode/src/server/shared/workspace-routing.ts b/packages/opencode/src/server/shared/workspace-routing.ts index 0edfb2b30542..de4dc672031e 100644 --- a/packages/opencode/src/server/shared/workspace-routing.ts +++ b/packages/opencode/src/server/shared/workspace-routing.ts @@ -34,5 +34,12 @@ export function workspaceProxyURL(target: string | URL, requestURL: URL) { proxyURL.search = requestURL.search proxyURL.hash = requestURL.hash proxyURL.searchParams.delete("workspace") + // The `directory` param is the *host's* working directory (e.g. a Windows + // path like `F:\proj`). It is meaningless — and dangerous — on the remote: + // the sandbox would `path.resolve` it against its own cwd, producing a bogus + // path like `/home/daytona/workspace/repo/F:\proj` that does not exist and + // crashes prompt handling. Drop it so the remote falls back to its own + // project root. This mirrors ProxyUtil.headers stripping `x-opencode-directory`. + proxyURL.searchParams.delete("directory") return proxyURL } diff --git a/packages/opencode/test/server/workspace-routing.test.ts b/packages/opencode/test/server/workspace-routing.test.ts index 9ae0f3e632c4..29c22038c3b6 100644 --- a/packages/opencode/test/server/workspace-routing.test.ts +++ b/packages/opencode/test/server/workspace-routing.test.ts @@ -80,6 +80,13 @@ describe("workspaceProxyURL", () => { expect(result.searchParams.get("keep")).toBe("yes") }) + test("strips the host directory param so the remote resolves its own root", () => { + const url = new URL("http://localhost/session/abc?directory=F%3A%5Cproj&keep=yes") + const result = workspaceProxyURL("http://remote:8080/base", url) + expect(result.searchParams.get("directory")).toBeNull() + expect(result.searchParams.get("keep")).toBe("yes") + }) + test("preserves hash from request", () => { const url = new URL("http://localhost/page#section") const result = workspaceProxyURL("http://remote:8080", url) From f0afb6750e63ee0a60b052914531bde0afb9bc2b Mon Sep 17 00:00:00 2001 From: James Murdza Date: Tue, 4 Aug 2026 20:43:59 +0700 Subject: [PATCH 219/333] fix(server): log upstream 5xx bodies from proxied workspace requests (#40135) Co-authored-by: Claude Opus 4.8 --- .../instance/httpapi/middleware/proxy.ts | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts index e5362f8cbe13..d21ab5647c1e 100644 --- a/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts +++ b/packages/opencode/src/server/routes/instance/httpapi/middleware/proxy.ts @@ -97,6 +97,29 @@ export function http( headers.delete("content-encoding") headers.delete("content-length") + // An upstream 5xx from a remote workspace sandbox arrives here as an opaque + // status — its real cause (and log line) live only inside the sandbox. Buffer + // the small error body, log it locally so it shows up in the host's log, and + // forward it unchanged (preserving content-type so the client can still parse + // the structured error, e.g. its `ref`). + if (response.status >= 500) { + const body = yield* response.text.pipe(Effect.catch(() => Effect.succeed(""))) + const contentType = response.headers["content-type"] ?? "application/json" + headers.delete("content-type") + yield* Effect.logError("workspace proxy upstream error", { + url: url.toString(), + method: request.method, + status: response.status, + body: body.slice(0, 2000), + }) + return HttpServerResponse.text(body, { + status: response.status, + statusText: statusText(response), + headers, + contentType, + }) + } + return HttpServerResponse.stream(response.stream.pipe(Stream.catchCause(() => Stream.empty)), { status: response.status, statusText: statusText(response), From 98dd65cd60f72bb257ae894f655d3cfe67c961d5 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 00:23:06 +0000 Subject: [PATCH 220/333] fix(app): collapse deletion-only edit parts (#40536) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- .../src/components/message-part.tsx | 11 +--- .../src/components/part-default-open.test.ts | 66 +++++++++++++++++++ .../src/components/part-default-open.ts | 26 ++++++++ 3 files changed, 94 insertions(+), 9 deletions(-) create mode 100644 packages/session-ui/src/components/part-default-open.test.ts create mode 100644 packages/session-ui/src/components/part-default-open.ts diff --git a/packages/session-ui/src/components/message-part.tsx b/packages/session-ui/src/components/message-part.tsx index c5e6cafdae5f..28de8847874f 100644 --- a/packages/session-ui/src/components/message-part.tsx +++ b/packages/session-ui/src/components/message-part.tsx @@ -61,6 +61,7 @@ import { TextShimmer } from "@opencode-ai/ui/text-shimmer" import { AnimatedCountList } from "./tool-count-summary" import { ToolStatusTitle } from "./tool-status-title" import { patchFiles } from "./apply-patch-file" +import { partDefaultOpen } from "./part-default-open" import { animate } from "motion" import { attached, inline, kind, typeLabel } from "./message-file" import { readPartText } from "./message-part-text" @@ -718,15 +719,7 @@ export function renderable(part: PartType, showReasoningSummaries = true) { return !!PART_MAPPING[part.type] } -function toolDefaultOpen(tool: string, shell = false, edit = false) { - 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) { - if (part.type !== "tool") return - return toolDefaultOpen(part.tool, shell, edit) -} +export { partDefaultOpen } from "./part-default-open" export function AssistantParts(props: { messages: AssistantMessage[] diff --git a/packages/session-ui/src/components/part-default-open.test.ts b/packages/session-ui/src/components/part-default-open.test.ts new file mode 100644 index 000000000000..2fc581543953 --- /dev/null +++ b/packages/session-ui/src/components/part-default-open.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, test } from "bun:test" +import type { Part as PartType } from "@opencode-ai/sdk/v2" +import { partDefaultOpen } from "./part-default-open" + +describe("partDefaultOpen", () => { + test("keeps edited files expanded when enabled", () => { + expect(partDefaultOpen(tool("edit", { filediff: { additions: 1, deletions: 1 } }), false, true)).toBe(true) + }) + + test("collapses deletion-only edits when enabled", () => { + expect(partDefaultOpen(tool("edit", { filediff: { additions: 0, deletions: 1_200 } }), false, true)).toBe(false) + }) + + test("collapses patches containing only deleted files when enabled", () => { + expect( + partDefaultOpen( + tool("apply_patch", { + files: [ + { filePath: "one.ts", type: "delete" }, + { filePath: "two.ts", type: "delete" }, + ], + }), + false, + true, + ), + ).toBe(false) + }) + + test("keeps mixed patches expanded when enabled", () => { + expect( + partDefaultOpen( + tool("apply_patch", { + files: [ + { filePath: "one.ts", type: "delete" }, + { filePath: "two.ts", type: "update" }, + ], + }), + false, + true, + ), + ).toBe(true) + }) + + test("preserves shell defaults", () => { + expect(partDefaultOpen(tool("shell", {}), true, false)).toBe(true) + }) +}) + +function tool(name: string, metadata: Record): PartType { + return { + id: `part_${name}`, + sessionID: "session", + messageID: "message", + type: "tool", + callID: `call_${name}`, + tool: name, + state: { + status: "completed", + input: {}, + output: "", + title: name, + metadata, + time: { start: 0, end: 1 }, + }, + } +} diff --git a/packages/session-ui/src/components/part-default-open.ts b/packages/session-ui/src/components/part-default-open.ts new file mode 100644 index 000000000000..57a667284577 --- /dev/null +++ b/packages/session-ui/src/components/part-default-open.ts @@ -0,0 +1,26 @@ +import type { Part as PartType, ToolPart } from "@opencode-ai/sdk/v2" + +function deletionOnly(part: ToolPart) { + if (!("metadata" in part.state)) return false + const metadata = part.state.metadata + if (!metadata) return false + + const files = metadata.files + if (Array.isArray(files) && files.length > 0) { + return files.every((file) => !!file && typeof file === "object" && "type" in file && file.type === "delete") + } + + const filediff = metadata.filediff + if (!filediff || typeof filediff !== "object") return false + if (!("additions" in filediff) || !("deletions" in filediff)) return false + return filediff.additions === 0 && typeof filediff.deletions === "number" && filediff.deletions > 0 +} + +export function partDefaultOpen(part: PartType, shell = false, edit = false) { + if (part.type !== "tool") return + if (part.tool === "bash" || part.tool === "shell") return shell + if (part.tool === "edit" || part.tool === "write" || part.tool === "patch" || part.tool === "apply_patch") { + if (!edit) return false + return !deletionOnly(part) + } +} From 66fdd51f0d6db8e47e876721c855ea155043b74c Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 5 Aug 2026 11:01:10 +1000 Subject: [PATCH 221/333] docs: add RTL development skill (#40543) --- .../skills/rtl-aware-development/SKILL.md | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) create mode 100644 .opencode/skills/rtl-aware-development/SKILL.md diff --git a/.opencode/skills/rtl-aware-development/SKILL.md b/.opencode/skills/rtl-aware-development/SKILL.md new file mode 100644 index 000000000000..595c981ead81 --- /dev/null +++ b/.opencode/skills/rtl-aware-development/SKILL.md @@ -0,0 +1,63 @@ +--- +name: rtl-aware-development +description: OpenCode Desktop should be RTL-aware. Use when implementing or reviewing RTL/LTR behavior in the web app, desktop app, CSS, menus, scrolling, resizing, icons, mixed-direction text, or Electron title bars. +--- + +# RTL-Aware Development + +Treat direction as independent from language. Test English in both directions as well as real RTL and mixed-script content. + +## Guidelines + +- Set `lang` and `dir` on the document, and propagate direction through component providers used by portaled menus and popovers. Do not change the selected locale merely to force RTL. +- Keep DOM and focus order semantic. Flexbox and Grid already follow `dir`; do not add `row-reverse`, CSS `order`, or reversed markup just to mirror a layout. +- Prefer logical CSS for semantic layout. Reserve physical coordinates for pointer positions, canvas geometry, native window controls, and other genuinely physical placement. + +```css +/* Avoid */ +padding-left: 12px; +right: 0; +border-right: 1px solid; +text-align: left; + +/* Prefer */ +padding-inline-start: 12px; +inset-inline-end: 0; +border-inline-end: 1px solid; +text-align: start; +``` + +- Isolate mixed-direction text. Use `dir="auto"` or `` for unknown text; keep code, URLs, IDs, and filesystem paths LTR without forcing the surrounding component LTR. + +```html +README.md C:\src\app.ts +``` + +- Mirror directional meaning, not every image. Back/forward, previous/next, disclosure, indentation, and directional progress may need mirroring. Do not mirror brands, clocks, media controls, charts, or text. Reverse physical gradients, `translateX`, SVG transforms, and animation deltas explicitly. +- Map interactions through direction. `clientX` remains physical; resizing a logical edge needs an RTL-aware delta. Logical previous/next keyboard controls may swap ArrowLeft/ArrowRight. Follow the relevant WAI-ARIA widget pattern. +- Do not assume LTR scrolling. RTL `scrollLeft` can start at `0` and become negative. Prefer `scrollIntoView({ inline: "nearest" })` or a tested direction-normalizing helper. +- For Electron title bars, prefer native caption controls and use `titleBarOverlay` plus `env(titlebar-area-*)` for the safe content rectangle. Keep Windows/macOS native-control avoidance and `trafficLightPosition` physical; keep app navigation inside that rectangle logical. Mark interactive titlebar children `app-region: no-drag`. +- Verify behavior, not screenshots alone. Check computed styles, pseudo-element geometry, hit zones, focus order, keyboard behavior, submenu direction, zoom/scaling, and both LTR and RTL scroll endpoints. + +## Test Matrix + +- English + LTR +- English + forced RTL +- A real RTL locale + RTL +- Mixed RTL/LTR content, long labels, numbers, code, and paths +- Keyboard, pointer resize, scrolling, menus/submenus, and Electron titlebar controls in both directions + +## References + +- [RTL Styling 101, Ahmad Shadeed](https://rtlstyling.com/posts/rtl-styling/) +- [CSS-Tricks: RTL Styling 101](https://css-tricks.com/rtl-styling-101/) +- [CSS-Tricks: CSS Logical Properties and Values](https://css-tricks.com/css-logical-properties-and-values/) +- [W3C: Structural markup and right-to-left text](https://www.w3.org/International/questions/qa-html-dir) +- [W3C: Inline bidirectional markup](https://www.w3.org/International/articles/inline-bidi-markup/) +- [MDN: CSS logical properties and values](https://developer.mozilla.org/en-US/docs/Web/CSS/Guides/Logical_properties_and_values) +- [MDN: `dir`](https://developer.mozilla.org/en-US/docs/Web/HTML/Reference/Global_attributes/dir) +- [MDN: `scrollLeft`](https://developer.mozilla.org/en-US/docs/Web/API/Element/scrollLeft) +- [web.dev: Logical properties](https://web.dev/learn/css/logical-properties/) +- [Electron: Custom title bar](https://www.electronjs.org/docs/latest/tutorial/custom-title-bar) +- [WAI-ARIA: Window splitter pattern](https://www.w3.org/WAI/ARIA/apg/patterns/windowsplitter/) +- [Kobalte: I18n Provider](https://kobalte.dev/docs/core/components/i18n-provider/) From cb88db6ce31dfbf52b2462258b42a607758e200a Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:18:44 -0500 Subject: [PATCH 222/333] tweak(opencode): make xAI OAuth device-only to reduce confusion w/ headless environments (#40537) --- packages/opencode/src/plugin/xai.ts | 293 +------------------- packages/opencode/test/plugin/xai.test.ts | 37 +-- packages/web/src/content/docs/providers.mdx | 30 +- 3 files changed, 15 insertions(+), 345 deletions(-) diff --git a/packages/opencode/src/plugin/xai.ts b/packages/opencode/src/plugin/xai.ts index 23233b5df79e..a455121d6aed 100644 --- a/packages/opencode/src/plugin/xai.ts +++ b/packages/opencode/src/plugin/xai.ts @@ -1,14 +1,9 @@ import type { Hooks, PluginInput } from "@opencode-ai/plugin" import { OAUTH_DUMMY_KEY } from "../auth" -import { createServer } from "http" import { InstallationVersion } from "@opencode-ai/core/installation/version" -import { OauthCallbackPage } from "@opencode-ai/core/oauth/page" -// Public Grok-CLI OAuth client. xAI's auth server rejects loopback OAuth from -// non-allowlisted clients, so we reuse the Grok-CLI client_id that xAI ships -// for desktop OAuth flows. Source of truth: hermes-agent PR #26534. +// Public Grok-CLI OAuth client. const CLIENT_ID = "b1a00492-073a-47ea-816f-4c329264a828" -const AUTHORIZE_URL = "https://auth.x.ai/oauth2/authorize" const TOKEN_URL = "https://auth.x.ai/oauth2/token" // RFC 8628 device authorization grant. Confirmed exposed by xAI's // /.well-known/openid-configuration as `device_authorization_endpoint` @@ -30,51 +25,15 @@ const DEVICE_CODE_SLOW_DOWN_INCREMENT_MS = 5_000 const DEVICE_CODE_DEFAULT_EXPIRES_MS = 5 * 60 * 1000 const OAUTH_POLLING_SAFETY_MARGIN_MS = 3_000 -// xAI rejects redirect_uris that don't match what was registered for the -// Grok-CLI client. The host:port pair is part of the registration, so we have -// to bind the loopback server to this exact port. -const OAUTH_HOST = "127.0.0.1" -const OAUTH_PORT = 56121 -const OAUTH_REDIRECT_PATH = "/callback" -const REDIRECT_URI = `http://${OAUTH_HOST}:${OAUTH_PORT}${OAUTH_REDIRECT_PATH}` - // Refresh the access token a little before it actually expires so a single // long-running tool call doesn't have to recover from a mid-flight 401. const ACCESS_TOKEN_REFRESH_SKEW_MS = 120_000 interface XaiAuthPluginOptions { - authorizeUrl?: string tokenUrl?: string deviceAuthorizationUrl?: string } -interface PkceCodes { - verifier: string - challenge: string -} - -async function generatePKCE(): Promise { - const verifier = generateRandomString(64) - const hash = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)) - return { verifier, challenge: base64UrlEncode(hash) } -} - -function generateRandomString(length: number): string { - const chars = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" - return Array.from(crypto.getRandomValues(new Uint8Array(length))) - .map((b) => chars[b % chars.length]) - .join("") -} - -function base64UrlEncode(buffer: ArrayBuffer): string { - const binary = String.fromCharCode(...new Uint8Array(buffer)) - return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "") -} - -function generateState(): string { - return base64UrlEncode(crypto.getRandomValues(new Uint8Array(32)).buffer) -} - interface TokenResponse { access_token: string refresh_token: string @@ -115,55 +74,6 @@ export function accessTokenIsExpiring( } } -export function buildAuthorizeUrl( - pkce: PkceCodes, - state: string, - nonce: string, - options: XaiAuthPluginOptions = {}, -): string { - // `plan=generic` opts the consent screen into xAI's generic OAuth plan tier; - // without it, accounts.x.ai rejects loopback OAuth from non-allowlisted - // clients. `referrer=opencode` lets xAI attribute opencode-originated - // logins in their OAuth server logs (best-effort attribution while we - // continue to reuse the Grok-CLI client_id). - const params = new URLSearchParams({ - response_type: "code", - client_id: CLIENT_ID, - redirect_uri: REDIRECT_URI, - scope: SCOPE, - code_challenge: pkce.challenge, - code_challenge_method: "S256", - state, - nonce, - plan: "generic", - referrer: "opencode", - }) - return `${options.authorizeUrl ?? AUTHORIZE_URL}?${params.toString()}` -} - -async function exchangeCodeForTokens( - code: string, - pkce: PkceCodes, - options: XaiAuthPluginOptions = {}, -): Promise { - const response = await fetch(options.tokenUrl ?? TOKEN_URL, { - method: "POST", - headers: authHeaders(), - body: new URLSearchParams({ - grant_type: "authorization_code", - code, - redirect_uri: REDIRECT_URI, - client_id: CLIENT_ID, - code_verifier: pkce.verifier, - }).toString(), - }) - if (!response.ok) { - const detail = await response.text().catch(() => "") - throw new Error(`xAI token exchange failed (${response.status})${detail ? `: ${detail}` : ""}`) - } - return response.json() as Promise -} - async function refreshAccessToken(refreshToken: string, options: XaiAuthPluginOptions = {}): Promise { const response = await fetch(options.tokenUrl ?? TOKEN_URL, { method: "POST", @@ -202,6 +112,7 @@ export async function requestDeviceCode(options: XaiAuthPluginOptions = {}): Pro body: new URLSearchParams({ client_id: CLIENT_ID, scope: SCOPE, + referrer: "opencode", }).toString(), }) if (!response.ok) { @@ -285,170 +196,6 @@ export async function pollDeviceCodeToken( throw new Error("xAI device authorization timed out") } -// CORS allowlist for the loopback callback. The redirect_uri itself is -// already bound to 127.0.0.1 and gated by PKCE+state, so we only accept -// xAI's own auth origins for additional defense-in-depth on the OPTIONS -// preflight. -const CORS_ALLOWED_ORIGINS = new Set(["https://accounts.x.ai", "https://auth.x.ai"]) - -interface PendingOAuth { - pkce: PkceCodes - state: string - resolve: (tokens: TokenResponse) => void - reject: (error: Error) => void -} - -let oauthServer: ReturnType | undefined -let pendingOAuth: PendingOAuth | undefined - -async function startOAuthServer(): Promise<{ port: number; redirectUri: string }> { - if (oauthServer) return { port: OAUTH_PORT, redirectUri: REDIRECT_URI } - - const server = createServer((req, res) => { - const reqUrl = req.url || "/" - const url = new URL(reqUrl, `http://${OAUTH_HOST}:${OAUTH_PORT}`) - - const origin = req.headers["origin"] - const allowOrigin = typeof origin === "string" && CORS_ALLOWED_ORIGINS.has(origin) ? origin : "" - if (allowOrigin) { - res.setHeader("Access-Control-Allow-Origin", allowOrigin) - res.setHeader("Access-Control-Allow-Methods", "GET, OPTIONS") - res.setHeader("Access-Control-Allow-Headers", "Content-Type") - res.setHeader("Access-Control-Allow-Private-Network", "true") - res.setHeader("Vary", "Origin") - } - - if (req.method === "OPTIONS") { - res.writeHead(204) - res.end() - return - } - - if (url.pathname === OAUTH_REDIRECT_PATH) { - const code = url.searchParams.get("code") - const state = url.searchParams.get("state") - const error = url.searchParams.get("error") - const errorDescription = url.searchParams.get("error_description") - - if (error) { - const errorMsg = errorDescription || error - pendingOAuth?.reject(new Error(errorMsg)) - pendingOAuth = undefined - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) - return - } - - if (!code) { - const errorMsg = "Missing authorization code" - pendingOAuth?.reject(new Error(errorMsg)) - pendingOAuth = undefined - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) - return - } - - if (!pendingOAuth || state !== pendingOAuth.state) { - const errorMsg = "Invalid state - potential CSRF attack" - pendingOAuth?.reject(new Error(errorMsg)) - pendingOAuth = undefined - res.writeHead(400, { "Content-Type": "text/html" }) - res.end(OauthCallbackPage.error(errorMsg, { provider: "xAI" })) - return - } - - const current = pendingOAuth - pendingOAuth = undefined - - exchangeCodeForTokens(code, current.pkce) - .then((tokens) => current.resolve(tokens)) - .catch((err) => current.reject(err)) - - res.writeHead(200, { "Content-Type": "text/html" }) - res.end(OauthCallbackPage.success({ provider: "xAI" })) - return - } - - if (url.pathname === "/cancel") { - pendingOAuth?.reject(new Error("Login cancelled")) - pendingOAuth = undefined - res.writeHead(200) - res.end("Login cancelled") - return - } - - res.writeHead(404) - res.end("Not found") - }) - - // listen() failures (e.g. EADDRINUSE because Grok-CLI is bound to the same - // pinned port) must clear `oauthServer` and remove our error listener, - // otherwise the next startOAuthServer() short-circuits on the truthy check - // and returns a redirect_uri pointing at nothing. - await new Promise((resolve, reject) => { - const onError = (err: Error) => { - oauthServer = undefined - reject(err) - } - server.once("error", onError) - server.listen(OAUTH_PORT, OAUTH_HOST, () => { - server.removeListener("error", onError) - // After listen() succeeds, install a permanent log-only listener so - // that subsequent server errors (e.g. accept() failures, socket-level - // errors) don't trip Node's default "unhandled error event = throw" - // behavior and crash the entire opencode process. Matches the silent- - // swallow behavior the Codex plugin gets from its permanent - // `oauthServer!.on("error", reject)`. - resolve() - }) - oauthServer = server - }) - - return { port: OAUTH_PORT, redirectUri: REDIRECT_URI } -} - -function stopOAuthServer() { - if (oauthServer) { - oauthServer.close() - oauthServer = undefined - } -} - -function waitForOAuthCallback(pkce: PkceCodes, state: string): Promise { - // A previous in-flight authorize() that the user abandoned (or that is - // being superseded by a fresh attempt) still owns `pendingOAuth`. Reject - // it eagerly so its caller stops waiting on a state value that can never - // match the next callback. - if (pendingOAuth) { - pendingOAuth.reject(new Error("Superseded by a newer xAI authorize request")) - pendingOAuth = undefined - } - return new Promise((resolve, reject) => { - const timeout = setTimeout( - () => { - if (pendingOAuth) { - pendingOAuth = undefined - reject(new Error("OAuth callback timeout - authorization took too long")) - } - }, - 5 * 60 * 1000, - ) - - pendingOAuth = { - pkce, - state, - resolve: (tokens) => { - clearTimeout(timeout) - resolve(tokens) - }, - reject: (error) => { - clearTimeout(timeout) - reject(error) - }, - } - }) -} - interface RefreshResult { access: string refresh: string @@ -548,40 +295,6 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp } }, methods: [ - { - label: "xAI Grok OAuth (SuperGrok Subscription)", - type: "oauth", - authorize: async () => { - await startOAuthServer() - const pkce = await generatePKCE() - const state = generateState() - const nonce = generateState() - const authUrl = buildAuthorizeUrl(pkce, state, nonce, options) - - const callbackPromise = waitForOAuthCallback(pkce, state) - - return { - url: authUrl, - instructions: "Complete authorization in your browser. This window will close automatically.", - method: "auto" as const, - callback: async () => { - try { - const tokens = await callbackPromise - return { - type: "success" as const, - refresh: tokens.refresh_token, - access: tokens.access_token, - expires: Date.now() + (tokens.expires_in ?? 3600) * 1000, - } - } catch (err) { - return { type: "failed" as const } - } finally { - stopOAuthServer() - } - }, - } - }, - }, { // RFC 8628 device-code flow. The CLI prints a verification URL // and a short user_code that the user enters in a browser on @@ -591,7 +304,7 @@ export async function XaiAuthPlugin(input: PluginInput, options: XaiAuthPluginOp // user's browser. Defends the only attack surface (the polling // loop) with the standard authorization_pending / slow_down // backoff and a hard deadline from xAI's `expires_in`. - label: "xAI Grok OAuth (Headless / Remote / VPS)", + label: "SuperGrok Subscription", type: "oauth", authorize: async () => { const device = await requestDeviceCode(options) diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts index 4139471a8bdb..2339a07e91c9 100644 --- a/packages/opencode/test/plugin/xai.test.ts +++ b/packages/opencode/test/plugin/xai.test.ts @@ -1,7 +1,6 @@ import { describe, expect, test } from "bun:test" import { accessTokenIsExpiring, - buildAuthorizeUrl, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin, @@ -76,32 +75,6 @@ describe("plugin.xai", () => { }) }) - describe("buildAuthorizeUrl", () => { - const pkce = { verifier: "ver", challenge: "chal" } - - test("includes required OAuth + PKCE + OIDC params", () => { - const url = new URL(buildAuthorizeUrl(pkce, "state-abc", "nonce-xyz")) - const params = url.searchParams - - expect(url.origin + url.pathname).toBe("https://auth.x.ai/oauth2/authorize") - expect(params.get("response_type")).toBe("code") - expect(params.get("client_id")).toBe("b1a00492-073a-47ea-816f-4c329264a828") - expect(params.get("redirect_uri")).toBe("http://127.0.0.1:56121/callback") - expect(params.get("scope")).toBe("openid profile email offline_access grok-cli:access api:access") - expect(params.get("code_challenge")).toBe("chal") - expect(params.get("code_challenge_method")).toBe("S256") - expect(params.get("state")).toBe("state-abc") - expect(params.get("nonce")).toBe("nonce-xyz") - expect(params.get("plan")).toBe("generic") - expect(params.get("referrer")).toBe("opencode") - }) - - test("supports endpoint override for local integration tests", () => { - const url = new URL(buildAuthorizeUrl(pkce, "s", "n", { authorizeUrl: "http://127.0.0.1/oauth2/authorize" })) - expect(url.origin + url.pathname).toBe("http://127.0.0.1/oauth2/authorize") - }) - }) - describe("loader", () => { test("returns no options unless stored auth is OAuth and exposes methods in order", async () => { const hooks = await XaiAuthPlugin({} as any) @@ -110,8 +83,7 @@ describe("plugin.xai", () => { await hooks.auth!.loader!(async () => ({ type: "wellknown", key: "k", token: "t" }) as any, {} as any), ).toEqual({}) expect(hooks.auth!.methods.map((m) => [m.type, m.label])).toEqual([ - ["oauth", "xAI Grok OAuth (SuperGrok Subscription)"], - ["oauth", "xAI Grok OAuth (Headless / Remote / VPS)"], + ["oauth", "SuperGrok Subscription"], ["api", "Manually enter API Key"], ]) }) @@ -426,7 +398,7 @@ describe("plugin.xai", () => { const hooks = await XaiAuthPlugin({} as any, serverOptions(server)) const headless = hooks.auth!.methods.find( (m): m is Extract => - m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + m.type === "oauth" && m.label === "SuperGrok Subscription", )! const result = await headless.authorize!() @@ -450,7 +422,7 @@ describe("plugin.xai", () => { }) const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( (m): m is Extract => - m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + m.type === "oauth" && m.label === "SuperGrok Subscription", )! expect((await headless.authorize!()).url).toBe("https://x.ai/device") }) @@ -474,6 +446,7 @@ describe("plugin.xai", () => { expect(parsed.get("scope")).toContain("offline_access") expect(parsed.get("scope")).toContain("grok-cli:access") expect(parsed.get("scope")).toContain("api:access") + expect(parsed.get("referrer")).toBe("opencode") await expect( requestDeviceCode({ deviceAuthorizationUrl: new URL("/error", server.url).toString() }), ).rejects.toThrow(/429.*rate limited/) @@ -612,7 +585,7 @@ describe("plugin.xai", () => { }) const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( (m): m is Extract => - m.type === "oauth" && m.label === "xAI Grok OAuth (Headless / Remote / VPS)", + m.type === "oauth" && m.label === "SuperGrok Subscription", )! expect(await ((await headless.authorize!()) as any).callback()).toEqual({ type: "failed" }) }) diff --git a/packages/web/src/content/docs/providers.mdx b/packages/web/src/content/docs/providers.mdx index 96927f44d2a7..a5a17de3a34d 100644 --- a/packages/web/src/content/docs/providers.mdx +++ b/packages/web/src/content/docs/providers.mdx @@ -2308,9 +2308,9 @@ Some useful routing options: ### xAI -Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same SuperGrok subscription via a headless device-code flow (for VPS / SSH / Docker), or a pay-as-you-go API key from the xAI console. +Two ways to authenticate: a SuperGrok subscription via device-code OAuth or a pay-as-you-go API key from the xAI console. -#### Option A — SuperGrok OAuth (browser login) +#### Option A — SuperGrok subscription 1. Run the `/connect` command and search for **xAI**. @@ -2318,9 +2318,11 @@ Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same /connect ``` -2. Select **xAI Grok OAuth (SuperGrok Subscription)**. OpenCode opens xAI's consent screen in your browser and waits for the callback on `http://127.0.0.1:56121/callback`. +2. Select **SuperGrok Subscription**. OpenCode opens xAI's verification link with the user code pre-populated when supported. -3. Run the `/models` command to select a Grok model. +3. Approve the consent screen. If xAI asks for a code, enter the user code displayed by OpenCode. OpenCode polls xAI's token endpoint and stores the resulting OAuth tokens once you approve. + +4. Run the `/models` command to select a Grok model. ```txt /models @@ -2328,25 +2330,7 @@ Three ways to authenticate: a SuperGrok subscription via browser OAuth, the same OpenCode refreshes the OAuth access token automatically. Any Grok or X Premium plan that includes Grok API access works; you do not need a separate `XAI_API_KEY`. -#### Option B — SuperGrok device-code (headless / remote server / VPS) - -Use this when OpenCode is running somewhere a browser can't reach the loopback redirect: a VPS, a remote dev box over SSH, inside Docker, in CI, etc. No callback port is opened on the host running OpenCode — instead xAI hands the CLI a short code that you type into a browser on any other device (laptop, phone, …). - -1. Run the `/connect` command on the remote host and search for **xAI**. - - ```txt - /connect - ``` - -2. Select **xAI Grok OAuth (Headless / Remote / VPS)**. OpenCode prints a verification URL and a short user code. - - ```txt - Open https://x.ai/device on any device and enter code: ABCD-1234 - ``` - -3. Open the URL on a device that has a browser (your laptop or phone), enter the code, and approve the consent screen. OpenCode polls xAI's token endpoint and stores the resulting OAuth tokens once you approve. Token refresh works the same as Option A. - -#### Option C — API key +#### Option B — API key 1. Head over to the [xAI console](https://console.x.ai/), create an account, and generate an API key. From 5b4fb1f7701712d5f02fa0220292958474ed9671 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 02:20:04 +0000 Subject: [PATCH 223/333] chore: generate --- packages/opencode/test/plugin/xai.test.ts | 16 ++++------------ 1 file changed, 4 insertions(+), 12 deletions(-) diff --git a/packages/opencode/test/plugin/xai.test.ts b/packages/opencode/test/plugin/xai.test.ts index 2339a07e91c9..59a9bd920563 100644 --- a/packages/opencode/test/plugin/xai.test.ts +++ b/packages/opencode/test/plugin/xai.test.ts @@ -1,10 +1,5 @@ import { describe, expect, test } from "bun:test" -import { - accessTokenIsExpiring, - pollDeviceCodeToken, - requestDeviceCode, - XaiAuthPlugin, -} from "../../src/plugin/xai" +import { accessTokenIsExpiring, pollDeviceCodeToken, requestDeviceCode, XaiAuthPlugin } from "../../src/plugin/xai" import { OAUTH_DUMMY_KEY } from "../../src/auth" function makeJwt(payload: object): string { @@ -397,8 +392,7 @@ describe("plugin.xai", () => { }) const hooks = await XaiAuthPlugin({} as any, serverOptions(server)) const headless = hooks.auth!.methods.find( - (m): m is Extract => - m.type === "oauth" && m.label === "SuperGrok Subscription", + (m): m is Extract => m.type === "oauth" && m.label === "SuperGrok Subscription", )! const result = await headless.authorize!() @@ -421,8 +415,7 @@ describe("plugin.xai", () => { return new Response("unexpected request", { status: 500 }) }) const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( - (m): m is Extract => - m.type === "oauth" && m.label === "SuperGrok Subscription", + (m): m is Extract => m.type === "oauth" && m.label === "SuperGrok Subscription", )! expect((await headless.authorize!()).url).toBe("https://x.ai/device") }) @@ -584,8 +577,7 @@ describe("plugin.xai", () => { return Response.json({ error: "access_denied" }, { status: 400 }) }) const headless = (await XaiAuthPlugin({} as any, serverOptions(server))).auth!.methods.find( - (m): m is Extract => - m.type === "oauth" && m.label === "SuperGrok Subscription", + (m): m is Extract => m.type === "oauth" && m.label === "SuperGrok Subscription", )! expect(await ((await headless.authorize!()) as any).callback()).toEqual({ type: "failed" }) }) From 9f38562237f3ca4e41eb8a04fd776be3f944742e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Tue, 4 Aug 2026 21:27:01 -0500 Subject: [PATCH 224/333] fix(opencode): include cache writes in ACP usage (#40450) Co-authored-by: Aiden Cline --- packages/opencode/src/acp/service.ts | 2 +- packages/opencode/src/acp/usage.ts | 6 +++++- packages/opencode/test/acp/usage.test.ts | 6 +++--- 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/opencode/src/acp/service.ts b/packages/opencode/src/acp/service.ts index fe2959189780..55fbc9681df3 100644 --- a/packages/opencode/src/acp/service.ts +++ b/packages/opencode/src/acp/service.ts @@ -656,7 +656,7 @@ function makeUsageService(sdk: OpencodeClient) { sessionId: params.sessionID, update: { sessionUpdate: "usage_update", - used: message.tokens.input + message.tokens.cache.read, + used: UsageService.contextTokens(message), size, cost: { amount: UsageService.totalSessionCost(messages), currency: "USD" }, }, diff --git a/packages/opencode/src/acp/usage.ts b/packages/opencode/src/acp/usage.ts index 737275768e2d..bc17447e6141 100644 --- a/packages/opencode/src/acp/usage.ts +++ b/packages/opencode/src/acp/usage.ts @@ -83,6 +83,10 @@ export function messageLoaderFromSDK(sdk: SDK): MessageLoaderInterface { export const messageLoaderLayer = (sdk: SDK) => Layer.succeed(MessageLoader, messageLoaderFromSDK(sdk)) +export function contextTokens(message: AssistantTokenCost): number { + return message.tokens.input + message.tokens.cache.read + message.tokens.cache.write +} + export function buildUsage(message: AssistantTokenCost): Usage { const cachedReadTokens = message.tokens.cache.read const cachedWriteTokens = message.tokens.cache.write @@ -207,7 +211,7 @@ const layer = Layer.effect( sessionId: input.sessionID, update: { sessionUpdate: "usage_update", - used: message.tokens.input + message.tokens.cache.read, + used: contextTokens(message), size, cost: { amount: totalSessionCost(messages), currency: "USD" }, }, diff --git a/packages/opencode/test/acp/usage.test.ts b/packages/opencode/test/acp/usage.test.ts index 06ccfb1f5061..e9d3b3c62e32 100644 --- a/packages/opencode/test/acp/usage.test.ts +++ b/packages/opencode/test/acp/usage.test.ts @@ -207,7 +207,7 @@ describe("acp usage", () => { ) }) - it.effect("sends ACP usage_update with context size and cumulative assistant cost", () => { + it.effect("includes cache reads and writes in ACP context usage", () => { const updates: SessionNotification[] = [] return Effect.gen(function* () { const usage = yield* UsageService.Service @@ -222,7 +222,7 @@ describe("acp usage", () => { sessionId: "ses_1", update: { sessionUpdate: "usage_update", - used: 15, + used: 22, size: 128_000, cost: { amount: 3, currency: "USD" }, }, @@ -239,7 +239,7 @@ describe("acp usage", () => { input: 10, output: 20, reasoning: 0, - cache: { read: 5, write: 0 }, + cache: { read: 5, write: 7 }, }, }), ]), From 82a579615939e582519457d28fb44048fba4cbb9 Mon Sep 17 00:00:00 2001 From: Luke Parker <10430890+Hona@users.noreply.github.com> Date: Wed, 5 Aug 2026 12:31:45 +1000 Subject: [PATCH 225/333] test(app): harden flaky e2e synchronization (#40556) --- packages/app/e2e/AGENTS.md | 16 +++++ .../performance/timeline-stability/fixture.ts | 4 +- .../regression/review-line-comment.spec.ts | 59 +++++++++------- .../session-timeline-transport.spec.ts | 68 +++++++++++++++---- packages/app/e2e/utils/sse-transport.ts | 15 ++-- 5 files changed, 118 insertions(+), 44 deletions(-) create mode 100644 packages/app/e2e/AGENTS.md diff --git a/packages/app/e2e/AGENTS.md b/packages/app/e2e/AGENTS.md new file mode 100644 index 000000000000..e0c6c006021d --- /dev/null +++ b/packages/app/e2e/AGENTS.md @@ -0,0 +1,16 @@ +## Required Reading + +- Before writing, changing, or reviewing E2E tests, ALWAYS read and follow Playwright's official [Best Practices](https://playwright.dev/docs/best-practices), [Auto-waiting](https://playwright.dev/docs/actionability), and [Assertions](https://playwright.dev/docs/test-assertions) guides. +- Use the official [Locators](https://playwright.dev/docs/locators), [Network](https://playwright.dev/docs/network), and [Test Isolation](https://playwright.dev/docs/browser-contexts) guides when those concerns apply. + +## Test Hygiene + +- Test user-visible behavior with isolated, deterministic data and scoped, unique locators. +- Prefer role, label, text, and explicit test-contract locators. Do not use `.first()` or `.last()` merely to silence strictness errors. +- Use locator actions, Playwright auto-waiting, and web-first assertions for observable readiness and outcomes. +- NEVER use `waitForTimeout`, `setTimeout`, sleeps, animation-frame counts, or other wall-clock delays to synchronize a test. Wait for the specific UI state, request, response, event, or application outcome instead. +- Do not treat navigation, a network response, DOM attachment, or visibility alone as proof that asynchronously rendered UI is ready. Assert the state the next action actually requires. +- Register event and network waits before the action that triggers them. +- Do not retry state-changing actions. Retry idempotent readiness checks, then perform the action once and assert its outcome. +- Keep action and assertion timeouts adaptive. Do not use short timeouts as readiness probes or rely on retries to hide flakes. +- Assert exact outcomes and identities so stale state, duplicate rendering, and interactions with the wrong element cannot pass. diff --git a/packages/app/e2e/performance/timeline-stability/fixture.ts b/packages/app/e2e/performance/timeline-stability/fixture.ts index df67da5a6621..66edece4092e 100644 --- a/packages/app/e2e/performance/timeline-stability/fixture.ts +++ b/packages/app/e2e/performance/timeline-stability/fixture.ts @@ -197,7 +197,9 @@ export async function setupTimeline( ) }, async waitForPart(partID: string) { - await expect(page.locator(`[data-timeline-part-id="${partID}"]`).first()).toBeVisible() + const part = page.locator(`[data-timeline-part-id="${partID}"]`) + await expect(part).toHaveCount(1) + await expect(part).toBeVisible() }, } } diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 7850f7820ac1..852a9e58f5ae 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -18,6 +18,7 @@ test("opens the comment editor when code is clicked", async ({ page }) => { await line.click() await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") }) test("opens the comment editor when a line number is clicked", async ({ page }) => { @@ -27,6 +28,7 @@ test("opens the comment editor when a line number is clicked", async ({ page }) await lineNumber.click() await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") }) test("opens the comment editor for a line number range", async ({ page }) => { @@ -36,15 +38,10 @@ test("opens the comment editor for a line number range", async ({ page }) => { await expectAppVisible(start) await expectAppVisible(end) - const from = await start.boundingBox() - const to = await end.boundingBox() - if (!from || !to) throw new Error("Missing line number bounds") - await page.mouse.move(from.x + from.width / 2, from.y + from.height / 2) - await page.mouse.down() - await page.mouse.move(to.x + to.width / 2, to.y + to.height / 2) - await page.mouse.up() + await start.dragTo(end) await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on lines 1-3") }) test("shows a comment button when a line number is hovered", async ({ page }) => { @@ -54,31 +51,40 @@ test("shows a comment button when a line number is hovered", async ({ page }) => const comment = review.getByRole("button", { name: "Comment", exact: true }) await expect(async () => { - await page.mouse.move(0, 0) await lineNumber.hover() - await expect(comment).toBeVisible({ timeout: 500 }) - await comment.click({ timeout: 500 }) - }).toPass() + await expect(lineNumber).toHaveAttribute("data-hovered", "") + await expect(comment).toHaveCount(1) + await expect(comment).toHaveCSS("pointer-events", "auto") + await comment.focus() + await expect(comment).toBeFocused() + }).toPass({ timeout: 10_000 }) + await comment.press("Enter") await expect(review.getByRole("textbox")).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 1") }) test("stages a submitted line comment in the prompt context", async ({ page }) => { - const requests: string[] = [] page.on("request", (request) => { - if (request.method() !== "GET") requests.push(`${request.method()} ${new URL(request.url()).pathname}`) + expect + .soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`) + .toBe("GET") }) const review = page.locator('[data-component="session-review"]') await review.getByText("export const value = 'after'", { exact: true }).click() - await review.getByRole("textbox").fill("Use the existing value instead") - await review.locator('[data-slot="line-comment-action"][data-variant="primary"]').click() + const textbox = review.getByRole("textbox") + await expect(textbox).toBeVisible() + await expect(review.locator('[data-slot="line-comment-editor-label"]')).toHaveText("Commenting on line 2") + await textbox.fill("Use the existing value instead") + const submit = review.locator('[data-slot="line-comment-action"][data-variant="primary"]') + await expect(submit).toBeEnabled() + await submit.click() await expect(review.getByText("Use the existing value instead", { exact: true })).toBeVisible() await page.getByRole("tab", { name: "Session" }).click() const context = page.getByText("Use the existing value instead", { exact: true }).last() await expect(context).toBeVisible() await expect(context.locator("..")).toContainText("review.ts:2") - expect(requests).toEqual([]) }) async function openReview(page: Page) { @@ -144,15 +150,22 @@ 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 === "/api/vcs/diff") - await page.getByRole("tab", { name: "Changes" }).click() + const changes = page.getByRole("tab", { name: "Changes" }) + const diffResponse = page.waitForResponse( + (response) => + response.request().method() === "GET" && response.ok() && new URL(response.url()).pathname === "/api/vcs/diff", + ) + await changes.click() expect((await (await diffResponse).json()).data).toHaveLength(1) + await expect(page.getByRole("tab", { selected: true })).toHaveAccessibleName(/Files Changed/) const review = page.locator('[data-component="session-review"]') await expectAppVisible(review) - await review - .getByRole("heading", { name: /review\.ts/ }) - .getByRole("button") - .first() - .click() + const file = review.locator('[data-file="src/review.ts"]') + await expectAppVisible(file) + const trigger = file.getByRole("button", { expanded: false }) + await expect(trigger).toHaveCount(1) + await trigger.click() + await expect(file.getByRole("button", { expanded: true })).toBeVisible() + await expect(file.getByText("export const value = 'after'", { exact: true })).toBeVisible() } diff --git a/packages/app/e2e/regression/session-timeline-transport.spec.ts b/packages/app/e2e/regression/session-timeline-transport.spec.ts index 778ff3a3af94..359804997876 100644 --- a/packages/app/e2e/regression/session-timeline-transport.spec.ts +++ b/packages/app/e2e/regression/session-timeline-transport.spec.ts @@ -1,9 +1,8 @@ -import { expect, test } from "@playwright/test" +import { expect, test, type Page } from "@playwright/test" import { assistantMessage, partUpdated, setupTimeline, - status, textPart, userMessage, } from "../performance/timeline-stability/fixture" @@ -17,7 +16,7 @@ test("keeps one connection open while delivering multiple events", async ({ page await timeline.waitForPart("prt_transport_first") await timeline.waitForPart("prt_transport_second") expect(first.connectionID).toBe(second.connectionID) - expect(await timeline.transport.connections()).toHaveLength(1) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) expect(await timeline.transport.acknowledgements()).toHaveLength(2) }) @@ -51,20 +50,28 @@ test("parses split JSON and a split multibyte code point", async ({ page }) => { }) test("delivers server heartbeat without mutating the timeline", async ({ page }) => { + const sentinelID = "prt_transport_heartbeat_sentinel" const timeline = await setupTimeline(page, { messages: [userMessage(), assistantMessage([textPart("prt_transport_steady", "steady")])], }) - const before = await page.locator("[data-timeline-row]").allTextContents() - - await timeline.transport.heartbeat() - await timeline.settle() - - expect(await page.locator("[data-timeline-row]").allTextContents()).toEqual(before) - expect(await timeline.transport.connections()).toHaveLength(1) + await timeline.waitForPart("prt_transport_steady") + const before = await stableTimelineRows(page) + + await timeline.transport.writeRaw(": heartbeat\n\n") + await timeline.transport.send(partUpdated(textPart(sentinelID, "heartbeat processed"))) + await timeline.waitForPart(sentinelID) + + await expect + .poll(async () => { + const rows = await timelineRows(page) + return rows.filter((row) => before.some((item) => item.key === row.key)) + }) + .toEqual(before) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) }) test("reconnects after a clean close", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) + const timeline = await setupTimeline(page) const first = await timeline.transport.waitForConnection() await timeline.transport.close() @@ -77,20 +84,21 @@ test("reconnects after a clean close", async ({ page }) => { }) test("reconnects after a stream error", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10 }) + const timeline = await setupTimeline(page) const first = await timeline.transport.waitForConnection() await timeline.transport.error("contract failure") const second = await timeline.transport.waitForConnection({ after: first.id }) - await timeline.transport.send(status("busy")) + await timeline.transport.send(partUpdated(textPart("prt_transport_error", "after error"))) + await timeline.waitForPart("prt_transport_error") await expect.poll(async () => (await timeline.transport.connections()).length).toBe(2) expect(second.id).toBeGreaterThan(first.id) expect((await timeline.transport.connections())[0]?.endedBy).toBe("error") }) test("does not request replay when reconnecting the volatile V2 event stream", async ({ page }) => { - const timeline = await setupTimeline(page, { eventRetry: 10, protocol: "v2" }) + const timeline = await setupTimeline(page, { protocol: "v2" }) const first = await timeline.transport.send(partUpdated(textPart("prt_transport_id", "event with id")), { id: "timeline-event-7", }) @@ -112,5 +120,35 @@ test("passes through non-event fetches", async ({ page }) => { }) expect(health).toEqual({ healthy: true }) - expect(await timeline.transport.connections()).toHaveLength(1) + await expect.poll(async () => (await timeline.transport.connections()).length).toBe(1) }) + +async function stableTimelineRows(page: Page) { + let previous: Awaited> | undefined + let stable = 0 + await expect + .poll( + async () => { + const next = await timelineRows(page) + stable = JSON.stringify(next) === JSON.stringify(previous) ? stable + 1 : 0 + previous = next + return stable + }, + { intervals: [50, 50, 100] }, + ) + .toBeGreaterThanOrEqual(2) + return previous! +} + +function timelineRows(page: Page) { + return page.locator("[data-timeline-key]").evaluateAll((elements) => + elements.map((element) => ({ + key: element.getAttribute("data-timeline-key"), + row: element.querySelector("[data-timeline-row]")?.getAttribute("data-timeline-row"), + parts: Array.from(element.querySelectorAll("[data-timeline-part-id]"), (part) => + part.getAttribute("data-timeline-part-id"), + ), + text: element.textContent, + })), + ) +} diff --git a/packages/app/e2e/utils/sse-transport.ts b/packages/app/e2e/utils/sse-transport.ts index b0e3b74c6d9a..a0a20a2e17f2 100644 --- a/packages/app/e2e/utils/sse-transport.ts +++ b/packages/app/e2e/utils/sse-transport.ts @@ -247,18 +247,23 @@ export async function installSseTransport( return { server, async waitForConnection(input = {}) { - await page.waitForFunction( + const connection = await page.waitForFunction( (after) => { const transport = (window as BrowserTransport).__testSseTransport const connections = transport?.command({ type: "connections" }) as SseConnectionRecord[] | undefined - return connections?.some((connection) => connection.id > after) + return connections?.findLast((connection) => connection.id > after && connection.endedAt === undefined) }, input.after ?? 0, { timeout: input.timeout }, ) - return (await command({ type: "connections" })).findLast( - (connection) => connection.id > (input.after ?? 0), - )! + let result: SseConnectionRecord | undefined + try { + result = await connection.jsonValue() + } finally { + await connection.dispose() + } + if (!result) throw new Error("SSE transport connection disappeared while waiting") + return result }, send(payload, eventOptions) { return command({ type: "send", deliveries: [{ payload, options: eventOptions }], burst: false }) From b8ea3ea091ea2b62b589d43af8b10e811195e39f Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 02:33:08 +0000 Subject: [PATCH 226/333] chore: generate --- packages/app/e2e/regression/review-line-comment.spec.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/app/e2e/regression/review-line-comment.spec.ts b/packages/app/e2e/regression/review-line-comment.spec.ts index 852a9e58f5ae..3e50dab65d93 100644 --- a/packages/app/e2e/regression/review-line-comment.spec.ts +++ b/packages/app/e2e/regression/review-line-comment.spec.ts @@ -65,9 +65,7 @@ test("shows a comment button when a line number is hovered", async ({ page }) => test("stages a submitted line comment in the prompt context", async ({ page }) => { page.on("request", (request) => { - expect - .soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`) - .toBe("GET") + expect.soft(request.method(), `unexpected ${request.method()} ${new URL(request.url()).pathname}`).toBe("GET") }) const review = page.locator('[data-component="session-review"]') From 2f17fc9613771af3de3b5a2715b836037d80c4b1 Mon Sep 17 00:00:00 2001 From: Jack Date: Wed, 5 Aug 2026 12:49:28 +0800 Subject: [PATCH 227/333] docs(zen): add LongCat free model (#40585) --- 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 0cc94de6efa2..86486c4560ed 100644 --- a/packages/web/src/content/docs/ar/zen.mdx +++ b/packages/web/src/content/docs/ar/zen.mdx @@ -112,6 +112,7 @@ OpenCode Zen هي بوابة AI تتيح لك الوصول إلى هذه الن | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -141,6 +142,7 @@ https://opencode.ai/zen/v1/models | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 - MiMo-V2.5 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Laguna S 2.1 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Ling-3.0-flash Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. +- LongCat-2.0 Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - North Mini Code Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Nemotron 3 Ultra Free متاح على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. - Big Pickle نموذج خفي ومتاح مجانا على OpenCode لفترة محدودة. يستخدم الفريق هذه الفترة لجمع الملاحظات وتحسين النموذج. @@ -275,6 +278,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Laguna S 2.1 Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. - Ling-3.0-flash Free: خلال فترته المجانية، قد تُستخدم البيانات المجمعة لتحسين النموذج. +- LongCat-2.0 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 17cf73581b6e..9372b8a734fb 100644 --- a/packages/web/src/content/docs/bs/zen.mdx +++ b/packages/web/src/content/docs/bs/zen.mdx @@ -117,6 +117,7 @@ Našim modelima možete pristupiti i preko sljedećih API endpointa. | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ Podržavamo pay-as-you-go model. Ispod su cijene **po 1M tokena**. | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ Besplatni modeli: - 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. +- LongCat-2.0 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. @@ -287,6 +290,7 @@ i ne koriste vaše podatke za treniranje modela, uz sljedeće izuzetke: - 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. +- LongCat-2.0 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 e200e14e60da..e67c37c3d3ab 100644 --- a/packages/web/src/content/docs/da/zen.mdx +++ b/packages/web/src/content/docs/da/zen.mdx @@ -117,6 +117,7 @@ Du kan også få adgang til vores modeller gennem følgende API-endpoints. | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ Vi understøtter en pay-as-you-go-model. Nedenfor er priserne **pr. 1M tokens**. | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ De gratis modeller: - 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. +- LongCat-2.0 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. @@ -285,6 +288,7 @@ Alle vores modeller hostes i US. Vores udbydere følger en nul-opbevaringspoliti - 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. +- LongCat-2.0 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 1fa6cbc7eb47..72cd654da986 100644 --- a/packages/web/src/content/docs/de/zen.mdx +++ b/packages/web/src/content/docs/de/zen.mdx @@ -108,6 +108,7 @@ Du kannst auch über die folgenden API-Endpunkte auf unsere Modelle zugreifen. | 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` | +| LongCat-2.0 Free | longcat-2.0-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 @@ Wir unterstützen ein Pay-as-you-go-Modell. Unten findest du die Preise **pro 1M | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ Die kostenlosen Modelle: - 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. +- LongCat-2.0 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. @@ -271,6 +274,7 @@ Alle unsere Modelle werden in den USA gehostet. Unsere Provider folgen einer Zer - 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. +- LongCat-2.0 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 539d5999f600..67ed496cc364 100644 --- a/packages/web/src/content/docs/es/zen.mdx +++ b/packages/web/src/content/docs/es/zen.mdx @@ -117,6 +117,7 @@ También puedes acceder a nuestros modelos a través de los siguientes endpoints | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ Admitimos un modelo de pago por uso. A continuación se muestran los precios **p | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ Los modelos gratuitos: - 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. +- LongCat-2.0 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. @@ -285,6 +288,7 @@ Todos nuestros modelos están alojados en US. Nuestros proveedores siguen una po - 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. +- LongCat-2.0 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 7a5d5bcf7b25..93dc2faefc81 100644 --- a/packages/web/src/content/docs/fr/zen.mdx +++ b/packages/web/src/content/docs/fr/zen.mdx @@ -108,6 +108,7 @@ Vous pouvez également accéder à nos modèles via les points de terminaison AP | 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` | +| LongCat-2.0 Free | longcat-2.0-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 @@ Nous prenons en charge un modèle de paiement à l'utilisation. Vous trouverez c | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ Les modèles gratuits : - 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. +- LongCat-2.0 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. @@ -271,6 +274,7 @@ Tous nos modèles sont hébergés aux US. Nos fournisseurs suivent une politique - 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. +- LongCat-2.0 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 941db5be9465..0c28e123fb2c 100644 --- a/packages/web/src/content/docs/it/zen.mdx +++ b/packages/web/src/content/docs/it/zen.mdx @@ -117,6 +117,7 @@ Puoi anche accedere ai nostri modelli tramite i seguenti endpoint API. | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ Supportiamo un modello pay-as-you-go. Qui sotto trovi i prezzi **per 1M token**. | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ I modelli gratuiti: - 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. +- LongCat-2.0 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. @@ -285,6 +288,7 @@ Tutti i nostri modelli sono ospitati negli US. I nostri provider seguono una pol - 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. +- LongCat-2.0 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 73b8d5a18733..6ca86c54bd3b 100644 --- a/packages/web/src/content/docs/ja/zen.mdx +++ b/packages/web/src/content/docs/ja/zen.mdx @@ -108,6 +108,7 @@ OpenCode Zen は、OpenCode のほかのプロバイダーと同じように動 | 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` | +| LongCat-2.0 Free | longcat-2.0-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 | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Laguna S 2.1 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Ling-3.0-flash Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 +- LongCat-2.0 Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - North Mini Code Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Nemotron 3 Ultra Free は期間限定で OpenCode で利用できます。チームはこの期間中にフィードバックを集め、モデルを改善しています。 - Big Pickle はステルスモデルで、期間限定で OpenCode で無料提供されています。チームはこの期間中にフィードバックを集め、モデルを改善しています。 @@ -271,6 +274,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Laguna S 2.1 Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 - Ling-3.0-flash Free: 無料提供期間中、収集されたデータがモデル改善に使われる場合があります。 +- LongCat-2.0 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 474bd38ff4c4..1f81d1428e00 100644 --- a/packages/web/src/content/docs/ko/zen.mdx +++ b/packages/web/src/content/docs/ko/zen.mdx @@ -108,6 +108,7 @@ OpenCode Zen은 OpenCode의 다른 provider와 똑같이 작동합니다. | 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` | +| LongCat-2.0 Free | longcat-2.0-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 | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Laguna S 2.1 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Ling-3.0-flash Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. +- LongCat-2.0 Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - North Mini Code Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Nemotron 3 Ultra Free는 한정된 기간 동안 OpenCode에서 제공됩니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. - Big Pickle은 한정된 기간 동안 OpenCode에서 무료로 제공되는 stealth model입니다. 팀은 이 기간에 피드백을 수집하고 모델을 개선합니다. @@ -271,6 +274,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Laguna S 2.1 Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. - Ling-3.0-flash Free: 무료 제공 기간에는 수집된 데이터가 모델 개선에 사용될 수 있습니다. +- LongCat-2.0 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 74cc45b3a979..99062b0e5c4a 100644 --- a/packages/web/src/content/docs/nb/zen.mdx +++ b/packages/web/src/content/docs/nb/zen.mdx @@ -117,6 +117,7 @@ Du kan også få tilgang til modellene våre gjennom følgende API-endepunkter. | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ Vi støtter en pay-as-you-go-modell. Nedenfor er prisene **per 1M tokens**. | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ Gratis-modellene: - 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. +- LongCat-2.0 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. @@ -285,6 +288,7 @@ Alle modellene våre hostes i US. Leverandørene våre følger en policy for zer - 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. +- LongCat-2.0 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 c6c8ba7fb4f2..807fc9d202c6 100644 --- a/packages/web/src/content/docs/pl/zen.mdx +++ b/packages/web/src/content/docs/pl/zen.mdx @@ -117,6 +117,7 @@ Możesz też uzyskać dostęp do naszych modeli przez poniższe endpointy API. | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ Obsługujemy model pay-as-you-go. Poniżej znajdują się ceny **za 1M tokenów* | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ Darmowe modele: - 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. +- LongCat-2.0 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. @@ -285,6 +288,7 @@ Wszystkie nasze modele są hostowane w US. Nasi dostawcy stosują politykę zero - 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. +- LongCat-2.0 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 fa19e0e7d2ac..2a59aa48e333 100644 --- a/packages/web/src/content/docs/pt-br/zen.mdx +++ b/packages/web/src/content/docs/pt-br/zen.mdx @@ -108,6 +108,7 @@ Você também pode acessar nossos modelos pelos seguintes endpoints de API. | 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` | +| LongCat-2.0 Free | longcat-2.0-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 @@ Oferecemos um modelo pay-as-you-go. Abaixo estão os preços **por 1M tokens**. | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ Os modelos gratuitos: - 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. +- LongCat-2.0 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. @@ -271,6 +274,7 @@ Todos os nossos modelos são hospedados nos US. Nossos provedores seguem uma pol - 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. +- LongCat-2.0 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 f9633ac32302..67e7881bdd46 100644 --- a/packages/web/src/content/docs/ru/zen.mdx +++ b/packages/web/src/content/docs/ru/zen.mdx @@ -117,6 +117,7 @@ OpenCode Zen работает как любой другой провайдер | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ https://opencode.ai/zen/v1/models | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Laguna S 2.1 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Ling-3.0-flash Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. +- LongCat-2.0 Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - North Mini Code Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Nemotron 3 Ultra Free доступна в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. - Big Pickle — это скрытая модель, которая доступна бесплатно в OpenCode ограниченное время. Команда использует это время, чтобы собирать отзывы и улучшать модель. @@ -285,6 +288,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Laguna S 2.1 Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. - Ling-3.0-flash Free: во время бесплатного периода собранные данные могут использоваться для улучшения модели. +- LongCat-2.0 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 2c256704ae99..724b27bdcd92 100644 --- a/packages/web/src/content/docs/th/zen.mdx +++ b/packages/web/src/content/docs/th/zen.mdx @@ -110,6 +110,7 @@ OpenCode Zen ทำงานเหมือน provider อื่น ๆ ใน | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -139,6 +140,7 @@ https://opencode.ai/zen/v1/models | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -215,6 +217,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Laguna S 2.1 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Ling-3.0-flash Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล +- LongCat-2.0 Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - North Mini Code Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Nemotron 3 Ultra Free เปิดให้ใช้บน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล - Big Pickle เป็น stealth model ที่ใช้งานฟรีบน OpenCode ในช่วงเวลาจำกัด ทีมกำลังใช้ช่วงเวลานี้เพื่อเก็บ feedback และปรับปรุงโมเดล @@ -273,6 +276,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Laguna S 2.1 Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล - Ling-3.0-flash Free: ระหว่างช่วงที่เปิดให้ใช้ฟรี ข้อมูลที่เก็บรวบรวมอาจถูกนำไปใช้เพื่อปรับปรุงโมเดล +- LongCat-2.0 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 83f5cfea780e..43e08193b6b3 100644 --- a/packages/web/src/content/docs/tr/zen.mdx +++ b/packages/web/src/content/docs/tr/zen.mdx @@ -108,6 +108,7 @@ Modellerimize aşağıdaki API uç noktaları aracılığıyla da erişebilirsin | 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` | +| LongCat-2.0 Free | longcat-2.0-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 @@ Kullandıkça öde modelini destekliyoruz. Aşağıda **1M token başına** fiya | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ Kredi kartı ücretleri maliyet üzerinden yansıtılır (%4.4 + işlem başına - 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. +- LongCat-2.0 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. @@ -271,6 +274,7 @@ Tüm modellerimiz US'de barındırılıyor. Sağlayıcılarımız zero-retention - 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. +- LongCat-2.0 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 6be3aa7d9928..efadfc568d1e 100644 --- a/packages/web/src/content/docs/zen.mdx +++ b/packages/web/src/content/docs/zen.mdx @@ -117,6 +117,7 @@ You can also access our models through the following API endpoints. | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -148,6 +149,7 @@ We support a pay-as-you-go model. Below are the prices **per 1M tokens**. | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -224,6 +226,7 @@ The free models: - 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. +- LongCat-2.0 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. @@ -285,6 +288,7 @@ All our models are hosted in the US. Our providers follow a zero-retention polic - 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. +- LongCat-2.0 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 f4bb9cc5022e..f4791cc5934b 100644 --- a/packages/web/src/content/docs/zh-cn/zen.mdx +++ b/packages/web/src/content/docs/zh-cn/zen.mdx @@ -108,6 +108,7 @@ OpenCode Zen 的工作方式与 OpenCode 中的任何其他提供商相同。 | 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` | +| LongCat-2.0 Free | longcat-2.0-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 | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -213,6 +215,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Laguna S 2.1 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Ling-3.0-flash Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 +- LongCat-2.0 Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - North Mini Code Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Nemotron 3 Ultra Free 目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 - Big Pickle 是一个隐身模型,目前在 OpenCode 上限时免费提供。团队正在利用这段时间收集反馈并改进模型。 @@ -271,6 +274,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free:在免费期间,收集的数据可能会被用于改进模型。 - Laguna S 2.1 Free:在免费期间,收集的数据可能会被用于改进模型。 - Ling-3.0-flash Free:在免费期间,收集的数据可能会被用于改进模型。 +- LongCat-2.0 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 60b87c88df12..9f68aeea68ae 100644 --- a/packages/web/src/content/docs/zh-tw/zen.mdx +++ b/packages/web/src/content/docs/zh-tw/zen.mdx @@ -112,6 +112,7 @@ OpenCode Zen 的運作方式和 OpenCode 中的其他供應商一樣。 | 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` | +| LongCat-2.0 Free | longcat-2.0-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` | @@ -142,6 +143,7 @@ https://opencode.ai/zen/v1/models | MiMo-V2.5 Free | Free | Free | Free | - | | Laguna S 2.1 Free | Free | Free | Free | - | | Ling-3.0-flash Free | Free | Free | Free | - | +| LongCat-2.0 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 | - | @@ -218,6 +220,7 @@ https://opencode.ai/zen/v1/models - MiMo-V2.5 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Laguna S 2.1 Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 - Ling-3.0-flash Free 在 OpenCode 上限時提供。團隊正在利用這段時間收集回饋並改進模型。 +- LongCat-2.0 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 - MiMo-V2.5 Free: 在免費期間,收集到的資料可能會用於改進模型。 - Laguna S 2.1 Free: 在免費期間,收集到的資料可能會用於改進模型。 - Ling-3.0-flash Free: 在免費期間,收集到的資料可能會用於改進模型。 +- LongCat-2.0 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 4a57013cf8cb163f58638273fd9da8538cd33cb7 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Wed, 5 Aug 2026 08:25:06 +0000 Subject: [PATCH 228/333] fix(app): show pending tool details (#40603) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- packages/session-ui/src/components/basic-tool.tsx | 2 +- packages/session-ui/src/components/message-part.tsx | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/session-ui/src/components/basic-tool.tsx b/packages/session-ui/src/components/basic-tool.tsx index 2b73db24ed12..efc15c173f9f 100644 --- a/packages/session-ui/src/components/basic-tool.tsx +++ b/packages/session-ui/src/components/basic-tool.tsx @@ -204,7 +204,7 @@ export function BasicTool(props: BasicToolProps) { > - + - + {trigger().subtitle} - + {(arg) => {arg}} From f929f8f100581a94f3484b6992d02d57d75fab7f Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 13:05:19 -0500 Subject: [PATCH 229/333] refactor(opencode): simplify retry error matching (#40694) --- packages/opencode/src/session/retry.ts | 37 +++++++------------- packages/opencode/test/session/retry.test.ts | 9 +++-- 2 files changed, 19 insertions(+), 27 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 4139665bd2bd..d1864cb7a8a0 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -122,32 +122,19 @@ export function retryable(error: Err, provider: string) { return { message: error.data.message.includes("Overloaded") ? "Provider is overloaded" : error.data.message } } - // Check for rate limit patterns in plain text error messages - const msg = isRecord(error.data) ? error.data.message : undefined - if (typeof msg === "string") { - const lower = msg.toLowerCase() - if ( - lower.includes("rate increased too quickly") || - lower.includes("rate limit") || - lower.includes("too many requests") - ) { - return { message: msg } - } - } - - const json = parseJSON(msg) - if (!json || typeof json !== "object") return undefined - const code = typeof json.code === "string" ? json.code : "" - - if (json.type === "error" && json.error?.type === "too_many_requests") { - return { message: "Too Many Requests" } - } - if (code.includes("exhausted") || code.includes("unavailable")) { - return { message: "Provider is overloaded" } - } - if (json.type === "error" && typeof json.error?.code === "string" && json.error.code.includes("rate_limit")) { - return { message: "Rate Limited" } + const message = isRecord(error.data) ? error.data.message : undefined + if (typeof message !== "string") return undefined + const lower = message.toLowerCase() + if ( + lower.includes("rate increased too quickly") || + lower.includes("rate limit") || + lower.includes("rate_limit") || + lower.includes("too many requests") + ) { + return { message } } + if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } + if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } return undefined } diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 30ac879a6a9d..0e30a5473a2e 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -118,16 +118,21 @@ describe("session.retry.delay", () => { }) describe("session.retry.retryable", () => { - test("maps too_many_requests json messages", () => { + test("retries serialized too_many_requests messages", () => { const error = wrap(JSON.stringify({ type: "error", error: { type: "too_many_requests" } })) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Too Many Requests" }) }) - test("maps overloaded provider codes", () => { + test("retries serialized overloaded provider codes", () => { const error = wrap(JSON.stringify({ code: "resource_exhausted" })) expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Provider is overloaded" }) }) + test("retries serialized rate_limit messages", () => { + const message = JSON.stringify({ type: "error", error: { code: "rate_limit_exceeded" } }) + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + test("does not retry unknown json messages", () => { const error = wrap(JSON.stringify({ error: { message: "no_kv_space" } })) expect(SessionRetry.retryable(error, retryProvider)).toBeUndefined() From 61aefc07593043a2cef6cc870f7267b09483f5c0 Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 14:39:49 -0500 Subject: [PATCH 230/333] fix(opencode): expand retryable error patterns (#40707) --- packages/opencode/src/session/retry.ts | 29 ++++++++++----- packages/opencode/test/session/retry.test.ts | 39 ++++++++++++++++++++ 2 files changed, 59 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index d1864cb7a8a0..22399e8703a8 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -28,6 +28,15 @@ export const RETRY_BACKOFF_FACTOR = 2 export const RETRY_MAX_DELAY_NO_HEADERS = 30_000 // 30 seconds export const RETRY_MAX_DELAY = 2_147_483_647 // max 32-bit signed integer for setTimeout +const RETRYABLE_MESSAGE_PATTERNS = [ + /429|500|502|503|504|524/i, + /rate increased too quickly|rate limit|rate-limit|rate_limit|too many requests/i, + /overloaded|service unavailable|service_unavailable|service-unavailable|internal error|internal_error|internal server error|server error|server_error|server-error|provider returned error|provider_returned_error|provider-returned-error/i, + /terminated|fetch failed|failed to fetch|network error|upstream connect|connection error|connection refused|connection lost|socket connection was closed|socket hang up|reset before headers|getaddrinfo|enotfound|eai_again|econnrefused|econnreset|etimedout/i, + /^timeout$|\b(?:request|response|connection|network|stream|read) (?:timeout|timed out|time out)\b/i, + /try your request again|retry your request|resource exhausted|resource_exhausted/i, +] + function cap(ms: number) { return Math.min(ms, RETRY_MAX_DELAY) } @@ -72,7 +81,12 @@ export function retryable(error: Err, provider: string) { const status = error.data.statusCode // 5xx errors are transient server failures and should always be retried, // even when the provider SDK doesn't explicitly mark them as retryable. - if (!error.data.isRetryable && !(status !== undefined && status >= 500)) return undefined + if ( + !error.data.isRetryable && + !(status !== undefined && status >= 500) && + !matchesRetryableMessage(error.data.message) && + !matchesRetryableMessage(error.data.responseBody) + ) return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, @@ -125,19 +139,16 @@ export function retryable(error: Err, provider: string) { const message = isRecord(error.data) ? error.data.message : undefined if (typeof message !== "string") return undefined const lower = message.toLowerCase() - if ( - lower.includes("rate increased too quickly") || - lower.includes("rate limit") || - lower.includes("rate_limit") || - lower.includes("too many requests") - ) { - return { message } - } if (lower.includes("too_many_requests")) return { message: "Too Many Requests" } if (lower.includes("exhausted") || lower.includes("unavailable")) return { message: "Provider is overloaded" } + if (matchesRetryableMessage(message)) return { message } return undefined } +function matchesRetryableMessage(value: unknown) { + return typeof value === "string" && RETRYABLE_MESSAGE_PATTERNS.some((pattern) => pattern.test(value)) +} + function str(value: unknown) { if (value === undefined || value === null) return "" return String(value) diff --git a/packages/opencode/test/session/retry.test.ts b/packages/opencode/test/session/retry.test.ts index 0e30a5473a2e..018f76fc3eaf 100644 --- a/packages/opencode/test/session/retry.test.ts +++ b/packages/opencode/test/session/retry.test.ts @@ -168,6 +168,45 @@ describe("session.retry.retryable", () => { expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: msg }) }) + test.each([ + "Internal server error", + "internal error", + "server-error", + "Provider returned error", + "provider-returned-error", + "terminated", + "fetch failed", + "connection refused", + "connect ECONNREFUSED", + "request ETIMEDOUT", + "failed to fetch", + "EAI_AGAIN", + "response timed out", + "Please retry your request", + "try your request again", + "upstream returned status 524", + ])("retries matching API error text: %s", (message) => { + expect(SessionRetry.retryable(wrap(message), retryProvider)).toEqual({ message }) + }) + + test("retries hyphenated service-unavailable errors", () => { + expect(SessionRetry.retryable(wrap("service-unavailable"), retryProvider)).toEqual({ + message: "Provider is overloaded", + }) + }) + + test("matches retryable API response bodies", () => { + const error = Schema.decodeUnknownSync(SessionV1.APIError.Schema)( + new SessionV1.APIError({ + message: "Request failed", + isRetryable: false, + statusCode: 400, + responseBody: JSON.stringify({ error: { message: "upstream connection refused" } }), + }).toObject(), + ) + expect(SessionRetry.retryable(error, retryProvider)).toEqual({ message: "Request failed" }) + }) + test("retries transport timeout errors", () => { const request = MessageV2.fromError(new ProviderError.HeaderTimeoutError(10000), { providerID }) expect(SessionV1.APIError.isInstance(request)).toBe(true) From 057b5a9dee0b151b18ff5a1164a3cf709389497a Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 19:41:32 +0000 Subject: [PATCH 231/333] chore: generate --- packages/opencode/src/session/retry.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/opencode/src/session/retry.ts b/packages/opencode/src/session/retry.ts index 22399e8703a8..cab48dda6330 100644 --- a/packages/opencode/src/session/retry.ts +++ b/packages/opencode/src/session/retry.ts @@ -86,7 +86,8 @@ export function retryable(error: Err, provider: string) { !(status !== undefined && status >= 500) && !matchesRetryableMessage(error.data.message) && !matchesRetryableMessage(error.data.responseBody) - ) return undefined + ) + return undefined if (error.data.responseBody?.includes("FreeUsageLimitError")) { return { message: GO_UPSELL_MESSAGE, From b84c63d034c7acbe897bd6515b0042fab58770fc Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:02:39 -0500 Subject: [PATCH 232/333] fix(stats): reduce html payloads --- .../stats/app/src/routes/[lab]/[model].tsx | 195 +++----- packages/stats/app/src/routes/[lab]/index.tsx | 79 ++-- .../stats/app/src/routes/compare-cards.tsx | 2 +- packages/stats/app/src/routes/geo-map.ts | 120 +++++ packages/stats/app/src/routes/index.tsx | 447 +++--------------- 5 files changed, 315 insertions(+), 528 deletions(-) create mode 100644 packages/stats/app/src/routes/geo-map.ts diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 3b2bf341fdb5..0b079e915138 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -1,23 +1,17 @@ import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" -import { geoEquirectangular, geoPath } from "d3-geo" import { scaleSqrt } from "d3-scale" import countryCodesSource from "i18n-iso-countries/codes.json?raw" -import { feature, mesh } from "topojson-client" -import countriesTopologySource from "world-atlas/countries-50m.json?raw" import { getStatsModelData, type CountryEntry, type ModelPeerEntry, type ModelUsagePoint, type StatsModelData, - type UsageRange, } from "@opencode-ai/stats-core/domain/home" import { createAsync, query, useParams } from "@solidjs/router" import { createMemo, createSignal, createUniqueId, For, onMount, Show, type JSX } from "solid-js" import { getRequestEvent } from "solid-js/web" -import type { FeatureCollection, GeometryObject, GeoJsonProperties } from "geojson" -import type { GeometryCollection, Topology } from "topojson-specification" import { LocaleLinks } from "../../component/locale-links" import { useI18n } from "../../context/i18n" import { useLanguage } from "../../context/language" @@ -25,10 +19,10 @@ import { localizedUrl } from "../../lib/language" import { findModelCatalogEntry, formatCatalogLabName, - getModelCatalog, - type ModelCatalog, + loadModelCatalog, type ModelCatalogEntry, } from "../model-catalog" +import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map" import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" import { setStatsPageCacheHeaders } from "../stats-cache" @@ -51,45 +45,41 @@ import { } from "../stats-shell" const statsUnfurlPath = "banner.png" -const geoMapWidth = 960 -const geoMapHeight = 430 const shortMonths = ["JAN", "FEB", "MAR", "APR", "MAY", "JUN", "JUL", "AUG", "SEP", "OCT", "NOV", "DEC"] as const type IsoCountryCode = readonly [string, string, string] -type WorldCountryProperties = GeoJsonProperties & { name?: string } -type WorldTopology = Topology<{ countries: GeometryCollection }> +type ModelCatalogOption = Pick +type ModelPageCatalog = { + entry: ModelCatalogEntry | null + labs: { id: string; name: string }[] + labModels: ModelCatalogOption[] +} +type StatsModelPageData = Omit & { country: CountryEntry[] } +type ModelPageData = { catalog: ModelPageCatalog; stats: StatsModelPageData | null } const countryNumericIds = new Map( (JSON.parse(countryCodesSource) as IsoCountryCode[]).map((country) => [country[0], country[2]] as const), ) -const worldTopology = JSON.parse(countriesTopologySource) as WorldTopology -const worldCountryGeometries: GeometryCollection = { - ...worldTopology.objects.countries, - geometries: worldTopology.objects.countries.geometries.filter((country) => String(country.id ?? "") !== "010"), -} -const worldCountries = feature(worldTopology, worldCountryGeometries) as FeatureCollection< - GeometryObject, - WorldCountryProperties -> -const worldProjection = geoEquirectangular().fitExtent( - [ - [10, 12], - [geoMapWidth - 10, geoMapHeight - 12], - ], - worldCountries, -) -const worldPath = geoPath(worldProjection) -const worldCountryPaths = worldCountries.features.map((country) => ({ - id: String(country.id ?? "").padStart(3, "0"), - path: worldPath(country) ?? "", - marker: geoCountryMarker(country), -})) -const worldBorderPath = worldPath(mesh(worldTopology, worldCountryGeometries, (a, b) => a !== b)) ?? "" - -const getModelData = query(async (lab: string, model: string) => { + +const getModelPageData = query(async (labParam: string, modelParam: string) => { "use server" - return runStatsEffect(getStatsModelData(model, lab)) -}, "getStatsModelData") + const catalog = await loadModelCatalog() + const entry = findModelCatalogEntry(catalog, modelParam, labParam) ?? null + const lab = entry?.lab ?? labParam + const model = entry?.slug ?? modelParam + const stats = lab && model ? await runStatsEffect(getStatsModelData(model, lab)) : null + return { + catalog: { + entry, + labs: catalog.labs.map((item) => ({ id: item.id, name: item.name })), + labModels: + catalog.labs + .find((item) => item.id === (entry?.lab ?? providerSlug(labParam))) + ?.models.map((item) => ({ id: item.id, lab: item.lab, slug: item.slug, name: item.name })) ?? [], + }, + stats: stats ? { ...stats, country: stats.country["2M"] } : null, + } satisfies ModelPageData +}, "getStatsModelPageData") export default function StatsModel() { const i18n = useI18n() @@ -99,18 +89,9 @@ export default function StatsModel() { const params = useParams() const labParam = createMemo(() => params.lab ?? "") const modelParam = createMemo(() => params.model ?? "") - const catalog = createAsync(() => getModelCatalog()) - const catalogEntry = createMemo(() => { - const data = catalog() - if (!data) return undefined - return findModelCatalogEntry(data, modelParam(), labParam()) ?? null - }) - const stats = createAsync(() => { - const entry = catalogEntry() - if (catalog() === undefined || entry === undefined) return Promise.resolve(undefined) - if (!entry && (!labParam() || !modelParam())) return Promise.resolve(null) - return getModelData(labParam(), entry?.slug ?? modelParam()) - }) + const page = createAsync(() => getModelPageData(labParam(), modelParam())) + const catalogEntry = createMemo(() => page()?.catalog.entry) + const stats = createMemo(() => page()?.stats) const githubStars = createAsync(() => getGitHubStars()) const [themePreference, setThemePreference] = createSignal("system") const modelName = createMemo(() => catalogEntry()?.name ?? stats()?.model ?? modelParam() ?? i18n.t("model.fallback")) @@ -179,13 +160,13 @@ export default function StatsModel() {
    - }> + }> }> <> @@ -193,10 +174,10 @@ export default function StatsModel() { - + props.catalog?.name ?? props.data?.model ?? i18n.t("model.fallback") const weights = () => props.catalog?.weights[0] const labs = () => props.catalogData?.labs ?? [] - const labModels = () => - props.catalogData?.labs.find((lab) => lab.id === providerSlug(labId()))?.models ?? - (props.catalog ? [props.catalog] : []) + const labModels = () => props.catalogData?.labModels ?? (props.catalog ? [props.catalog] : []) return (
    ) } @@ -1853,12 +1576,10 @@ function MetricBar(props: { value: number; max: number; active: boolean }) { ) } -function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) { +function SessionCostSection(props: { data: SessionCostEntry[] }) { const i18n = useI18n() - const [product, setProduct] = createSignal("Go") const [activeIndex, setActiveIndex] = createSignal(2) - const data = createMemo(() => props.data[product()]) - const visible = createMemo(() => data().slice(0, 16)) + const visible = createMemo(() => props.data.slice(0, 16)) const selectedIndex = createMemo(() => Math.min(activeIndex(), Math.max(visible().length - 1, 0))) return ( @@ -1877,17 +1598,6 @@ function SessionCostSection(props: { data: StatsHomeData["sessionCost"] }) { >
    - ) } @@ -1949,11 +1659,6 @@ function SessionCostChart(props: { ) } -function LiveIndicator() { - const i18n = useI18n() - return {i18n.t("chart.live")} -} - function formatTokenCount(value: number) { if (value >= 1_000_000) return `${Number((value / 1_000_000).toFixed(1))}M` return `${Math.round(value / 1_000)}K` From 3355b78d91876104085995ea5e9dc27ede28d21b Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:06:05 +0000 Subject: [PATCH 233/333] chore: generate --- packages/stats/app/src/routes/[lab]/[model].tsx | 7 +------ packages/stats/app/src/routes/[lab]/index.tsx | 6 +----- packages/stats/app/src/routes/index.tsx | 7 +------ 3 files changed, 3 insertions(+), 17 deletions(-) diff --git a/packages/stats/app/src/routes/[lab]/[model].tsx b/packages/stats/app/src/routes/[lab]/[model].tsx index 0b079e915138..c9906a975e93 100644 --- a/packages/stats/app/src/routes/[lab]/[model].tsx +++ b/packages/stats/app/src/routes/[lab]/[model].tsx @@ -16,12 +16,7 @@ import { LocaleLinks } from "../../component/locale-links" import { useI18n } from "../../context/i18n" import { useLanguage } from "../../context/language" import { localizedUrl } from "../../lib/language" -import { - findModelCatalogEntry, - formatCatalogLabName, - loadModelCatalog, - type ModelCatalogEntry, -} from "../model-catalog" +import { findModelCatalogEntry, formatCatalogLabName, loadModelCatalog, type ModelCatalogEntry } from "../model-catalog" import { geoMapHeight, geoMapWidth, worldBorderPath, worldCountryMarkers, worldCountryPaths } from "../geo-map" import { SectionHeading } from "../section-heading" import { runStatsEffect } from "../../stats-runtime" diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 8931c2af0cf3..3d4a1a663c37 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -152,11 +152,7 @@ export default function StatsLab() { - + usageTotal(item) > 0)} fallback={} > - +
    0} From 709c195905c260299facd5b566d9ab537c48c3bc Mon Sep 17 00:00:00 2001 From: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:14:03 -0500 Subject: [PATCH 234/333] fix(opencode): preserve compatible stream errors (#40718) --- bun.lock | 1 + package.json | 3 +- .../test/session/processor-effect.test.ts | 49 +++++++++++++++++++ .../@ai-sdk%2Fopenai-compatible@2.0.41.patch | 39 +++++++++++++++ 4 files changed, 91 insertions(+), 1 deletion(-) create mode 100644 patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch diff --git a/bun.lock b/bun.lock index 0d08cb8a95ab..3ba15ef800bd 100644 --- a/bun.lock +++ b/bun.lock @@ -1078,6 +1078,7 @@ "@ai-sdk/google@3.0.73": "patches/@ai-sdk%2Fgoogle@3.0.73.patch", "pacote@21.5.0": "patches/pacote@21.5.0.patch", "@dnd-kit/dom@0.5.0": "patches/@dnd-kit%2Fdom@0.5.0.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch", }, "overrides": { "@opentui/core": "catalog:", diff --git a/package.json b/package.json index 15725c865fc2..58712547b4b8 100644 --- a/package.json +++ b/package.json @@ -158,6 +158,7 @@ "@pierre/trees@1.0.0-beta.4": "patches/@pierre%2Ftrees@1.0.0-beta.4.patch", "@modelcontextprotocol/sdk@1.29.0": "patches/@modelcontextprotocol%2Fsdk@1.29.0.patch", "effect@4.0.0-beta.83": "patches/effect@4.0.0-beta.83.patch", - "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch" + "@tanstack/virtual-core@3.17.3": "patches/@tanstack%2Fvirtual-core@3.17.3.patch", + "@ai-sdk/openai-compatible@2.0.41": "patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch" } } diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index 528760543656..d9466c0c2c98 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -604,6 +604,55 @@ it.live("session.processor effect tests retry recognized structured json errors" ), ) +it.live("session.processor effect tests retry OpenAI-compatible midstream server errors", () => + provideTmpdirServer( + ({ dir, llm }) => + Effect.gen(function* () { + const { processors, session, provider } = yield* boot() + + yield* llm.push( + raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] }), + ) + yield* llm.text("after") + + const chat = yield* session.create({}) + const parent = yield* user(chat.id, "retry midstream server error") + const msg = yield* assistant(chat.id, parent.id, path.resolve(dir)) + const mdl = yield* provider.getModel(ref.providerID, ref.modelID) + const handle = yield* processors.create({ + assistantMessage: msg, + sessionID: chat.id, + model: mdl, + }) + + const value = yield* handle.process({ + user: { + id: parent.id, + sessionID: chat.id, + role: "user", + time: parent.time, + agent: parent.agent, + model: { providerID: ref.providerID, modelID: ref.modelID }, + } satisfies SessionV1.User, + sessionID: chat.id, + model: mdl, + agent: agent(), + system: [], + messages: [{ role: "user", content: "retry midstream server error" }], + tools: {}, + }) + + const parts = yield* MessageV2.parts(msg.id) + + expect(value).toBe("continue") + expect(yield* llm.calls).toBe(2) + expect(parts.some((part) => part.type === "text" && part.text === "after")).toBe(true) + expect(handle.message.error).toBeUndefined() + }), + { config: (url) => providerCfg(url) }, + ), +) + it.live("session.processor effect tests publish retry status updates", () => provideTmpdirServer( ({ dir, llm }) => diff --git a/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch b/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch new file mode 100644 index 000000000000..9f03ec95732a --- /dev/null +++ b/patches/@ai-sdk%2Fopenai-compatible@2.0.41.patch @@ -0,0 +1,39 @@ +diff --git a/dist/index.js b/dist/index.js +index dca128d3a790378c51a24a16d92585178343b278..da75f9d64acd2b607abd15079ce2d03b7a8d675a 100644 +--- a/dist/index.js ++++ b/dist/index.js +@@ -696,7 +696,7 @@ var OpenAICompatibleChatLanguageModel = class { + finishReason = { unified: "error", raw: void 0 }; + controller.enqueue({ + type: "error", +- error: chunk.value.error.message ++ error: chunk.value.error + }); + return; + } +diff --git a/dist/index.mjs b/dist/index.mjs +index 3b1e1b6bdec5032e3b4fa5ffbcc8cdf3dfe1cc40..eaffc446f80552ea0b26573b89daa4dfc7776e6e 100644 +--- a/dist/index.mjs ++++ b/dist/index.mjs +@@ -683,7 +683,7 @@ var OpenAICompatibleChatLanguageModel = class { + finishReason = { unified: "error", raw: void 0 }; + controller.enqueue({ + type: "error", +- error: chunk.value.error.message ++ error: chunk.value.error + }); + return; + } +diff --git a/src/chat/openai-compatible-chat-language-model.ts b/src/chat/openai-compatible-chat-language-model.ts +index 8c622db23c2d9a7373701f5a1b0c2ba109e24602..643c3db68a6043e097edc1122e0eb53fd13495c5 100644 +--- a/src/chat/openai-compatible-chat-language-model.ts ++++ b/src/chat/openai-compatible-chat-language-model.ts +@@ -442,7 +442,7 @@ export class OpenAICompatibleChatLanguageModel implements LanguageModelV3 { + finishReason = { unified: 'error', raw: undefined }; + controller.enqueue({ + type: 'error', +- error: chunk.value.error.message, ++ error: chunk.value.error, + }); + return; + } From 082fe93e160c042332b3686a006d0f35ce4a6d6e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:16:40 +0000 Subject: [PATCH 235/333] chore: generate --- packages/opencode/test/session/processor-effect.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/packages/opencode/test/session/processor-effect.test.ts b/packages/opencode/test/session/processor-effect.test.ts index d9466c0c2c98..052477d0a2e7 100644 --- a/packages/opencode/test/session/processor-effect.test.ts +++ b/packages/opencode/test/session/processor-effect.test.ts @@ -610,9 +610,7 @@ it.live("session.processor effect tests retry OpenAI-compatible midstream server Effect.gen(function* () { const { processors, session, provider } = yield* boot() - yield* llm.push( - raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] }), - ) + yield* llm.push(raw({ chunks: [{ error: { type: "server_error", code: "server_error", message: "xxx" } }] })) yield* llm.text("after") const chat = yield* session.create({}) From b1f8cc04af936c3d3b5de8cb0645c6d249968eb4 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Wed, 5 Aug 2026 20:33:25 +0000 Subject: [PATCH 236/333] 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 83bc08a630ed..6f321d88bf2e 100644 --- a/nix/hashes.json +++ b/nix/hashes.json @@ -1,8 +1,8 @@ { "nodeModules": { - "x86_64-linux": "sha256-GRjnvvyj37H36RqiCB7dz5ALAEwvw16izwuk1wsHEpU=", - "aarch64-linux": "sha256-0OIn1o6dpqIQ5XgIMzpenMCMqsYzraaYVJk+te5eINU=", - "aarch64-darwin": "sha256-sQSQcuox78d8wT1lsKYHqVBl11NusiszQ+gu2XYXZi8=", - "x86_64-darwin": "sha256-SINkhMRd4oM1zABSs1Uf3OAzxJ1lgcycl1U4fmi+baY=" + "x86_64-linux": "sha256-uduwrM143NDSc+tXsi4lVVfoMll2a3BDHRUjuO7GB68=", + "aarch64-linux": "sha256-6DUda78XdXY6DP86lIUkweSjys3iG4Y4mo1PiaNuXbg=", + "aarch64-darwin": "sha256-AkJwfLULLZVwwz+XU1QcFUZoIS7oVPCn+n/MXEaxrqE=", + "x86_64-darwin": "sha256-hAxKGdiITTxQ2uujQt6prNjo3NxGAMMeo+9HlMWK6GU=" } } From 146720e197866afc2e799462374c42b51e12857b Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Wed, 5 Aug 2026 15:54:35 -0500 Subject: [PATCH 237/333] fix(stats): improve page speed --- packages/stats/app/src/entry-server.tsx | 12 ++++++- .../stats/app/src/routes/compare-cards.tsx | 1 - packages/stats/app/src/routes/index.css | 36 +++++++++++++------ packages/stats/app/src/routes/index.tsx | 30 ++++------------ packages/stats/app/vite.config.ts | 3 +- 5 files changed, 45 insertions(+), 37 deletions(-) diff --git a/packages/stats/app/src/entry-server.tsx b/packages/stats/app/src/entry-server.tsx index 3cec2cba04dd..1ea92a46e938 100644 --- a/packages/stats/app/src/entry-server.tsx +++ b/packages/stats/app/src/entry-server.tsx @@ -1,7 +1,10 @@ // @refresh reload +import type { Asset, PageEvent } from "@solidjs/start" import { createHandler, StartServer } from "@solidjs/start/server" +import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" import { getRequestEvent } from "solid-js/web" import { dir, localeFromRequest, tag } from "./lib/language" +import statsStylesheetUrl from "./routes/index.css?url" const statsThemePreloadScript = `;(function () { var preference = "system" @@ -18,8 +21,13 @@ export default createHandler( () => ( { - const event = getRequestEvent() + const event = getRequestEvent() as PageEvent | undefined const locale = event ? localeFromRequest(event.request) : "en" + const stylesheet = (event?.assets as Asset[] | undefined)?.find( + (asset): asset is Extract => + asset.tag === "link" && asset.attrs.rel === "stylesheet", + ) + const stylesheetHref = import.meta.env.DEV ? statsStylesheetUrl : stylesheet?.attrs.href return ( @@ -27,6 +35,8 @@ export default createHandler( + + {stylesheetHref ? : null} {assets} diff --git a/packages/stats/app/src/routes/compare-cards.tsx b/packages/stats/app/src/routes/compare-cards.tsx index 47cc07b9295d..10a105580085 100644 --- a/packages/stats/app/src/routes/compare-cards.tsx +++ b/packages/stats/app/src/routes/compare-cards.tsx @@ -86,7 +86,6 @@ function FeaturedComparisonCard(props: { pair: ComparisonPair }) { diff --git a/packages/stats/app/src/routes/index.css b/packages/stats/app/src/routes/index.css index c378688490cd..39877378a435 100644 --- a/packages/stats/app/src/routes/index.css +++ b/packages/stats/app/src/routes/index.css @@ -55,7 +55,7 @@ body { --color-background-strong: #161616; --color-background-strong-hover: #242424; --color-text: #5c5c5c; - --color-text-weak: #808080; + --color-text-weak: #707070; --color-text-strong: #161616; --color-text-inverted: #ffffff; --color-border-weak: #0000001a; @@ -66,10 +66,10 @@ body { --stats-line-strong: #00000033; --stats-text: #161616; --stats-muted: #5c5c5c; - --stats-faint: #808080; + --stats-faint: #707070; --stats-theme-icon-active: #3a3a3a; --stats-accent: #3b5cf6; - --stats-accent-text: #6c7dff; + --stats-accent-text: #3b5cf6; --stats-bar-idle: #d4d4d4; --stats-dot: #d4d4d4; --stats-hero-muted: #5c5c5c; @@ -92,6 +92,18 @@ body { display: none !important; } +[data-page="stats"] [data-slot="visually-hidden"] { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0 0 0 0); + white-space: nowrap; + border: 0; +} + [data-page="stats"][data-layout="compare-detail"] { /* The table contains its own wide rows; keep the page itself out of the horizontal scroll chain. */ overflow-x: visible; @@ -7079,7 +7091,7 @@ body { --color-background-strong: #ffffff; --color-background-strong-hover: #eeeeee; --color-text: #d4d4d4; - --color-text-weak: #808080; + --color-text-weak: #a3a3a3; --color-text-strong: #ffffff; --color-text-inverted: #161616; --color-border-weak: #ffffff1a; @@ -7090,11 +7102,12 @@ body { --stats-line-strong: #ffffff33; --stats-text: #ffffff; --stats-muted: #d4d4d4; - --stats-faint: #808080; + --stats-faint: #a3a3a3; + --stats-accent-text: #8190ff; --stats-theme-icon-active: #fafafa; --stats-bar-idle: #303030; --stats-dot: #303030; - --stats-hero-muted: #808080; + --stats-hero-muted: #a3a3a3; --stats-hero-pattern: #303030; --stats-logo-bg: #f1ecec; --stats-logo-fill: #b7b1b1; @@ -7254,7 +7267,7 @@ body { --color-background-strong: #ffffff; --color-background-strong-hover: #eeeeee; --color-text: #d4d4d4; - --color-text-weak: #808080; + --color-text-weak: #a3a3a3; --color-text-strong: #ffffff; --color-text-inverted: #161616; --color-border-weak: #ffffff1a; @@ -7265,11 +7278,12 @@ body { --stats-line-strong: #ffffff33; --stats-text: #ffffff; --stats-muted: #d4d4d4; - --stats-faint: #808080; + --stats-faint: #a3a3a3; + --stats-accent-text: #8190ff; --stats-theme-icon-active: #fafafa; --stats-bar-idle: #303030; --stats-dot: #303030; - --stats-hero-muted: #808080; + --stats-hero-muted: #a3a3a3; --stats-hero-pattern: #303030; --stats-logo-bg: #f1ecec; --stats-logo-fill: #b7b1b1; @@ -8393,7 +8407,7 @@ body { } [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] { - --top-models-mobile-bar-width: 12px; + --top-models-mobile-bar-width: 18px; } [data-page="stats"] [data-component="top-models-chart"][data-dense-labels="true"] [data-slot="top-models-axis"], @@ -8402,7 +8416,7 @@ body { } [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] { - --market-mobile-bar-width: 12px; + --market-mobile-bar-width: 18px; } [data-page="stats"] [data-component="market-share"][data-dense-labels="true"] [data-slot="market-labels"], diff --git a/packages/stats/app/src/routes/index.tsx b/packages/stats/app/src/routes/index.tsx index 28e5cf59a336..398f53f71861 100644 --- a/packages/stats/app/src/routes/index.tsx +++ b/packages/stats/app/src/routes/index.tsx @@ -1,11 +1,7 @@ -import { Link, Meta, Title } from "@solidjs/meta" +import { Meta, Title } from "@solidjs/meta" import { ProviderIcon } from "@opencode-ai/ui/provider-icon" import { scaleSqrt } from "d3-scale" import countryCodesSource from "i18n-iso-countries/codes.json?raw" -import ibmPlexMonoRegularLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Regular-Latin1.woff2?url" -import ibmPlexMonoMediumLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Medium-Latin1.woff2?url" -import ibmPlexMonoSemiBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-SemiBold-Latin1.woff2?url" -import ibmPlexMonoBoldLatin1 from "@ibm/plex/IBM-Plex-Mono/fonts/split/woff2/IBMPlexMono-Bold-Latin1.woff2?url" import { getStatsHomeData, type CacheRatioEntry, @@ -142,10 +138,6 @@ export default function StatsHome() { - - - -
    @@ -227,7 +219,8 @@ function Hero(props: { updatedAt: string | null }) { return (
    -

    +

    + {currentUpdatedLabel()}

    +
    {(entry) => ( @@ -766,7 +758,7 @@ function Leaderboard(props: { )}
    -
    +
    {(entry) => ( props.onActiveModelChange(props.entry.model)} onPointerLeave={(event) => { if (event.pointerType === "touch") return @@ -956,6 +945,7 @@ function MarketShare(props: { {(day, index) => ( +
    {(message) => ( diff --git a/packages/app/src/i18n/en.ts b/packages/app/src/i18n/en.ts index c37c9c21161a..26720c8ac0d4 100644 --- a/packages/app/src/i18n/en.ts +++ b/packages/app/src/i18n/en.ts @@ -95,6 +95,8 @@ export const dict = { "command.session.share.description": "Share this session and copy the URL to clipboard", "command.session.unshare": "Unshare session", "command.session.unshare.description": "Stop sharing this session", + "command.session.export": "Export session", + "command.session.export.description": "Export the full session transcript as JSON", "palette.search.placeholder": "Search files, commands, and sessions", "palette.search.placeholder.home": "Search commands and sessions", @@ -489,6 +491,7 @@ export const dict = { "context.systemPrompt.title": "System Prompt", "context.rawMessages.title": "Raw messages", + "context.export.session": "Export session", "context.stats.session": "Session", "context.stats.messages": "Messages", @@ -568,6 +571,11 @@ export const dict = { "toast.session.unshare.failed.title": "Failed to unshare session", "toast.session.unshare.failed.description": "An error occurred while unsharing the session", + "toast.session.export.success.title": "Session exported", + "toast.session.export.success.description": "Saved session to {{filename}}", + "toast.session.export.failed.title": "Failed to export session", + "toast.session.export.failed.description": "An error occurred while exporting the session", + "toast.session.listFailed.title": "Failed to load sessions for {{project}}", "toast.project.reloadFailed.title": "Failed to reload {{project}}", @@ -802,6 +810,7 @@ export const dict = { "common.moreOptions": "More options", "common.learnMore": "Learn more", "common.rename": "Rename", + "common.export": "Export", "common.reset": "Reset", "common.archive": "Archive", "common.delete": "Delete", diff --git a/packages/app/src/pages/session/timeline/message-timeline.tsx b/packages/app/src/pages/session/timeline/message-timeline.tsx index 7d22ce5e1668..e69623179faa 100644 --- a/packages/app/src/pages/session/timeline/message-timeline.tsx +++ b/packages/app/src/pages/session/timeline/message-timeline.tsx @@ -53,6 +53,7 @@ import type { UserMessage, } from "@opencode-ai/sdk/v2" import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { getDirectory, getFilename } from "@opencode-ai/core/util/path" import { Popover as KobaltePopover } from "@kobalte/core/popover" import { normalize } from "@opencode-ai/session-ui/session-diff" @@ -806,6 +807,29 @@ export function MessageTimeline(props: { navigate(`/${params.dir}/session`) } + const exportSession = async (sessionID: string) => { + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + const archiveSession = async (sessionID: string) => { const session = sync().session.get(sessionID) if (!session) return @@ -1564,6 +1588,9 @@ export function MessageTimeline(props: { + exportSession(id)}> + {language.t("common.export")} + void archiveSession(id)}> {language.t("common.archive")} @@ -1635,6 +1662,9 @@ export function MessageTimeline(props: { {language.t("session.share.action.share")}... + exportSession(id)}> + {language.t("common.export")}... + void archiveSession(id)}> {language.t("common.archive")} diff --git a/packages/app/src/pages/session/use-session-commands.tsx b/packages/app/src/pages/session/use-session-commands.tsx index 12dd96a5e66b..cfc302e77533 100644 --- a/packages/app/src/pages/session/use-session-commands.tsx +++ b/packages/app/src/pages/session/use-session-commands.tsx @@ -12,10 +12,11 @@ import { useSettings } from "@/context/settings" import { useSync } from "@/context/sync" import { useTerminal } from "@/context/terminal" import { showToast } from "@/utils/toast" +import { downloadSessionExport, fetchSessionExport, sessionExportFilename } from "@/utils/session-export" import { findLast } from "@opencode-ai/core/util/array" import { createSessionTabs } from "@/pages/session/helpers" import { extractPromptFromParts } from "@/utils/prompt" -import { UserMessage } from "@opencode-ai/sdk/v2" +import { Message, Part, UserMessage } from "@opencode-ai/sdk/v2" import { useSessionLayout } from "@/pages/session/session-layout" import { createSessionOwnership } from "./session-ownership" import { useLocal } from "@/context/local" @@ -231,6 +232,31 @@ export const useSessionCommands = (actions: SessionCommandContext) => { ) } + const exportSession = async () => { + const sessionID = params.id + if (!sessionID) return + try { + const data = await fetchSessionExport({ + sessionID, + client: sdk().client, + }) + const filename = sessionExportFilename(data.info) + downloadSessionExport(filename, data) + showToast({ + variant: "success", + icon: "circle-check", + title: language.t("toast.session.export.success.title"), + description: language.t("toast.session.export.success.description", { filename }), + }) + } catch (err) { + showToast({ + variant: "error", + title: language.t("toast.session.export.failed.title"), + description: err instanceof Error ? err.message : language.t("toast.session.export.failed.description"), + }) + } + } + const openFile = () => { void openDialog( () => import("@/components/dialog-select-file"), @@ -458,6 +484,14 @@ export const useSessionCommands = (actions: SessionCommandContext) => { disabled: !params.id || visibleUserMessages().length === 0, onSelect: fork, }), + sessionCommand({ + id: "session.export", + title: language.t("command.session.export"), + description: language.t("command.session.export.description"), + slash: "export", + disabled: !params.id, + onSelect: exportSession, + }), ] const fileCmds = () => { diff --git a/packages/app/src/utils/session-export.test.ts b/packages/app/src/utils/session-export.test.ts new file mode 100644 index 000000000000..ff18a16b7fa3 --- /dev/null +++ b/packages/app/src/utils/session-export.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, test } from "bun:test" +import { fetchSessionExport, sessionExportFilename } from "./session-export" +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +describe("sessionExportFilename", () => { + test("generates filename from title", () => { + expect(sessionExportFilename({ id: "ses_123", title: "Clone PR in worktree from fork" })).toBe( + "clone-pr-in-worktree-from-fork.json", + ) + }) + + test("generates filename from slug when title missing", () => { + expect(sessionExportFilename({ id: "ses_123", slug: "my-session-slug" })).toBe("my-session-slug.json") + }) + + test("falls back to id when title and slug are empty", () => { + expect(sessionExportFilename({ id: "ses_123" })).toBe("ses_123.json") + }) +}) + +describe("fetchSessionExport", () => { + test("fetches full transcript from client", async () => { + const session = { id: "ses_1", title: "Test Session" } as Session + const msg = { id: "msg_1", role: "user" } as Message + const part = { id: "prt_1", type: "text", text: "hello" } as Part + const messages = [{ info: msg, parts: [part] }] + + const client = { + session: { + get: async () => ({ data: session }), + messages: async () => ({ data: messages }), + }, + } + + const result = await fetchSessionExport({ + sessionID: "ses_1", + client, + }) + + expect(result).toEqual({ + info: session, + messages, + }) + }) + + test("throws when session not found", async () => { + const client = { + session: { + get: async () => ({ data: null }), + messages: async () => ({ data: [] }), + }, + } + + expect( + fetchSessionExport({ + sessionID: "ses_missing", + client, + }), + ).rejects.toThrow("Session not found: ses_missing") + }) +}) diff --git a/packages/app/src/utils/session-export.ts b/packages/app/src/utils/session-export.ts new file mode 100644 index 000000000000..6eb9f9ab6a7c --- /dev/null +++ b/packages/app/src/utils/session-export.ts @@ -0,0 +1,61 @@ +import type { Message, Part, Session } from "@opencode-ai/sdk/v2/client" + +// Matches the exact `{ info, messages: [{ info, parts }] }` structure produced by `opencode export` CLI +export type SessionExportData = { + info: Session + messages: { + info: Message + parts: Part[] + }[] +} + +export type SessionExportClient = { + session: { + get: (input: { sessionID: string }) => Promise<{ data?: Session | null }> + messages: (input: { sessionID: string }) => Promise<{ data?: SessionExportData["messages"] | null }> + } +} + +export async function fetchSessionExport(input: { + sessionID: string + client: SessionExportClient +}): Promise { + const [sessionRes, messagesRes] = await Promise.all([ + input.client.session.get({ sessionID: input.sessionID }), + input.client.session.messages({ sessionID: input.sessionID }), + ]) + + if (!sessionRes?.data) { + throw new Error(`Session not found: ${input.sessionID}`) + } + if (!messagesRes?.data) { + throw new Error(`Failed to load messages for session: ${input.sessionID}`) + } + + return { + info: sessionRes.data, + messages: messagesRes.data, + } +} + +export function sessionExportFilename(session: { id: string; title?: string; slug?: string }) { + const name = session.title || session.slug || session.id + const clean = name + .toLowerCase() + .replace(/[^a-z0-9_-]+/gi, "-") + .replace(/^-+|-+$/g, "") + return `${clean || session.id}.json` +} + +export function downloadSessionExport(filename: string, data: unknown) { + const json = JSON.stringify(data, null, 2) + const blob = new Blob([json], { type: "application/json" }) + const url = URL.createObjectURL(blob) + const a = document.createElement("a") + a.href = url + a.download = filename + document.body.appendChild(a) + a.click() + document.body.removeChild(a) + URL.revokeObjectURL(url) +} From b8bd88901a4870ef3a5752840f4e23e11d54e24e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 6 Aug 2026 01:52:15 +0000 Subject: [PATCH 243/333] chore: generate --- .../app/src/components/session/session-context-tab.tsx | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/packages/app/src/components/session/session-context-tab.tsx b/packages/app/src/components/session/session-context-tab.tsx index c08c202fa2e9..2f37c33fb37e 100644 --- a/packages/app/src/components/session/session-context-tab.tsx +++ b/packages/app/src/components/session/session-context-tab.tsx @@ -358,7 +358,12 @@ export function SessionContextTab() {
    {language.t("context.rawMessages.title")}
    - From 847771fe06d89eb41aa5f5ba9ebe6cee1ec6067c Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 6 Aug 2026 03:21:16 -0400 Subject: [PATCH 244/333] fix log processor --- .../console/function/src/log-processor.ts | 22 +++++++++---------- 1 file changed, 11 insertions(+), 11 deletions(-) diff --git a/packages/console/function/src/log-processor.ts b/packages/console/function/src/log-processor.ts index ee8743ef1544..75daba2b5046 100644 --- a/packages/console/function/src/log-processor.ts +++ b/packages/console/function/src/log-processor.ts @@ -54,15 +54,15 @@ export default { console.log(JSON.stringify(data, null, 2)) const lakeIngest = getLakeIngest() - const [honeycomb, lake] = await Promise.all([ - fetch("https://api.honeycomb.io/1/batch/zen", { - method: "POST", - headers: { - "Content-Type": "application/json", - "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value, - }, - body: JSON.stringify(events), - }), + const [lake] = await Promise.all([ + // fetch("https://api.honeycomb.io/1/batch/zen", { + // method: "POST", + // headers: { + // "Content-Type": "application/json", + // "X-Honeycomb-Team": Resource.HONEYCOMB_API_KEY.value, + // }, + // body: JSON.stringify(events), + // }), ...(lakeIngest ? [ fetch(lakeIngest.url, { @@ -76,8 +76,8 @@ export default { ] : []), ]) - console.log(honeycomb.status) - console.log(await honeycomb.text()) + // console.log(honeycomb.status) + // console.log(await honeycomb.text()) if (lake) { console.log(lake.status) console.log(await lake.text()) From f6c5afe5a960a252177c1c7ddc280706fd85b7b0 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 17:25:41 +1000 Subject: [PATCH 245/333] fix(desktop): disable packaged console logging (#40794) Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com> --- packages/desktop/src/main/logging.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/packages/desktop/src/main/logging.ts b/packages/desktop/src/main/logging.ts index 8866fd78ef77..b8fc260cb874 100644 --- a/packages/desktop/src/main/logging.ts +++ b/packages/desktop/src/main/logging.ts @@ -189,6 +189,11 @@ async function writeZip(output: string, entries: Entry[]) { } function initConsoleTransport() { + if (app.isPackaged) { + log.transports.console.level = false + return + } + const write = log.transports.console.writeFn.bind(log.transports.console) log.transports.console.writeFn = (options) => { try { From def7220bfc65b84046e597e9be772eae81f663ff Mon Sep 17 00:00:00 2001 From: ayu Date: Thu, 6 Aug 2026 01:23:32 -0700 Subject: [PATCH 246/333] fix(tui): support copying over ssh with `set-clipboard on` tmux config (#30472) Co-authored-by: Simon Klee --- packages/tui/src/clipboard.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/tui/src/clipboard.ts b/packages/tui/src/clipboard.ts index 08f86f9f7a97..2ae29da88894 100644 --- a/packages/tui/src/clipboard.ts +++ b/packages/tui/src/clipboard.ts @@ -23,7 +23,8 @@ function command(command: string, args: string[] = [], input?: string) { function writeOsc52(text: string) { if (!process.stdout.isTTY) return const sequence = `\x1b]52;c;${Buffer.from(text).toString("base64")}\x07` - process.stdout.write(process.env.TMUX || process.env.STY ? `\x1bPtmux;\x1b${sequence}\x1b\\` : sequence) + const passthrough = `\x1bPtmux;\x1b${sequence}\x1b\\` + process.stdout.write(process.env.TMUX ? sequence + passthrough : process.env.STY ? passthrough : sequence) } export async function read() { From b379d71d194704ed42d08e9b736cf2966e3d1a02 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 6 Aug 2026 10:58:58 -0500 Subject: [PATCH 247/333] fix(stats): update github stars --- packages/stats/app/src/component/model-compare-detail.tsx | 7 ++++++- packages/stats/app/src/routes/[lab]/[model].tsx | 7 ++++++- packages/stats/app/src/routes/[lab]/index.tsx | 7 ++++++- packages/stats/app/src/routes/compare/index.tsx | 7 ++++++- packages/stats/app/src/routes/stats-shell.tsx | 5 +++-- 5 files changed, 27 insertions(+), 6 deletions(-) diff --git a/packages/stats/app/src/component/model-compare-detail.tsx b/packages/stats/app/src/component/model-compare-detail.tsx index d02723e4614f..3790789b58b3 100644 --- a/packages/stats/app/src/component/model-compare-detail.tsx +++ b/packages/stats/app/src/component/model-compare-detail.tsx @@ -31,6 +31,7 @@ import { applyThemePreference, Footer, getGitHubStars, + githubLink, Header, isThemePreference, themeStorageKey, @@ -240,7 +241,11 @@ export default function ModelCompareDetailPage(props: ModelCompareDetailPageProp -
    +
    -
    +
    }> diff --git a/packages/stats/app/src/routes/[lab]/index.tsx b/packages/stats/app/src/routes/[lab]/index.tsx index 3d4a1a663c37..ab202fc89f7d 100644 --- a/packages/stats/app/src/routes/[lab]/index.tsx +++ b/packages/stats/app/src/routes/[lab]/index.tsx @@ -32,6 +32,7 @@ import { applyThemePreference, Footer, getGitHubStars, + githubLink, Header, isThemePreference, themeStorageKey, @@ -141,7 +142,11 @@ export default function StatsLab() { -
    +
    }> diff --git a/packages/stats/app/src/routes/compare/index.tsx b/packages/stats/app/src/routes/compare/index.tsx index 7aa5cbe4c708..861f69493473 100644 --- a/packages/stats/app/src/routes/compare/index.tsx +++ b/packages/stats/app/src/routes/compare/index.tsx @@ -20,6 +20,7 @@ import { applyThemePreference, Footer, getGitHubStars, + githubLink, Header, isThemePreference, themeStorageKey, @@ -125,7 +126,11 @@ export default function ModelCompareIndex() { -
    +
    diff --git a/packages/stats/app/src/routes/stats-shell.tsx b/packages/stats/app/src/routes/stats-shell.tsx index 8906dc9d5a7a..1aa6e7a26fa8 100644 --- a/packages/stats/app/src/routes/stats-shell.tsx +++ b/packages/stats/app/src/routes/stats-shell.tsx @@ -10,7 +10,7 @@ export type HeaderLink = { href: string; label: string } export const githubLink = { href: "https://github.com/anomalyco/opencode", apiHref: "https://api.github.com/repos/anomalyco/opencode", - fallbackStars: "150K", + fallbackStars: "195K", } export const themePreferences = ["dark", "light", "system"] as const export const themeStorageKey = "opencode:stats-theme" @@ -18,7 +18,8 @@ export type ThemePreference = (typeof themePreferences)[number] const compactNumberFormatter = new Intl.NumberFormat("en", { notation: "compact", - maximumFractionDigits: 1, + maximumFractionDigits: 0, + roundingIncrement: 5, }) export const getGitHubStars = query(async () => { From 03bff6500abd09fc469d59e5bd4143d3eb053a94 Mon Sep 17 00:00:00 2001 From: Adam <2363879+adamdotdevin@users.noreply.github.com> Date: Thu, 6 Aug 2026 11:07:33 -0500 Subject: [PATCH 248/333] fix(app): update homepage stats --- packages/console/app/src/config.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/console/app/src/config.ts b/packages/console/app/src/config.ts index 2cd039fef7ef..78da7eba3120 100644 --- a/packages/console/app/src/config.ts +++ b/packages/console/app/src/config.ts @@ -9,8 +9,8 @@ export const config = { github: { repoUrl: "https://github.com/anomalyco/opencode", starsFormatted: { - compact: "160K", - full: "160,000", + compact: "195K", + full: "195,000", }, }, @@ -22,8 +22,8 @@ export const config = { // Static stats (used on landing page) stats: { - contributors: "900", + contributors: "950", commits: "13,000", - monthlyUsers: "7.5M", + monthlyUsers: "16M", }, } as const From dc898ca2c3e64f95fca7e2f737c3cd1eb14ae5ec Mon Sep 17 00:00:00 2001 From: Frank Date: Thu, 6 Aug 2026 19:34:26 -0400 Subject: [PATCH 249/333] sync --- packages/console/app/src/routes/zen/util/handler.ts | 3 ++- packages/console/core/src/schema/billing.sql.ts | 1 - packages/console/core/src/schema/referral.sql.ts | 7 ++----- 3 files changed, 4 insertions(+), 7 deletions(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index 4d31756161d8..a92aaaea35c1 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -197,7 +197,8 @@ export async function handler( if (Array.isArray(v)) return [[k, v]] if (typeof v === "object") return [[k, replacer(v)]] if (typeof v === "string") { - if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo?.workspaceID]] : [] + if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo.workspaceID]] : [] + if (v === "$org") return authInfo?.workspaceID ? [[k, authInfo.workspaceID.replace("wrk_", "org_")]] : [] if (v === "$user") return stickyId ? [[k, stickyId]] : [] if (v.startsWith("$header.")) { const headerValue = input.request.headers.get(v.slice(8)) diff --git a/packages/console/core/src/schema/billing.sql.ts b/packages/console/core/src/schema/billing.sql.ts index b177858f363f..915646cf3da0 100644 --- a/packages/console/core/src/schema/billing.sql.ts +++ b/packages/console/core/src/schema/billing.sql.ts @@ -53,7 +53,6 @@ export const BillingTable = mysqlTable( ...workspaceIndexes(table), uniqueIndex("global_customer_id").on(table.customerID), uniqueIndex("global_subscription_id").on(table.subscriptionID), - uniqueIndex("global_lite_subscription_id").on(table.liteSubscriptionID), ], ) diff --git a/packages/console/core/src/schema/referral.sql.ts b/packages/console/core/src/schema/referral.sql.ts index be2499ee1158..9850c92457db 100644 --- a/packages/console/core/src/schema/referral.sql.ts +++ b/packages/console/core/src/schema/referral.sql.ts @@ -1,4 +1,4 @@ -import { bigint, index, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core" +import { bigint, mysqlTable, primaryKey, uniqueIndex, varchar } from "drizzle-orm/mysql-core" import { timestamps, ulid, utc, workspaceColumns } from "../drizzle/types" import { workspaceIndexes } from "./workspace.sql" @@ -31,8 +31,5 @@ export const ReferralRewardTable = mysqlTable( amount: bigint("amount", { mode: "number" }).notNull(), timeApplied: utc("time_applied"), }, - (table) => [ - primaryKey({ columns: [table.workspaceID, table.referralID] }), - index("referral_id").on(table.referralID), - ], + (table) => [primaryKey({ columns: [table.workspaceID, table.referralID] })], ) From 69f2cbaa3ab875bd1a1cf4392ea68f207b7966d8 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" Date: Thu, 6 Aug 2026 23:37:25 +0000 Subject: [PATCH 250/333] chore: generate --- packages/console/app/src/routes/zen/util/handler.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/packages/console/app/src/routes/zen/util/handler.ts b/packages/console/app/src/routes/zen/util/handler.ts index a92aaaea35c1..7319295a67ba 100644 --- a/packages/console/app/src/routes/zen/util/handler.ts +++ b/packages/console/app/src/routes/zen/util/handler.ts @@ -198,7 +198,8 @@ export async function handler( if (typeof v === "object") return [[k, replacer(v)]] if (typeof v === "string") { if (v === "$workspace") return authInfo?.workspaceID ? [[k, authInfo.workspaceID]] : [] - if (v === "$org") return authInfo?.workspaceID ? [[k, authInfo.workspaceID.replace("wrk_", "org_")]] : [] + if (v === "$org") + return authInfo?.workspaceID ? [[k, authInfo.workspaceID.replace("wrk_", "org_")]] : [] if (v === "$user") return stickyId ? [[k, stickyId]] : [] if (v.startsWith("$header.")) { const headerValue = input.request.headers.get(v.slice(8)) From b7f9363393c4caefb413372d9a49111ed8289666 Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Thu, 6 Aug 2026 21:11:14 -0500 Subject: [PATCH 251/333] fix(opencode): serialize orphaned compaction history (#40800) Co-authored-by: Aiden Cline <63023139+rekram1-node@users.noreply.github.com> --- packages/opencode/src/session/compaction.ts | 51 ++++++++++-- .../opencode/test/session/compaction.test.ts | 77 ++++++++++++++++++- 2 files changed, 119 insertions(+), 9 deletions(-) diff --git a/packages/opencode/src/session/compaction.ts b/packages/opencode/src/session/compaction.ts index fa439e4efffa..7693f5ccfdcc 100644 --- a/packages/opencode/src/session/compaction.ts +++ b/packages/opencode/src/session/compaction.ts @@ -49,6 +49,42 @@ type CompletedCompaction = { summary: string | undefined } +const truncate = (value: string) => + value.length <= TOOL_OUTPUT_MAX_CHARS ? value : `${value.slice(0, TOOL_OUTPUT_MAX_CHARS)}\n[truncated]` + +const serialize = (message: SessionV1.WithParts) => { + if (message.info.role === "user") { + const text = message.parts + .filter((part): part is SessionV1.TextPart => part.type === "text" && !part.ignored) + .map((part) => part.text) + .filter(Boolean) + .join("\n") + const files = message.parts.flatMap((part) => + part.type === "file" ? [`[Attached ${part.mime}: ${part.filename ?? "file"}]`] : [], + ) + return [...(text ? [`[User]: ${text}`] : []), ...files].join("\n") + } + return message.parts + .flatMap((part) => { + if (part.type === "text") return part.text ? [`[Assistant]: ${part.text}`] : [] + if (part.type === "reasoning") return part.text ? [`[Assistant reasoning]: ${part.text}`] : [] + if (part.type !== "tool") return [] + const call = `[Assistant tool call]: ${part.tool}(${JSON.stringify(part.state.input)})` + if (part.state.status === "completed") { + const attachments = (part.state.attachments ?? []).map( + (item) => `[Attached ${item.mime}: ${item.filename ?? "file"}]`, + ) + const output = part.state.time.compacted + ? "[Old tool result content cleared]" + : truncate([part.state.output, ...attachments].join("\n")) + return [call, `[Tool result]: ${output}`] + } + if (part.state.status === "error") return [call, `[Tool error]: ${part.state.error}`] + return [call] + }) + .join("\n") +} + function summaryText(message: SessionV1.WithParts) { const text = message.parts .filter((part): part is SessionV1.TextPart => part.type === "text") @@ -348,10 +384,7 @@ const layer = Layer.effect( const nextPrompt = compacting.prompt ?? buildPrompt({ previousSummary, context: compacting.context }) const msgs = structuredClone(selected.head) yield* plugin.trigger("experimental.chat.messages.transform", {}, { messages: msgs }) - const modelMessages = yield* MessageV2.toModelMessagesEffect(msgs, model, { - stripMedia: true, - toolOutputMaxChars: TOOL_OUTPUT_MAX_CHARS, - }) + const conversation = msgs.map(serialize).filter(Boolean).join("\n\n") const ctx = yield* InstanceState.context const msg: SessionV1.Assistant = { id: MessageID.ascending(), @@ -392,10 +425,16 @@ const layer = Layer.effect( tools: {}, system: [], messages: [ - ...modelMessages, { role: "user", - content: [{ type: "text", text: nextPrompt }], + content: [ + { + type: "text", + text: [nextPrompt, "The following is the conversation history:", conversation] + .filter(Boolean) + .join("\n\n"), + }, + ], }, ], model, diff --git a/packages/opencode/test/session/compaction.test.ts b/packages/opencode/test/session/compaction.test.ts index 4a4210cf08bc..0dff7354b5b6 100644 --- a/packages/opencode/test/session/compaction.test.ts +++ b/packages/opencode/test/session/compaction.test.ts @@ -1362,10 +1362,10 @@ describe("session.compaction.process", () => { "summarizes only the head while keeping recent tail out of summary input", () => { const stub = llm() - let captured = "" + let messages: LLM.StreamInput["messages"] = [] stub.push( reply("summary", (input) => { - captured = JSON.stringify(input.messages) + messages = input.messages }), ) return Effect.gen(function* () { @@ -1386,7 +1386,10 @@ describe("session.compaction.process", () => { auto: false, }) - expect(captured).toContain("older context") + const captured = JSON.stringify(messages) + expect(messages).toHaveLength(1) + expect(messages[0]?.role).toBe("user") + expect(captured).toContain("[User]: older context") expect(captured).not.toContain("keep this turn") expect(captured).not.toContain("and this one too") expect(captured).not.toContain("What did we do so far?") @@ -1437,6 +1440,74 @@ describe("session.compaction.process", () => { { git: true }, ) + itCompaction.instance( + "serializes repeated compaction history as one user message", + () => { + const stub = llm() + let captured: LLM.StreamInput["messages"] = [] + stub.push( + reply("summary two", (input) => { + captured = input.messages + }), + ) + + return Effect.gen(function* () { + const ssn = yield* SessionNs.Service + const test = yield* TestInstance + const session = yield* ssn.create({}) + const turn = yield* createUserMessage(session.id, "original request") + const kept = yield* createAssistantMessage(session.id, turn.id, test.directory) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: kept.id, + sessionID: session.id, + type: "tool", + callID: "read-call", + tool: "read", + state: { + status: "completed", + input: { filePath: "src/index.ts" }, + output: "file contents", + title: "src/index.ts", + metadata: {}, + time: { start: Date.now(), end: Date.now() }, + }, + }) + + const previous = yield* ssn.updateMessage({ + id: MessageID.ascending(), + role: "user", + model: ref, + sessionID: session.id, + agent: "build", + time: { created: Date.now() }, + }) + yield* ssn.updatePart({ + id: PartID.ascending(), + messageID: previous.id, + sessionID: session.id, + type: "compaction", + auto: false, + tail_start_id: kept.id, + }) + yield* createSummaryAssistantMessage(session.id, previous.id, test.directory, "summary one") + yield* createCompactionMarker(session.id) + + const msgs = MessageV2.filterCompacted(yield* MessageV2.stream(session.id)) + const parent = msgs.at(-1)?.info.id + expect(parent).toBeTruthy() + yield* SessionCompaction.use.process({ parentID: parent!, messages: msgs, sessionID: session.id, auto: false }) + + expect(captured).toHaveLength(1) + expect(captured[0]?.role).toBe("user") + expect(JSON.stringify(captured)).toContain('[Assistant tool call]: read({\\"filePath\\":\\"src/index.ts\\"})') + expect(JSON.stringify(captured)).toContain("[Tool result]: file contents") + expect(JSON.stringify(captured)).not.toContain('\\"role\\":\\"assistant\\"') + }).pipe(withCompaction({ llm: stub.llmLayer, config: cfg({ tail_turns: 0 }) })) + }, + { git: true }, + ) + itCompaction.instance("keeps recent pre-compaction turns across repeated compactions", () => { const stub = llm() stub.push(reply("summary one")) From 8d65dbdd04b39f6d2830a708bfb843b1d006a44e Mon Sep 17 00:00:00 2001 From: "opencode-agent[bot]" <219766164+opencode-agent[bot]@users.noreply.github.com> Date: Fri, 7 Aug 2026 03:06:23 +0000 Subject: [PATCH 252/333] fix(app): complete translation coverage (#40981) Co-authored-by: Hona <10430890+Hona@users.noreply.github.com> --- packages/app/AGENTS.md | 3 +++ .../app/src/components/dialog-usage-exceeded.tsx | 4 +++- packages/app/src/components/titlebar.tsx | 2 +- packages/app/src/i18n/ar.ts | 13 +++++++++++++ packages/app/src/i18n/az.ts | 13 +++++++++++++ packages/app/src/i18n/br.ts | 13 +++++++++++++ packages/app/src/i18n/bs.ts | 13 +++++++++++++ packages/app/src/i18n/da.ts | 13 +++++++++++++ packages/app/src/i18n/de.ts | 13 +++++++++++++ packages/app/src/i18n/en.ts | 1 + packages/app/src/i18n/es.ts | 13 +++++++++++++ packages/app/src/i18n/fi.ts | 13 +++++++++++++ packages/app/src/i18n/fr.ts | 13 +++++++++++++ packages/app/src/i18n/hi.ts | 13 +++++++++++++ packages/app/src/i18n/id.ts | 13 +++++++++++++ packages/app/src/i18n/it.ts | 13 +++++++++++++ packages/app/src/i18n/ja.ts | 13 +++++++++++++ packages/app/src/i18n/ko.ts | 13 +++++++++++++ packages/app/src/i18n/nl.ts | 13 +++++++++++++ packages/app/src/i18n/no.ts | 13 +++++++++++++ packages/app/src/i18n/pa.ts | 13 +++++++++++++ packages/app/src/i18n/parity.test.ts | 2 +- packages/app/src/i18n/pl.ts | 13 +++++++++++++ packages/app/src/i18n/ru.ts | 13 +++++++++++++ packages/app/src/i18n/sv.ts | 13 +++++++++++++ packages/app/src/i18n/th.ts | 13 +++++++++++++ packages/app/src/i18n/tr.ts | 13 +++++++++++++ packages/app/src/i18n/uk.ts | 13 +++++++++++++ packages/app/src/i18n/ur.ts | 13 +++++++++++++ packages/app/src/i18n/vi.ts | 13 +++++++++++++ packages/app/src/i18n/zh.ts | 13 +++++++++++++ packages/app/src/i18n/zht.ts | 13 +++++++++++++ packages/desktop/AGENTS.md | 2 ++ 33 files changed, 362 insertions(+), 3 deletions(-) diff --git a/packages/app/AGENTS.md b/packages/app/AGENTS.md index 2f587ea73d53..4972f2e3a6cc 100644 --- a/packages/app/AGENTS.md +++ b/packages/app/AGENTS.md @@ -24,6 +24,9 @@ - NEVER hardcode user-visible English strings in production code. ALWAYS use an i18n key for visible copy, placeholders, accessible labels, tooltips, menus, dialogs, toasts, empty states, and displayed errors. - When migrating existing copy to i18n, preserve the English text byte-for-byte unless the task explicitly requests a copy change. - NEVER change existing English text or English keys to facilitate translation. English is intentional, designer-written source copy; adapt locale-specific translations and i18n mechanics around it. +- Keep locale complexity behind the shared typed i18n APIs. Feature and component code should use `language.t(...)` for ordinary copy and `language.plural(baseKey, count, params)` for count-sensitive copy. It must not inspect the locale, call `Intl.PluralRules`, construct or select plural-category keys such as `.one` or `.other`, or branch on locale-specific grammar. +- Prefer complete translated phrases. Do not concatenate grammatical fragments or make call sites assemble sentences. Keep placeholders to irreducible dynamic values such as names, paths, and counts. +- If a translation cannot be expressed by the current API, deepen the shared language/UI i18n module so one typed call owns locale selection, plural resolution, fallback, and interpolation. Do not leak that machinery into product code. - Do not translate from model knowledge alone. Verify terminology and grammar with Unicode CLDR locale/plural data, Microsoft Localization Style Guides and terminology, Apple localization/style guidance and localized platform UI, Mozilla localization style guides, Mozilla Pontoon, and the Firefox localization corpus at `github.com/mozilla-l10n/firefox-l10n`. - Also use the relevant language authority or official dictionary for the locale (for example RAE/Fundéu, FranceTerme, Duden, TDK, Kotus/Kielitoimiston sanakirja, Språkrådet/Bokmålsordboka, Rada Języka Polskiego/PWN, the Russian and Arabic language academies, the Ukrainian Orthography, Taiwan MOE dictionaries, or the Royal Society of Thailand). Treat the English dictionary as the semantic source of truth and preserve placeholders, code identifiers, product names, and keyboard labels. diff --git a/packages/app/src/components/dialog-usage-exceeded.tsx b/packages/app/src/components/dialog-usage-exceeded.tsx index bf5da751e225..de6451a63b6a 100644 --- a/packages/app/src/components/dialog-usage-exceeded.tsx +++ b/packages/app/src/components/dialog-usage-exceeded.tsx @@ -1,4 +1,5 @@ import { usePlatform } from "@/context/platform" +import { useLanguage } from "@/context/language" import { Button } from "@opencode-ai/ui/button" import { useDialog } from "@opencode-ai/ui/context/dialog" import { Dialog } from "@opencode-ai/ui/dialog" @@ -14,6 +15,7 @@ export type DialogGoUpsellProps = { export function DialogUsageExceeded(props: DialogGoUpsellProps) { const dialog = useDialog() + const language = useLanguage() const platform = usePlatform() const runAction = () => { @@ -32,7 +34,7 @@ export function DialogUsageExceeded(props: DialogGoUpsellProps) {