diff --git a/CHANGELOG.md b/CHANGELOG.md index ccdabf6f..9849cacc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,32 @@ All notable changes to this project will be documented in this file. ## [Unreleased] +## [1.22.1] - 2026-09-04 + +- **OpenCode Go:** every request OpenChamber sends to OpenCode Go on its own, such as commit messages and pull request text, reply recaps and suggested follow-ups, walkthroughs, Goal Mode checks, notes, and usage, now carries the `x-opencode-session` header that OpenCode Go requires from 6 September. Chat traffic already had it, because it goes through OpenCode. Update before that date if you use OpenCode Go. +- **Message queue:** messages queued while a session is busy are now sent by the OpenChamber server, so they go out even if the tab that queued them is closed, and every client shows the same queue. A queued message keeps its attached context, file mentions, and skill; editing it brings them back to the composer. +- **Git:** switching branches with uncommitted changes now stops at a commit-or-revert dialog with an optional push; a failed push cancels the switch. The branch picker lists recent branches with an unpushed-commit badge, the mobile Changes view gets a branch picker, and a draft over a dirty directory shows a warning on its branch selector (thanks to @yulia-ivashko). +- **Worktrees:** removing a worktree no longer freezes the interface. It runs in the background with a progress toast, and its sessions are archived in one request, which takes a 121-session worktree from about fifteen seconds to one; archiving several selected sessions from the sidebar uses the same request (thanks to @yulia-ivashko). A worktree created from a branch behind its upstream now fetches first and branches from the remote (thanks to @jtatum). +- **MCP:** an MCP server that failed to start or lost its connection is now reconnected automatically, with a delay growing up to thirty seconds. Disabled servers and ones waiting for a login are left alone. Applies where OpenChamber launches OpenCode itself: Web and Desktop. +- Thinking effort: picking Default now sticks after a send and across agent or session switches, and a reopened session restores the effort its last message used (thanks to @yulia-ivashko). +- Chat: a new session opens on the Chat or Project side you last used, and plus inside a chat opens a chat draft (thanks to @yulia-ivashko). +- Chat: starting an isolated-worktree session from an assistant answer now picks the right project when the visible session already lives in a worktree (thanks to @yulia-ivashko). +- Chat: resizing the window no longer snaps an idle reader back to the end of the conversation. +- Sessions: starting a rename selects the whole title (thanks to @yulia-ivashko). +- Worktrees: the New Worktree dialog keeps its form when the worktree list changes while open, and a removed worktree leaves the sidebar under every project it was listed in (thanks to @yulia-ivashko). +- Git: status stays hidden while a new worktree runs its setup commands instead of flashing mid-setup changes, and refreshes after a tool finishes a change or a worktree changes (thanks to @yulia-ivashko). +- Settings: the theme no longer flips when switching sessions across directories, and missing theme fields keep the current preference (thanks to @kydorn). +- Settings: new installs default to the OpenChamber light and dark themes; an existing Flexoki choice is kept. +- Settings: Fixel Text is available as an interface font. +- Usage: exe.dev usage windows are tracked. +- Settings: the Claude Code and Cursor plugin install cards are gone from Settings → Integrations, which now holds only GitHub and Linear. +- Desktop: the instance switcher keeps its statuses between opens, never shows the connected instance as checking, retries a relay instance for up to fifteen seconds before calling it unreachable, and shows the full instance name. Switching instances clears the previous instance's Linear and GitHub logins, quotas, MCP status, skills, and memory. +- Desktop: dev server previews work over the private relay. +- Terminal: the terminal waits for its font before measuring glyphs, so it no longer renders wrong until resized, and connects on servers running under Bun. +- Server: a browser on https behind an HTTP proxy hop is no longer rejected as a mismatched origin. +- Sidebar: the project label no longer shifts when its hover actions appear. +- Turkish interface: the missing Git repository discovery labels are back (thanks to @kydorn). + ## [1.22.0] - 2026-08-30 - **Linear integration:** connect a workspace in Settings → Integrations, browse and filter issues, and start a session or worktree from an issue. OpenChamber reports session progress back to Linear and can attach an issue to the next chat message (thanks to @AlexKutas). diff --git a/README.md b/README.md index cc5e1e73..8d6b35b1 100644 --- a/README.md +++ b/README.md @@ -5,14 +5,11 @@ [![Discord](https://img.shields.io/badge/Discord-join.svg?style=flat&labelColor=100F0F&color=8B7EC8&logo=discord&logoColor=FFFCF0)](https://discord.gg/ZYRSdnwwKA) [![Support the project](https://img.shields.io/badge/Support-Project-black?style=flat&labelColor=100F0F&color=EC8B49&logo=patreon&logoColor=FFFCF0)](https://www.patreon.com/openchamber) -> [!IMPORTANT] -> 🏖️ I'm on vacation from 31 Aug to 4 Sep. I'll review issues and PRs when I'm back. Thanks for your patience. - ## Run agent work. Keep control. Ship from anywhere. -**OpenChamber is an open-source workspace for running, supervising, and reviewing AI coding work across desktop, browser, editor, and mobile.** +**OpenChamber is an open-source workspace for running and reviewing AI coding work on desktop, web, VS Code, and mobile.** -OpenChamber gives you one place to direct agent work, understand the changes, and move them toward release. Your projects stay available when you switch devices or step away. +Start agent work, see what changed, and take it through review and release. Your projects and sessions remain available when you switch devices or step away. ![OpenChamber Chat](docs/references/chat_example.png) @@ -32,7 +29,7 @@ OpenChamber gives you one place to direct agent work, understand the changes, an ### Goals that continue on their own -Give a session a finish line with **Session Goals**. OpenChamber checks the result after every turn and keeps the agent working until the goal is complete, blocked, or reaches the limit you set — even after you close the app. +Give a session a finish line with **Session Goals**. OpenChamber checks the result after every turn and keeps the agent working until it completes the goal, gets blocked, or reaches the limit you set. It can continue after you close the app. ### Compare and combine runs @@ -44,7 +41,7 @@ Use **Multi-run** to give the same task to up to five models, each in its own se ### Inspect a running app -Open your app beside the conversation with **Preview**. Point at an element and send the agent its screenshot, styles, position, and browser errors — all the context behind “this thing here.” Desktop brings the same workflow to any web page through its built-in browser. +Open your app beside the conversation with **Preview**. Point at an element to send the agent its screenshot, styles, position, and browser errors. No more trying to explain "this thing here." The desktop app can do the same with any web page in its built-in browser. ### GitHub context from issue to pull request @@ -78,7 +75,7 @@ Run a prompt once, daily, weekly, or on a cron schedule. Scheduled tasks can use ## Quick start -### Desktop — macOS, Windows, and Linux +### Desktop for macOS, Windows, and Linux Download the latest release from [GitHub Releases](https://github.com/openchamber/openchamber/releases/latest). Desktop bundles the matching OpenCode CLI, so no separate OpenCode installation is required. @@ -93,9 +90,9 @@ Linux AppImages require FUSE (`libfuse.so.2`). Without FUSE, run with `APPIMAGE_ ### VS Code -Install [OpenChamber from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber), or search for “OpenChamber” in Extensions. +Install [OpenChamber from the Visual Studio Marketplace](https://marketplace.visualstudio.com/items?itemName=fedaykindev.openchamber), or search for "OpenChamber" in Extensions. -### CLI — Web and PWA +### CLI for Web and PWA Requires Node.js 22+. CLI/Web and VS Code use your installed [OpenCode CLI](https://opencode.ai). @@ -139,9 +136,9 @@ For self-hosting details, see the [reverse proxy guide](docs/REVERSE_PROXY.md). ## Why OpenCode? -OpenChamber uses [OpenCode](https://opencode.ai) to power its coding agents. We chose it because we believe it provides the best open-source agentic coding experience today: capable, extensible, and open by design. +OpenChamber uses [OpenCode](https://opencode.ai) to run coding agents. We chose it because it is open source, has a solid API, and is easy to extend. -Around that foundation, OpenChamber brings together the work that happens before, during, and after an agent run — deciding what to try, keeping it on track, reviewing the result, connecting from anywhere, and getting the change shipped. +OpenChamber handles the rest of the workflow. You can decide what to try, keep the agent on track, review the result, connect from another device, and ship the change. OpenChamber is an independent project and is not affiliated with the OpenCode team. @@ -153,11 +150,11 @@ See [CONTRIBUTING.md](./CONTRIBUTING.md) for development setup and contribution Special thanks to: -- [OpenCode](https://opencode.ai) for its excellent API and extensible open-source architecture -- [Pierre](https://pierrejs-docs.vercel.app/) for its fast diff viewer and syntax highlighting +- [OpenCode](https://opencode.ai) for the API and open-source architecture OpenChamber builds on +- [Pierre](https://pierrejs-docs.vercel.app/) for the diff viewer and syntax highlighting - [Ghostty-web](https://github.com/coder/ghostty-web) for its Ghostty web renderer - [Yulia Ivashko](https://github.com/yulia-ivashko), who built the firework celebration that plays on every successful push -- Every contributor who shaped OpenChamber with code, ideas, and attention to detail +- Everyone who contributed code, reported bugs, or shared ideas ## License diff --git a/bun.lock b/bun.lock index 15e74422..7e519662 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -169,7 +169,7 @@ "@dnd-kit/utilities": "^3.2.2", "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", @@ -244,7 +244,7 @@ "version": "1.22.0", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -271,7 +271,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "@simplewebauthn/server": "13.3.1", "bun-pty": "^0.4.5", "compression": "^1.8.1", @@ -1008,7 +1008,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.25", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-GwgwhW+vE8FWSDw730SjzqNhsWXB0uJjbFOiqFkmM+USFuG13HuTlGe6SR2ixt+WXxoD6FV1hILWqsXyqej9hQ=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.28", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-laFCPzeeYVVjr4WiXdA9t7YGguhEsSJDcteiL2H2zH0KDfGOgHXlYoZNRS9QKkucExpN7MDW9RzrsZoJbEXRzw=="], "@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="], diff --git a/package.json b/package.json index dc3602d4..dfeb8edb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "openchamber-monorepo", - "version": "1.22.0", + "version": "1.22.1", "description": "OpenChamber monorepo workspace for web, ui, and desktop runtimes", "private": true, "type": "module", @@ -116,7 +116,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/docs/content/docs/de/github.mdx b/packages/docs/content/docs/de/github.mdx index e2de6f5a..382b8815 100644 --- a/packages/docs/content/docs/de/github.mdx +++ b/packages/docs/content/docs/de/github.mdx @@ -9,11 +9,11 @@ Verbinde dein GitHub-Konto und OpenChamber kann Issues und Pull Requests einlese ## GitHub verbinden -1. Öffne **Einstellungen → Git**. -2. Wähle unter GitHub **Connect**. OpenChamber zeigt einen Link und einen kurzen Code an. +1. Öffne **Einstellungen → Integrationen** und klappe die Karte **GitHub** auf. +2. Wähle **Connect GitHub**. OpenChamber zeigt einen Link und einen kurzen Code an. 3. Öffne den Link, gib den Code ein und bestätige. -Wenn die Verbindung steht, erscheint dein Konto im GitHub-Bereich. Du kannst mehr als ein Konto verbinden und zwischen ihnen wechseln oder dich jederzeit trennen. +Wenn die Verbindung steht, erscheint dein Konto auf der Karte. Du kannst mehr als ein Konto verbinden und zwischen ihnen wechseln oder dich jederzeit trennen. Ist die `gh`-CLI auf demselben Rechner angemeldet, kann OpenChamber sie als Fallback nutzen. ## Arbeit aus einem Issue oder PR starten @@ -28,6 +28,10 @@ So landest du direkt in einer Sitzung, in der der Kontext bereits geladen ist. Im **PR**-Tab der [Git-Ansicht](/git/) kannst du einen Pull Request erstellen, aktualisieren, einen Entwurf als bereit markieren oder mergen. OpenChamber kann den PR-Titel und die Beschreibung aus deinen Änderungen generieren. +## Issue oder PR an eine Nachricht hängen + +Wähle im Chat-Composer **Link GitHub Issue** oder **Link GitHub PR**. Das Issue bzw. der PR mit seinem Diff geht als Kontext mit deiner nächsten Nachricht mit, und das Arbeitsstatus-Panel der Session behält einen Link darauf. Über denselben Picker kannst du auch eine neue Session mit dem Issue als erstem Prompt starten. + ## Verwandt - [Git- & GitHub-Workflows](/git/) — committen und Branches verwalten diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index 38b24051..4756b339 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -1,54 +1,53 @@ --- title: Integrationen -description: Nutze dein Claude- oder Cursor-Abo als Provider. +description: Verbinde GitHub und Linear mit OpenChamber. --- # Integrationen -Eine Integration ist ein kleines Plugin, das OpenChamber einen Provider hinzufügt — auf Basis eines Abos, das du bereits hast. Verwalten kannst du sie unter **Settings → Integrations**. +Unter **Einstellungen → Integrationen** meldest du dich bei den Diensten an, mit denen OpenChamber arbeitet. Die Anmeldung wird auf dem OpenChamber-Server gespeichert, sodass Web, Desktop und ein gekoppeltes Telefon sie teilen. -> **Experimentelle Funktion.** Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko. +## GitHub -Verfügbare Integrationen: +Klappe die Karte **GitHub** auf und wähle **Connect GitHub**, um dich mit einem kurzen Gerätecode anzumelden. Danach kann OpenChamber Sessions aus Issues und Pull Requests starten, sie an Nachrichten hängen und PRs öffnen oder aktualisieren. Alles, was GitHub kann, steht unter [GitHub Issues & PRs](/de/github/). -- **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys -- **Cursor** — die Modell-Limits deines Cursor-Plans +## Linear -## Integration installieren +### Workspace verbinden -1. Öffne **Settings → Integrations**. -2. Suche die Integration und wähle **Install**. -3. Starte OpenCode neu, wenn darum gebeten wird — der Provider erscheint nach dem Neustart. -4. Wähle **Set up** und melde dich an. Die Modelle erscheinen danach in der Modellauswahl im Chat. +1. Klappe die Karte **Linear** auf und wähle **Connect**. +2. Bestätige den Zugriff im Browser-Tab, der sich öffnet. Bis dahin zeigt die Karte **Waiting for Linear**. -Integrationen werden für deinen Benutzer installiert und funktionieren damit in jedem Projekt. Aktualisieren oder entfernen kannst du sie jederzeit über dieselbe Karte. +Eine weitere Linear-Organisation verbindest du mit **Add workspace**. Es ist immer genau ein Workspace aktuell: Wechsle mit **Switch to**, und **Disconnect** entfernt nur den aktuellen. Deine Projektzuordnung bleibt erhalten. -## Claude Code +### Issues durchsuchen -Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne separate Claude-App. +Sobald ein Workspace verbunden ist, erscheint das Panel **Linear** in der rechten Kontextleiste. Es listet die Issues des aktuellen Workspace und erlaubt dir: -1. Installiere die Integration (siehe oben). -2. Wähle **Set up** und melde dich an. Wenn du die Claude Code CLI noch nicht hast, bietet die Einrichtung an, sie zuerst zu installieren, und meldet dich danach an. +- nach Status (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), Zuständigem (**Anyone** oder **Assigned to me**), Team und Priorität zu filtern +- nach Titel, Kennung oder Linear-URL zu suchen +- ein Issue zu öffnen und Beschreibung, Kommentare, Labels und Priorität zu lesen +- seinen **Status** zu ändern oder **Close issue** zu wählen +- den aktuellen **Workspace** zu wechseln -Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht. +### Session aus einem Issue starten -**Wie dein Claude-Konto geschützt bleibt:** Diese Integration nutzt das offizielle Claude Agent SDK von Anthropic und deine installierte Claude Code CLI. Sie kapert kein OAuth, extrahiert oder wiederholt keine Browser-Tokens, gibt sich nicht als nicht unterstützter Client aus und umgeht nicht Anthropics Authentifizierung. Sie bleibt auf dem von Anthropic unterstützten Zugriffsweg und trägt daher nicht das mit Token-Hijacking oder unautorisierten Authentifizierungsumgehungen verbundene Sperrrisiko. +- Öffne im Linear-Panel ein Issue und wähle **Start session**. Der Button bleibt in der Fußzeile, während das Issue scrollt. +- Wähle im Chat **New Session From Linear Issue**, um eines aus der Liste zu wählen, mit **Create in worktree**, wenn du einen frischen Branch möchtest. +- Wähle im Dialog **New Worktree** die Option **Start from Linear Issue**. -## Cursor +Die Session öffnet sich im Projekt, das dem Team des Issues zugeordnet ist, mit dem Issue als erstem Prompt. Teams ordnest du in der Linear-Karte zu: **Default project** gilt für alle Teams, **Team projects** überschreibt es pro Team. Bei einem nicht zugeordneten Team wirst du zuerst um die Zuordnung gebeten. Die erste Nachricht entsteht aus dem Magic Prompt **Issue Review**, den du unter **Einstellungen → Magic Prompts → Linear** bearbeiten kannst. -Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar. +### Issue an eine Nachricht hängen -1. Installiere die Integration (siehe oben). -2. Wähle **Set up**, öffne den Link und genehmige den Zugriff im Browser. Kein API-Key nötig. Die Modellliste lädt nach der Anmeldung automatisch. +Wähle im Chat-Composer **Link Linear Issue**. Das Issue geht als Kontext mit deiner nächsten Nachricht mit, und das Arbeitsstatus-Panel der Session behält einen Link darauf. -## Aktualisieren oder entfernen +### Session-Kommentare -- **Update** installiert die neueste veröffentlichte Version des Plugins. -- **Remove** löscht das Plugin aus deiner OpenCode-Konfiguration. Der Provider wird beim nächsten Neuladen von OpenCode nicht mehr geladen. - -Wenn eine Karte meldet, dass Einträge manuell verwaltet werden müssen, wähle **Manage plugins** und bereinige die Duplikate dort. +Schalte **Session comments** in der Linear-Karte ein, und eine aus einem Issue gestartete Session schreibt beim Start, beim Abschluss oder bei einem Fehler einen Kommentar auf das Issue, jeweils mit einem Link, der die Session öffnet. Kommentare werden nur gepostet, wenn dieser OpenChamber-Server eine öffentliche Adresse hat, damit der Link für alle am Issue funktioniert; bei einer lokalen oder privaten Adresse wird nichts gepostet. ## Verwandtes -- [Anbieter, Modelle und Agenten](/de/providers/) — weitere Provider verbinden und Modelle wählen -- [Nutzung und Kontingente](/de/usage/) — verfolge, wie viel du genutzt hast +- [GitHub Issues & PRs](/de/github/) — alles, was GitHub in OpenChamber kann +- [Worktree-Sessions](/de/worktrees/) — wo Issue-Sessions starten können +- [Magic Prompts](/de/magic-prompts/) — den Prompt bearbeiten, mit dem eine Linear-Session beginnt diff --git a/packages/docs/content/docs/de/providers.mdx b/packages/docs/content/docs/de/providers.mdx index f559dab2..9e1e0752 100644 --- a/packages/docs/content/docs/de/providers.mdx +++ b/packages/docs/content/docs/de/providers.mdx @@ -45,6 +45,6 @@ Provider-Anmeldungen werden von OpenCode gespeichert, nicht von OpenChamber, dah ## Weiterführend -- [Integrationen](/integrations/) — nutze ein Claude-, Command-Code- oder Cursor-Abo als Provider +- [Integrationen](/de/integrations/) — GitHub und Linear verbinden - [MCP-Server](/mcp/) — füge Agents zusätzliche Werkzeuge hinzu - [Nutzung & Kontingente](/usage/) — verfolge, wie viel du verbraucht hast diff --git a/packages/docs/content/docs/es/github.mdx b/packages/docs/content/docs/es/github.mdx index c4889e22..e0f62d4b 100644 --- a/packages/docs/content/docs/es/github.mdx +++ b/packages/docs/content/docs/es/github.mdx @@ -9,11 +9,11 @@ Conecta tu cuenta de GitHub y OpenChamber podrá traer issues y pull requests, i ## Conecta GitHub -1. Abre **Settings → Git**. -2. En GitHub, elige **Connect**. OpenChamber muestra un enlace y un código corto. +1. Abre **Settings → Integrations** y despliega la tarjeta **GitHub**. +2. Elige **Connect GitHub**. OpenChamber muestra un enlace y un código corto. 3. Abre el enlace, introduce el código y aprueba. -Cuando esté conectado, tu cuenta aparece en la sección de GitHub. Puedes conectar más de una cuenta y cambiar entre ellas, o desconectar en cualquier momento. +Cuando esté conectado, tu cuenta aparece en la tarjeta. Puedes conectar más de una cuenta y cambiar entre ellas, o desconectar en cualquier momento. Si la CLI `gh` tiene sesión iniciada en la misma máquina, OpenChamber puede usarla como respaldo. ## Empieza a trabajar desde una issue o PR @@ -28,6 +28,10 @@ Esto te deja directamente en una sesión con el contexto ya cargado. Desde la pestaña **PR** en la [vista de git](/es/git/), puedes crear un pull request, actualizarlo, marcar un borrador como listo o fusionarlo. OpenChamber puede generar el título y la descripción del PR a partir de tus cambios. +## Vincula una issue o PR a un mensaje + +En el compositor del chat, elige **Link GitHub Issue** o **Link GitHub PR**. La issue, o el PR con su diff, acompaña tu siguiente mensaje como contexto, y el panel de estado de trabajo de la sesión conserva un enlace. El mismo selector puede iniciar una nueva sesión con la issue como primer prompt. + ## Relacionado - [Flujos de trabajo de Git y GitHub](/es/git/) — haz commit y gestiona ramas diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index 17582762..82bea6b3 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -1,54 +1,53 @@ --- title: Integraciones -description: Usa tu suscripción de Claude o Cursor como proveedor. +description: Conecta GitHub y Linear con OpenChamber. --- # Integraciones -Una integración es un pequeño plugin que añade un proveedor a OpenChamber usando una suscripción que ya tienes. Las gestionas en **Settings → Integrations**. +En **Ajustes → Integraciones** inicias sesión en los servicios con los que trabaja OpenChamber. El inicio de sesión se guarda en el servidor de OpenChamber, así que la web, el escritorio y un teléfono emparejado lo comparten. -> **Función experimental.** Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad. +## GitHub -Integraciones disponibles: +Despliega la tarjeta **GitHub** y elige **Connect GitHub** para iniciar sesión con un código de dispositivo corto. Una vez conectado, OpenChamber puede iniciar sesiones desde issues y pull requests, adjuntarlos a mensajes y abrir o actualizar PRs. Todo lo que GitHub puede hacer está en [Issues y PRs de GitHub](/es/github/). -- **Claude Code** — tu plan Claude Pro o Max, sin claves de API -- **Cursor** — los límites de modelos de tu plan de Cursor +## Linear -## Instalar una integración +### Conecta un espacio de trabajo -1. Abre **Settings → Integrations**. -2. Busca la integración y elige **Install**. -3. Reinicia OpenCode cuando se te pida — el proveedor aparece tras el reinicio. -4. Elige **Set up** e inicia sesión. Los modelos aparecerán luego en el selector de modelos del chat. +1. Despliega la tarjeta **Linear** y elige **Connect**. +2. Aprueba el acceso en la pestaña del navegador que se abre. Mientras tanto la tarjeta muestra **Waiting for Linear**. -Las integraciones se instalan para tu usuario, así que funcionan en todos los proyectos. Puedes actualizarlas o eliminarlas desde la misma tarjeta en cualquier momento. +Conecta otra organización de Linear con **Add workspace**. Solo un espacio de trabajo es el actual: cambia con **Switch to**, y **Disconnect** quita solo el actual. La asignación de proyectos se conserva. -## Claude Code +### Explora issues -Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de Claude aparte. +Con un espacio de trabajo conectado, el panel **Linear** aparece en la barra de contexto derecha. Lista las issues del espacio actual y te permite: -1. Instala la integración (arriba). -2. Elige **Set up** e inicia sesión. Si aún no tienes la CLI de Claude Code, la configuración ofrece instalarla primero y luego iniciar sesión. +- filtrar por estado (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), asignado (**Anyone** o **Assigned to me**), equipo y prioridad +- buscar por título, identificador o URL de Linear +- abrir una issue para leer su descripción, comentarios, etiquetas y prioridad +- cambiar su **Status** o elegir **Close issue** +- cambiar el **Workspace** actual -Claude Code es la única integración de esta página que requiere tener la CLI de su proveedor instalada y con sesión iniciada. Cursor no requiere su CLI. +### Inicia una sesión desde una issue -**Cómo se protege tu cuenta de Claude:** esta integración usa el Claude Agent SDK oficial de Anthropic y tu CLI de Claude Code instalada. No secuestra OAuth, no extrae ni reutiliza tokens del navegador, no se hace pasar por un cliente no admitido ni omite la autenticación de Anthropic. Se mantiene en la vía de acceso admitida por Anthropic, por lo que no conlleva el riesgo de baneo asociado al secuestro de tokens o a rodeos de autenticación no autorizados. +- En el panel de Linear, abre una issue y elige **Start session**. El botón se queda en el pie mientras la issue se desplaza. +- En el chat, elige **New Session From Linear Issue** para escoger una de la lista, con **Create in worktree** si quieres una rama nueva. +- En el diálogo **New Worktree**, elige **Start from Linear Issue**. -## Cursor +La sesión se abre en el proyecto asignado al equipo de la issue, con la issue como primer prompt. Asigna equipos en la tarjeta de Linear: **Default project** cubre todos los equipos y **Team projects** lo sobrescribe por equipo. Un equipo sin asignar te pide asignarlo primero. El primer mensaje se construye con el magic prompt **Issue Review**, que puedes editar en **Ajustes → Magic Prompts → Linear**. -Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor. +### Vincula una issue a un mensaje -1. Instala la integración (arriba). -2. Elige **Set up**, abre el enlace y autoriza el acceso en tu navegador. No necesitas clave de API. La lista de modelos se carga automáticamente tras iniciar sesión. +En el compositor del chat, elige **Link Linear Issue**. La issue acompaña tu siguiente mensaje como contexto y el panel de estado de trabajo de la sesión conserva un enlace a ella. -## Actualizar o eliminar +### Comentarios de sesión -- **Update** instala la última versión publicada del plugin. -- **Remove** elimina el plugin de tu configuración de OpenCode. El proveedor deja de cargarse cuando OpenCode se recarga. - -Si una tarjeta indica que las entradas requieren gestión manual, elige **Manage plugins** y limpia ahí los duplicados. +Activa **Session comments** en la tarjeta de Linear y una sesión iniciada desde una issue publica un comentario en esa issue al empezar, terminar o fallar, cada uno con un enlace que abre la sesión. Los comentarios solo se publican cuando este servidor de OpenChamber tiene una dirección pública, para que el enlace funcione para todos en la issue; en una dirección local o privada no se publica nada. ## Relacionado -- [Proveedores, modelos y agentes](/es/providers/) — conecta otros proveedores y elige modelos -- [Uso y cuotas](/es/usage/) — sigue cuánto has usado +- [Issues y PRs de GitHub](/es/github/) — todo lo que GitHub puede hacer en OpenChamber +- [Sesiones en worktree](/es/worktrees/) — donde pueden empezar las sesiones desde issues +- [Magic Prompts](/es/magic-prompts/) — edita el prompt con el que empieza una sesión de Linear diff --git a/packages/docs/content/docs/es/providers.mdx b/packages/docs/content/docs/es/providers.mdx index 65526b23..0ca5ee49 100644 --- a/packages/docs/content/docs/es/providers.mdx +++ b/packages/docs/content/docs/es/providers.mdx @@ -45,6 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a ## Relacionado -- [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor +- [Integraciones](/es/integrations/) — conecta GitHub y Linear - [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes - [Uso y cuotas](/es/usage/) — controla cuánto has consumido diff --git a/packages/docs/content/docs/fr/github.mdx b/packages/docs/content/docs/fr/github.mdx index 747bc2f2..3e683db6 100644 --- a/packages/docs/content/docs/fr/github.mdx +++ b/packages/docs/content/docs/fr/github.mdx @@ -9,11 +9,11 @@ Connectez votre compte GitHub et OpenChamber peut récupérer les issues et pull ## Connecter GitHub -1. Ouvrez **Paramètres → Git**. -2. Sous GitHub, choisissez **Connect**. OpenChamber affiche un lien et un code court. +1. Ouvrez **Paramètres → Intégrations** et dépliez la carte **GitHub**. +2. Choisissez **Connect GitHub**. OpenChamber affiche un lien et un code court. 3. Ouvrez le lien, saisissez le code et approuvez. -Une fois connecté, votre compte apparaît dans la section GitHub. Vous pouvez connecter plusieurs comptes et passer de l’un à l’autre, ou vous déconnecter à tout moment. +Une fois connecté, votre compte apparaît sur la carte. Vous pouvez connecter plusieurs comptes et passer de l’un à l’autre, ou vous déconnecter à tout moment. Si la CLI `gh` est connectée sur la même machine, OpenChamber peut l’utiliser en secours. ## Démarrer le travail depuis une issue ou une PR @@ -28,6 +28,10 @@ Vous arrivez directement dans une session avec le contexte déjà chargé. Depuis l’onglet **PR** de la [vue git](/git/), vous pouvez créer une pull request, la mettre à jour, marquer un brouillon comme prêt ou la merger. OpenChamber peut générer le titre et la description de la PR à partir de vos changements. +## Lier une issue ou une PR à un message + +Dans le composer du chat, choisissez **Link GitHub Issue** ou **Link GitHub PR**. L’issue, ou la PR avec son diff, accompagne votre prochain message comme contexte, et le panneau d’état de la session en garde un lien. Le même sélecteur peut démarrer une nouvelle session avec l’issue comme premier prompt. + ## Pages liées - [Workflows Git et GitHub](/git/) — commiter et gérer les branches diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index 2628a870..f81fa1dd 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -1,54 +1,53 @@ --- title: Intégrations -description: Utilise ton abonnement Claude ou Cursor comme fournisseur. +description: Connecte GitHub et Linear à OpenChamber. --- # Intégrations -Une intégration est un petit plugin qui ajoute un fournisseur à OpenChamber à partir d'un abonnement que tu possèdes déjà. Tu les gères dans **Settings → Integrations**. +**Paramètres → Intégrations** est l'endroit où tu te connectes aux services avec lesquels OpenChamber travaille. La connexion est enregistrée sur le serveur OpenChamber, donc le web, le bureau et un téléphone appairé la partagent. -> **Fonctionnalité expérimentale.** Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilise les intégrations à tes risques. +## GitHub -Intégrations disponibles : +Déplie la carte **GitHub** et choisis **Connect GitHub** pour te connecter avec un code d'appareil court. Une fois connecté, OpenChamber peut démarrer des sessions depuis des issues et des pull requests, les attacher à des messages, et ouvrir ou mettre à jour des PR. Tout ce que GitHub sait faire est sur [Issues et PR GitHub](/fr/github/). -- **Claude Code** — ton plan Claude Pro ou Max, sans clés API -- **Cursor** — les limites de modèles de ton plan Cursor +## Linear -## Installer une intégration +### Connecter un espace de travail -1. Ouvre **Settings → Integrations**. -2. Trouve l'intégration et choisis **Install**. -3. Redémarre OpenCode quand c'est demandé — le fournisseur apparaît après le redémarrage. -4. Choisis **Set up** et connecte-toi. Les modèles apparaissent ensuite dans le sélecteur de modèles du chat. +1. Déplie la carte **Linear** et choisis **Connect**. +2. Approuve l'accès dans l'onglet de navigateur qui s'ouvre. La carte affiche **Waiting for Linear** en attendant. -Les intégrations s'installent pour ton utilisateur et fonctionnent donc dans tous les projets. Tu peux les mettre à jour ou les retirer à tout moment depuis la même carte. +Connecte une autre organisation Linear avec **Add workspace**. Un seul espace de travail est courant à la fois : change avec **Switch to**, et **Disconnect** ne retire que le courant. Ton association de projets est conservée. -## Claude Code +### Parcourir les issues -Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans application Claude séparée. +Dès qu'un espace de travail est connecté, le panneau **Linear** apparaît dans la barre de contexte de droite. Il liste les issues de l'espace courant et te permet de : -1. Installe l'intégration (ci-dessus). -2. Choisis **Set up** et connecte-toi. Si tu n'as pas encore la CLI Claude Code, la configuration propose de l'installer d'abord, puis de te connecter. +- filtrer par statut (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), assigné (**Anyone** ou **Assigned to me**), équipe et priorité +- chercher par titre, identifiant ou URL Linear +- ouvrir une issue pour lire sa description, ses commentaires, ses labels et sa priorité +- changer son **Status** ou choisir **Close issue** +- changer le **Workspace** courant -Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Cursor n'exige pas sa CLI. +### Démarrer une session depuis une issue -**Comment ton compte Claude reste protégé :** cette intégration utilise le Claude Agent SDK officiel d'Anthropic et ta CLI Claude Code installée. Elle ne détourne pas l'OAuth, n'extrait ni rejoue de tokens de navigateur, ne se fait pas passer pour un client non pris en charge et ne contourne pas l'authentification d'Anthropic. Elle reste sur la voie d'accès prise en charge par Anthropic et ne porte donc pas le risque de bannissement associé au détournement de tokens ou aux contournements d'authentification non autorisés. +- Dans le panneau Linear, ouvre une issue et choisis **Start session**. Le bouton reste dans le pied pendant que l'issue défile. +- Dans le chat, choisis **New Session From Linear Issue** pour en choisir une dans la liste, avec **Create in worktree** si tu veux une branche neuve. +- Dans la boîte de dialogue **New Worktree**, choisis **Start from Linear Issue**. -## Cursor +La session s'ouvre dans le projet associé à l'équipe de l'issue, avec l'issue comme premier prompt. Associe les équipes dans la carte Linear : **Default project** couvre toutes les équipes, et **Team projects** le remplace équipe par équipe. Une équipe non associée te demande d'abord de le faire. Le premier message est construit à partir du magic prompt **Issue Review**, modifiable dans **Paramètres → Magic Prompts → Linear**. -Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor. +### Lier une issue à un message -1. Installe l'intégration (ci-dessus). -2. Choisis **Set up**, ouvre le lien et autorise l'accès dans ton navigateur. Aucune clé API n'est nécessaire. La liste des modèles se charge automatiquement après la connexion. +Dans le composer du chat, choisis **Link Linear Issue**. L'issue accompagne ton prochain message comme contexte, et le panneau d'état de la session garde un lien vers elle. -## Mettre à jour ou retirer +### Commentaires de session -- **Update** installe la dernière version publiée du plugin. -- **Remove** supprime le plugin de ta configuration OpenCode. Le fournisseur cesse d'être chargé au prochain rechargement d'OpenCode. - -Si une carte indique que les entrées nécessitent une gestion manuelle, choisis **Manage plugins** et nettoie les doublons à cet endroit. +Active **Session comments** dans la carte Linear : une session démarrée depuis une issue publie un commentaire sur cette issue au démarrage, à la fin ou en cas d'échec, chacun avec un lien qui ouvre la session. Les commentaires ne sont publiés que si ce serveur OpenChamber a une adresse publique, pour que le lien fonctionne pour tout le monde sur l'issue ; sur une adresse locale ou privée, rien n'est publié. ## À voir aussi -- [Fournisseurs, modèles et agents](/fr/providers/) — connecter d'autres fournisseurs et choisir des modèles -- [Utilisation et quotas](/fr/usage/) — suis ta consommation +- [Issues et PR GitHub](/fr/github/) — tout ce que GitHub peut faire dans OpenChamber +- [Sessions worktree](/fr/worktrees/) — où les sessions d'issue peuvent démarrer +- [Magic Prompts](/fr/magic-prompts/) — modifier le prompt de départ d'une session Linear diff --git a/packages/docs/content/docs/fr/providers.mdx b/packages/docs/content/docs/fr/providers.mdx index a3ce0149..e790c813 100644 --- a/packages/docs/content/docs/fr/providers.mdx +++ b/packages/docs/content/docs/fr/providers.mdx @@ -45,6 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e ## Pages liées -- [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur +- [Intégrations](/fr/integrations/) — connecter GitHub et Linear - [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents - [Utilisation et quotas](/usage/) — suivre votre consommation diff --git a/packages/docs/content/docs/github.mdx b/packages/docs/content/docs/github.mdx index 95555ad5..9359b525 100644 --- a/packages/docs/content/docs/github.mdx +++ b/packages/docs/content/docs/github.mdx @@ -9,11 +9,11 @@ Connect your GitHub account and OpenChamber can pull in issues and pull requests ## Connect GitHub -1. Open **Settings → Git**. -2. Under GitHub, choose **Connect**. OpenChamber shows a link and a short code. +1. Open **Settings → Integrations** and expand the **GitHub** card. +2. Choose **Connect GitHub**. OpenChamber shows a link and a short code. 3. Open the link, enter the code, and approve. -When it's connected, your account shows under the GitHub section. You can connect more than one account and switch between them, or disconnect at any time. +When it's connected, your account shows on the card. You can connect more than one account and switch between them, or disconnect at any time. If the `gh` CLI is signed in on the same machine, OpenChamber can use it as a fallback. ## Start work from an issue or PR @@ -28,6 +28,10 @@ This drops you straight into a session with the context already loaded. From the **PR** tab in the [git view](/git/), you can create a pull request, update it, mark a draft as ready, or merge it. OpenChamber can generate the PR title and description from your changes. +## Link an issue or PR to a message + +In the chat composer, choose **Link GitHub Issue** or **Link GitHub PR**. The issue, or the PR with its diff, rides along with your next message as context, and the session's work status panel keeps a link to it. The same picker can start a new session with the issue as the first prompt. + ## Related - [Git & GitHub Workflows](/git/) — commit and manage branches diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index 8aef2d38..ee7c18f2 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -1,54 +1,53 @@ --- title: Integrations -description: Use your Claude or Cursor subscription as a provider. +description: Connect GitHub and Linear to OpenChamber. --- # Integrations -An integration is a small plugin that adds a provider to OpenChamber using a subscription you already have. You manage them at **Settings → Integrations**. +**Settings → Integrations** is where you sign in to the services OpenChamber works with. The sign-in is stored on the OpenChamber server, so web, desktop, and a paired phone share it. -> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk. +## GitHub -Available integrations: +Expand the **GitHub** card and choose **Connect GitHub** to sign in with a short device code. Once connected, OpenChamber can start sessions from issues and pull requests, attach them to messages, and open or update PRs. Everything GitHub can do is on [GitHub Issues & PRs](/github/). -- **Claude Code** — your Claude Pro or Max plan, no API keys -- **Cursor** — the model limits of your Cursor plan +## Linear -## Install an integration +### Connect a workspace -1. Open **Settings → Integrations**. -2. Find the integration and choose **Install**. -3. Restart OpenCode when asked — the provider appears after the restart. -4. Choose **Set up** and sign in. The models then appear in the chat model picker. +1. Expand the **Linear** card and choose **Connect**. +2. Approve access in the browser tab that opens. The card shows **Waiting for Linear** until you do. -Integrations install for your user, so they work in every project. You can update or remove them from the same card at any time. +Connect another Linear organization with **Add workspace**. One workspace is current at a time: switch with **Switch to**, and **Disconnect** removes only the current one. Your project mapping stays. -## Claude Code +### Browse issues -Claude Code uses your Claude Pro or Max plan — no API keys and no separate Claude app. +Once a workspace is connected, the **Linear** panel appears in the right-hand context rail. It lists the current workspace's issues and lets you: -1. Install the integration (above). -2. Choose **Set up** and sign in. If you don't have the Claude Code CLI yet, setup offers to install it first and then sign you in. +- filter by status (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), assignee (**Anyone** or **Assigned to me**), team, and priority +- search by title, identifier, or a Linear URL +- open an issue to read its description, comments, labels, and priority +- change its **Status** or **Close issue** +- switch the current **Workspace** -Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI. +### Start a session from an issue -**How your Claude account stays safe:** this integration uses Anthropic's official Claude Agent SDK and your installed Claude Code CLI. It does not hijack OAuth, extract or replay browser tokens, impersonate an unsupported client, or bypass Anthropic's authentication flow. It stays on Anthropic's supported access path, so it does not carry the account-ban risk of token hijacking or unauthorized authentication workarounds. +- In the Linear panel, open an issue and choose **Start session**. The button stays in the footer while the issue scrolls. +- In the chat, choose **New Session From Linear Issue** to pick one from the list, with **Create in worktree** if you want a fresh branch. +- In the **New Worktree** dialog, choose **Start from Linear Issue**. -## Cursor +The session opens in the project mapped to the issue's team, with the issue as the first prompt. Map teams in the Linear card: **Default project** covers every team, and **Team projects** override it per team. An unmapped team asks you to map it first. The first message is built from the **Issue Review** magic prompt, which you can edit at **Settings → Magic Prompts → Linear**. -Cursor makes the models included in your Cursor plan available in OpenChamber. +### Link an issue to a message -1. Install the integration (above). -2. Choose **Set up**, open the link, and approve access in your browser. No API key is required. The model list loads automatically after you sign in. +In the chat composer, choose **Link Linear Issue**. The issue rides along with your next message as context, and the session's work status panel keeps a link back to it. -## Update or remove +### Session comments -- **Update** installs the latest published version of the plugin. -- **Remove** deletes the plugin from your OpenCode config. The provider stops loading after OpenCode refreshes. - -If a card says the entries need manual management, choose **Manage plugins** and clean up the duplicates there. +Turn on **Session comments** in the Linear card and a session started from an issue posts a comment on that issue when it starts, finishes, or fails, each with a link that opens the session. Comments are only posted when this OpenChamber server has a public address, so the link works for everyone on the issue; on a local or private address nothing is posted. ## Related -- [Providers, Models & Agents](/providers/) — connect other providers and pick models -- [Usage & Quotas](/usage/) — track how much you've used +- [GitHub Issues & PRs](/github/) — everything GitHub can do in OpenChamber +- [Worktree Sessions](/worktrees/) — where issue sessions can start +- [Magic Prompts](/magic-prompts/) — edit the prompt a Linear session starts with diff --git a/packages/docs/content/docs/ja/github.mdx b/packages/docs/content/docs/ja/github.mdx index aaa282f7..7b3a02fb 100644 --- a/packages/docs/content/docs/ja/github.mdx +++ b/packages/docs/content/docs/ja/github.mdx @@ -9,11 +9,11 @@ GitHub アカウントを接続すると、OpenChamber は Issue とプルリク ## GitHub を接続する -1. **Settings → Git** を開きます。 -2. GitHub セクションで **Connect** を選びます。OpenChamber がリンクと短いコードを表示します。 +1. **Settings → Integrations** を開き、**GitHub** カードを展開します。 +2. **Connect GitHub** を選びます。OpenChamber がリンクと短いコードを表示します。 3. リンクを開き、コードを入力して承認します。 -接続されると、GitHub セクションにアカウントが表示されます。複数のアカウントを接続して切り替えたり、いつでも切断したりできます。 +接続されると、カードにアカウントが表示されます。複数のアカウントを接続して切り替えたり、いつでも切断したりできます。同じマシンで `gh` CLI にサインイン済みなら、OpenChamber はそれをフォールバックとして使えます。 ## Issue または PR から作業を始める @@ -28,6 +28,10 @@ GitHub 接続済みで [worktree セッション](/worktrees/) を作成する [Git ビュー](/git/) の **PR** タブから、プルリクエストの作成、更新、draft から ready への変更、マージができます。OpenChamber は変更内容から PR のタイトルと説明を生成できます。 +## Issue や PR をメッセージに添付する + +チャットのコンポーザーで **Link GitHub Issue** または **Link GitHub PR** を選びます。Issue、または diff 付きの PR が次のメッセージにコンテキストとして添付され、セッションの作業ステータスパネルにリンクが残ります。同じピッカーから、Issue を最初のプロンプトにした新しいセッションを開始することもできます。 + ## 関連 - [Git と GitHub ワークフロー](/git/) — コミットとブランチ管理 diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index b13ac0cd..b7da4912 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -1,54 +1,53 @@ --- title: 統合機能 -description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。 +description: GitHub と Linear を OpenChamber に接続する。 --- # 統合機能 -統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。 +**設定 → 統合機能** では、OpenChamber が連携するサービスにサインインします。サインイン情報は OpenChamber サーバーに保存されるため、Web、デスクトップ、ペアリングした端末で共有されます。 -> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。 +## GitHub -利用できる統合機能: +**GitHub** カードを展開し、**Connect GitHub** を選んで短いデバイスコードでサインインします。接続後は、Issue やプルリクエストからセッションを開始し、メッセージに添付し、PR を作成・更新できます。GitHub でできることは [GitHub Issues と PR](/ja/github/) にまとめています。 -- **Claude Code** — Claude Pro または Max プラン、API キー不要 -- **Cursor** — Cursor プランのモデル利用枠 +## Linear -## 統合機能をインストールする +### ワークスペースを接続する -1. **Settings → Integrations** を開きます。 -2. 統合機能を見つけて **Install** を選びます。 -3. 求められたら OpenCode を再起動します — 再起動後にプロバイダーが現れます。 -4. **Set up** を選んでサインインします。するとチャットのモデル選択にモデルが表示されます。 +1. **Linear** カードを展開し、**Connect** を選びます。 +2. 開いたブラウザータブでアクセスを承認します。承認するまでカードには **Waiting for Linear** と表示されます。 -統合機能はユーザー単位でインストールされるため、すべてのプロジェクトで使えます。同じカードからいつでも更新や削除ができます。 +別の Linear 組織は **Add workspace** で接続します。現在のワークスペースは常に 1 つで、**Switch to** で切り替え、**Disconnect** は現在のものだけを外します。プロジェクトの対応付けは残ります。 -## Claude Code +### Issue を閲覧する -Claude Code は Claude Pro または Max プランを使います — API キーも Claude アプリも不要です。 +ワークスペースを接続すると、右側のコンテキストレールに **Linear** パネルが現れます。現在のワークスペースの Issue を一覧し、次の操作ができます。 -1. 統合機能をインストールします(上記)。 -2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。 +- ステータス(**Backlog**、**To Do**、**In Progress**、**In Review**、**Done**、**Canceled**、**Duplicate**)、担当者(**Anyone** または **Assigned to me**)、チーム、優先度で絞り込む +- タイトル、識別子、Linear の URL で検索する +- Issue を開いて説明、コメント、ラベル、優先度を読む +- **Status** を変更する、または **Close issue** を選ぶ +- 現在の **Workspace** を切り替える -Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。 +### Issue からセッションを開始する -**Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。 +- Linear パネルで Issue を開き、**Start session** を選びます。ボタンは Issue をスクロールしてもフッターに残ります。 +- チャットで **New Session From Linear Issue** を選ぶと一覧から選べます。新しいブランチが欲しければ **Create in worktree** を使います。 +- **New Worktree** ダイアログで **Start from Linear Issue** を選びます。 -## Cursor +セッションは Issue のチームに対応付けられたプロジェクトで開き、Issue が最初のプロンプトになります。チームの対応付けは Linear カードで行います。**Default project** が全チームに適用され、**Team projects** でチームごとに上書きできます。対応付けのないチームでは、先に対応付けを求められます。最初のメッセージは Magic Prompt の **Issue Review** から作られ、**設定 → Magic Prompts → Linear** で編集できます。 -Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。 +### Issue をメッセージに添付する -1. 統合機能をインストールします(上記)。 -2. **Set up** を選び、リンクを開いてブラウザーでアクセスを許可します。API キーは不要です。サインイン後、モデル一覧は自動的に読み込まれます。 +チャットのコンポーザーで **Link Linear Issue** を選びます。Issue は次のメッセージにコンテキストとして添付され、セッションの作業ステータスパネルにリンクが残ります。 -## 更新と削除 +### セッションコメント -- **Update** はプラグインの最新の公開バージョンをインストールします。 -- **Remove** は OpenCode の設定からプラグインを削除します。OpenCode が再読み込みされるとプロバイダーは読み込まれなくなります。 - -カードに手動での管理が必要だと表示された場合は、**Manage plugins** を選んで重複を整理してください。 +Linear カードで **Session comments** をオンにすると、Issue から開始したセッションが開始時、完了時、失敗時にその Issue へコメントを投稿します。各コメントにはセッションを開くリンクが付きます。コメントは、この OpenChamber サーバーが公開アドレスを持つ場合にのみ投稿されます(Issue の関係者全員がリンクを開けるようにするため)。ローカルやプライベートなアドレスでは何も投稿されません。 ## 関連情報 -- [プロバイダー、モデル、エージェント](/ja/providers/) — 他のプロバイダーの接続とモデルの選択 -- [使用量とクォータ](/ja/usage/) — 利用量を追跡 +- [GitHub Issues と PR](/ja/github/) — OpenChamber で GitHub にできること +- [ワークツリーセッション](/ja/worktrees/) — Issue からのセッションを開始できる場所 +- [Magic Prompts](/ja/magic-prompts/) — Linear セッションの最初のプロンプトを編集する diff --git a/packages/docs/content/docs/ja/providers.mdx b/packages/docs/content/docs/ja/providers.mdx index 7e9c0525..5f8756cf 100644 --- a/packages/docs/content/docs/ja/providers.mdx +++ b/packages/docs/content/docs/ja/providers.mdx @@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ ## 関連 -- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う +- [統合機能](/ja/integrations/) — GitHub と Linear を接続する - [MCP サーバー](/mcp/) — エージェントに追加ツールを加える - [使用量とクォータ](/usage/) — 使った量を追跡する diff --git a/packages/docs/content/docs/ko/github.mdx b/packages/docs/content/docs/ko/github.mdx index 5e61b22b..c98dd4af 100644 --- a/packages/docs/content/docs/ko/github.mdx +++ b/packages/docs/content/docs/ko/github.mdx @@ -9,11 +9,11 @@ GitHub 계정을 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 가 ## GitHub 연결하기 -1. **Settings → Git**를 엽니다. -2. GitHub 아래에서 **Connect**를 선택합니다. OpenChamber가 링크와 짧은 코드를 표시합니다. +1. **Settings → Integrations**를 열고 **GitHub** 카드를 펼칩니다. +2. **Connect GitHub**를 선택합니다. OpenChamber가 링크와 짧은 코드를 표시합니다. 3. 링크를 열고 코드를 입력한 뒤 승인합니다. -연결되면 GitHub 섹션 아래에 계정이 표시됩니다. 계정을 여러 개 연결해 전환할 수 있으며, 언제든 연결을 해제할 수 있습니다. +연결되면 카드에 계정이 표시됩니다. 계정을 여러 개 연결해 전환할 수 있으며, 언제든 연결을 해제할 수 있습니다. 같은 컴퓨터에서 `gh` CLI에 로그인되어 있으면 OpenChamber가 이를 대체 수단으로 사용할 수 있습니다. ## 이슈나 PR에서 작업 시작하기 @@ -28,6 +28,10 @@ GitHub가 연결된 상태에서 [worktree 세션](/ko/worktrees/)을 만들면 [git 보기](/ko/git/)의 **PR** 탭에서 풀 리퀘스트를 만들고, 업데이트하고, 초안을 준비 완료로 표시하거나 병합할 수 있습니다. OpenChamber는 변경 사항에서 PR 제목과 설명을 생성할 수 있습니다. +## 이슈나 PR을 메시지에 연결하기 + +채팅 작성창에서 **Link GitHub Issue** 또는 **Link GitHub PR**을 선택합니다. 이슈, 또는 diff가 포함된 PR이 다음 메시지에 컨텍스트로 함께 전송되고, 세션의 작업 상태 패널에 링크가 남습니다. 같은 선택기에서 이슈를 첫 프롬프트로 하는 새 세션을 시작할 수도 있습니다. + ## 관련 항목 - [Git & GitHub Workflows](/ko/git/) — 커밋하고 브랜치를 관리하세요 diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index 0663879e..ef17b34e 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -1,54 +1,53 @@ --- title: 통합 기능 -description: Claude 또는 Cursor 구독을 공급자로 사용하세요. +description: GitHub와 Linear를 OpenChamber에 연결합니다. --- # 통합 기능 -통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다. +**설정 → 통합 기능**에서 OpenChamber가 함께 사용하는 서비스에 로그인합니다. 로그인 정보는 OpenChamber 서버에 저장되므로 웹, 데스크톱, 페어링된 휴대전화가 이를 공유합니다. -> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요. +## GitHub -사용 가능한 통합 기능: +**GitHub** 카드를 펼치고 **Connect GitHub**를 선택해 짧은 기기 코드로 로그인합니다. 연결되면 OpenChamber가 이슈와 풀 리퀘스트에서 세션을 시작하고, 메시지에 첨부하고, PR을 열거나 업데이트할 수 있습니다. GitHub로 할 수 있는 모든 것은 [GitHub 이슈 및 PR](/ko/github/)에 있습니다. -- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요 -- **Cursor** — Cursor 플랜의 모델 한도 +## Linear -## 통합 기능 설치 +### 워크스페이스 연결하기 -1. **Settings → Integrations**를 엽니다. -2. 통합 기능을 찾아 **Install**을 선택합니다. -3. 요청되면 OpenCode를 다시 시작합니다 — 재시작 후 공급자가 나타납니다. -4. **Set up**를 선택하고 로그인합니다. 그러면 채팅의 모델 선택기에 모델이 나타납니다. +1. **Linear** 카드를 펼치고 **Connect**를 선택합니다. +2. 열리는 브라우저 탭에서 접근을 승인합니다. 승인 전까지 카드에는 **Waiting for Linear**가 표시됩니다. -통합 기능은 사용자 단위로 설치되므로 모든 프로젝트에서 작동합니다. 같은 카드에서 언제든 업데이트하거나 제거할 수 있습니다. +다른 Linear 조직은 **Add workspace**로 연결합니다. 현재 워크스페이스는 한 번에 하나이며, **Switch to**로 전환하고 **Disconnect**는 현재 워크스페이스만 제거합니다. 프로젝트 매핑은 유지됩니다. -## Claude Code +### 이슈 찾아보기 -Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도 별도의 Claude 앱도 필요 없습니다. +워크스페이스가 연결되면 오른쪽 컨텍스트 레일에 **Linear** 패널이 나타납니다. 현재 워크스페이스의 이슈를 나열하며 다음을 할 수 있습니다. -1. 통합 기능을 설치합니다(위 참고). -2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다. +- 상태(**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), 담당자(**Anyone** 또는 **Assigned to me**), 팀, 우선순위로 필터링 +- 제목, 식별자 또는 Linear URL로 검색 +- 이슈를 열어 설명, 댓글, 라벨, 우선순위 확인 +- **Status** 변경 또는 **Close issue** 선택 +- 현재 **Workspace** 전환 -Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다. +### 이슈에서 세션 시작하기 -**Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다. +- Linear 패널에서 이슈를 열고 **Start session**을 선택합니다. 이슈를 스크롤해도 버튼은 하단에 남아 있습니다. +- 채팅에서 **New Session From Linear Issue**를 선택해 목록에서 고릅니다. 새 브랜치가 필요하면 **Create in worktree**를 사용합니다. +- **New Worktree** 대화상자에서 **Start from Linear Issue**를 선택합니다. -## Cursor +세션은 이슈의 팀에 매핑된 프로젝트에서 열리고, 이슈가 첫 프롬프트가 됩니다. 팀 매핑은 Linear 카드에서 합니다. **Default project**는 모든 팀에 적용되고 **Team projects**는 팀별로 이를 덮어씁니다. 매핑되지 않은 팀은 먼저 매핑하라고 요청합니다. 첫 메시지는 **Issue Review** 매직 프롬프트로 만들어지며 **설정 → Magic Prompts → Linear**에서 편집할 수 있습니다. -Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다. +### 이슈를 메시지에 연결하기 -1. 통합 기능을 설치합니다(위 참고). -2. **Set up**를 선택하고 링크를 열어 브라우저에서 접근을 승인합니다. API 키는 필요 없습니다. 로그인 후 모델 목록이 자동으로 로드됩니다. +채팅 작성창에서 **Link Linear Issue**를 선택합니다. 이슈가 다음 메시지에 컨텍스트로 함께 전송되고, 세션의 작업 상태 패널에 링크가 남습니다. -## 업데이트 및 제거 +### 세션 댓글 -- **Update**는 플러그인의 최신 공개 버전을 설치합니다. -- **Remove**는 OpenCode 설정에서 플러그인을 삭제합니다. OpenCode가 다시 로드되면 공급자는 더 이상 로드되지 않습니다. - -카드에 항목을 수동으로 관리해야 한다고 표시되면 **Manage plugins**를 선택해 중복 항목을 정리하세요. +Linear 카드에서 **Session comments**를 켜면 이슈에서 시작한 세션이 시작, 완료, 실패 시 해당 이슈에 댓글을 남기며, 각 댓글에는 세션을 여는 링크가 포함됩니다. 댓글은 이 OpenChamber 서버에 공개 주소가 있을 때만 게시되어 이슈의 모든 사람이 링크를 열 수 있습니다. 로컬이나 사설 주소에서는 아무것도 게시되지 않습니다. ## 관련 문서 -- [공급자, 모델, 에이전트](/ko/providers/) — 다른 공급자 연결과 모델 선택 -- [사용량 및 할당량](/ko/usage/) — 사용량 추적 +- [GitHub 이슈 및 PR](/ko/github/) — OpenChamber에서 GitHub로 할 수 있는 모든 것 +- [워크트리 세션](/ko/worktrees/) — 이슈 세션을 시작할 수 있는 곳 +- [Magic Prompts](/ko/magic-prompts/) — Linear 세션이 시작하는 프롬프트 편집 diff --git a/packages/docs/content/docs/ko/providers.mdx b/packages/docs/content/docs/ko/providers.mdx index 98fb109d..e07f46a1 100644 --- a/packages/docs/content/docs/ko/providers.mdx +++ b/packages/docs/content/docs/ko/providers.mdx @@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가 ## 관련 항목 -- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용 +- [통합 기능](/ko/integrations/) — GitHub와 Linear 연결 - [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다 - [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다 diff --git a/packages/docs/content/docs/pl/github.mdx b/packages/docs/content/docs/pl/github.mdx index 5d56001c..4ee95c1b 100644 --- a/packages/docs/content/docs/pl/github.mdx +++ b/packages/docs/content/docs/pl/github.mdx @@ -9,11 +9,11 @@ Połącz swoje konto GitHub, a OpenChamber może pobierać issues i pull request ## Połącz GitHub -1. Otwórz **Settings → Git**. -2. W sekcji GitHub wybierz **Connect**. OpenChamber pokazuje link i krótki kod. +1. Otwórz **Settings → Integrations** i rozwiń kartę **GitHub**. +2. Wybierz **Connect GitHub**. OpenChamber pokazuje link i krótki kod. 3. Otwórz link, wpisz kod i zatwierdź. -Po połączeniu Twoje konto pojawia się w sekcji GitHub. Możesz połączyć więcej niż jedno konto i przełączać się między nimi albo rozłączyć w dowolnym momencie. +Po połączeniu Twoje konto pojawia się na karcie. Możesz połączyć więcej niż jedno konto i przełączać się między nimi albo rozłączyć w dowolnym momencie. Jeśli CLI `gh` jest zalogowane na tej samej maszynie, OpenChamber może użyć go jako zapasowego źródła. ## Rozpocznij pracę od issue lub PR @@ -28,6 +28,10 @@ To wprowadza Cię prosto do sesji z już wczytanym kontekstem. Z zakładki **PR** w [widoku git](/pl/git/) możesz utworzyć pull request, zaktualizować go, oznaczyć szkic jako gotowy lub scalić go. OpenChamber może wygenerować tytuł i opis PR na podstawie Twoich zmian. +## Dołącz issue lub PR do wiadomości + +W polu czatu wybierz **Link GitHub Issue** lub **Link GitHub PR**. Issue, albo PR razem z jego diffem, trafia jako kontekst do Twojej następnej wiadomości, a panel statusu pracy sesji zachowuje do niego link. Ten sam selektor może rozpocząć nową sesję z issue jako pierwszym promptem. + ## Powiązane - [Przepływy Git i GitHub](/pl/git/) — commituj i zarządzaj gałęziami diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index 7e3ddf2c..19c4b44a 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -1,54 +1,53 @@ --- title: Integracje -description: Używaj subskrypcji Claude lub Cursor jako dostawcy. +description: Połącz GitHub i Linear z OpenChamber. --- # Integracje -Integracja to mała wtyczka, która dodaje dostawcę do OpenChamber na podstawie subskrypcji, którą już masz. Zarządzasz nimi w **Settings → Integrations**. +W **Ustawienia → Integracje** logujesz się do usług, z którymi pracuje OpenChamber. Logowanie jest zapisywane na serwerze OpenChamber, więc web, desktop i sparowany telefon korzystają z niego wspólnie. -> **Funkcja eksperymentalna.** Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko. +## GitHub -Dostępne integracje: +Rozwiń kartę **GitHub** i wybierz **Connect GitHub**, aby zalogować się krótkim kodem urządzenia. Po połączeniu OpenChamber może rozpoczynać sesje z issues i pull requestów, dołączać je do wiadomości oraz otwierać i aktualizować PR-y. Wszystko, co potrafi GitHub, opisuje strona [Issues i PR na GitHub](/pl/github/). -- **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API -- **Cursor** — limity modeli z Twojego planu Cursor +## Linear -## Instalacja integracji +### Połącz przestrzeń roboczą -1. Otwórz **Settings → Integrations**. -2. Znajdź integrację i wybierz **Install**. -3. Uruchom OpenCode ponownie, gdy o to poproszą — dostawca pojawi się po restarcie. -4. Wybierz **Set up** i zaloguj się. Modele pojawią się potem w selektorze modeli na czacie. +1. Rozwiń kartę **Linear** i wybierz **Connect**. +2. Zatwierdź dostęp w karcie przeglądarki, która się otworzy. Do tego czasu karta pokazuje **Waiting for Linear**. -Integracje instalują się dla Twojego użytkownika, więc działają w każdym projekcie. W każdej chwili możesz je zaktualizować lub usunąć z tej samej karty. +Kolejną organizację Linear połączysz przez **Add workspace**. Bieżąca jest zawsze jedna przestrzeń: przełączaj przez **Switch to**, a **Disconnect** usuwa tylko bieżącą. Mapowanie projektów zostaje. -## Claude Code +### Przeglądaj issues -Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez osobnej aplikacji Claude. +Gdy przestrzeń robocza jest połączona, w prawym pasku kontekstu pojawia się panel **Linear**. Wyświetla issues bieżącej przestrzeni i pozwala: -1. Zainstaluj integrację (patrz wyżej). -2. Wybierz **Set up** i zaloguj się. Jeśli nie masz jeszcze Claude Code CLI, konfiguracja zaoferuje najpierw jego instalację, a potem logowanie. +- filtrować po statusie (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), osobie przypisanej (**Anyone** lub **Assigned to me**), zespole i priorytecie +- szukać po tytule, identyfikatorze lub adresie URL Linear +- otworzyć issue, aby przeczytać opis, komentarze, etykiety i priorytet +- zmienić jego **Status** lub wybrać **Close issue** +- przełączyć bieżący **Workspace** -Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI. +### Rozpocznij sesję z issue -**Jak chronione jest Twoje konto Claude:** ta integracja używa oficjalnego Claude Agent SDK od Anthropic i Twojego zainstalowanego Claude Code CLI. Nie przechwytuje OAuth, nie wyodrębnia ani nie odtwarza tokenów przeglądarki, nie podszywa się pod nieobsługiwany klient i nie omija uwierzytelniania Anthropic. Działa na obsługiwanej przez Anthropic ścieżce dostępu, więc nie niesie ryzyka zablokowania konta związanego z przechwytywaniem tokenów lub nieautoryzowanymi obejściami uwierzytelniania. +- W panelu Linear otwórz issue i wybierz **Start session**. Przycisk zostaje w stopce, gdy przewijasz issue. +- W czacie wybierz **New Session From Linear Issue**, aby wybrać z listy, z opcją **Create in worktree**, jeśli chcesz świeżej gałęzi. +- W oknie **New Worktree** wybierz **Start from Linear Issue**. -## Cursor +Sesja otwiera się w projekcie zmapowanym do zespołu issue, a issue staje się pierwszym promptem. Zespoły mapujesz na karcie Linear: **Default project** obejmuje wszystkie zespoły, a **Team projects** nadpisuje go dla poszczególnych. Niezmapowany zespół najpierw poprosi o mapowanie. Pierwsza wiadomość powstaje z magic promptu **Issue Review**, który edytujesz w **Ustawienia → Magic Prompts → Linear**. -Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor. +### Dołącz issue do wiadomości -1. Zainstaluj integrację (patrz wyżej). -2. Wybierz **Set up**, otwórz link i zatwierdź dostęp w przeglądarce. Klucz API nie jest potrzebny. Lista modeli wczyta się automatycznie po zalogowaniu. +W polu czatu wybierz **Link Linear Issue**. Issue trafia jako kontekst do Twojej następnej wiadomości, a panel statusu pracy sesji zachowuje do niego link. -## Aktualizacja i usuwanie +### Komentarze sesji -- **Update** instaluje najnowszą opublikowaną wersję wtyczki. -- **Remove** usuwa wtyczkę z konfiguracji OpenCode. Dostawca przestanie się ładować po odświeżeniu OpenCode. - -Jeśli karta wskazuje, że wpisy wymagają ręcznego zarządzania, wybierz **Manage plugins** i wyczyść tam duplikaty. +Włącz **Session comments** na karcie Linear, a sesja rozpoczęta z issue doda na nim komentarz przy starcie, zakończeniu lub błędzie, każdy z linkiem otwierającym sesję. Komentarze są publikowane tylko wtedy, gdy ten serwer OpenChamber ma publiczny adres, aby link działał dla wszystkich w issue; przy adresie lokalnym lub prywatnym nic nie jest publikowane. ## Powiązane -- [Dostawcy, modele i agenci](/pl/providers/) — podłączanie innych dostawców i wybór modeli -- [Zużycie i limity](/pl/usage/) — śledź, ile wykorzystałeś +- [Issues i PR na GitHub](/pl/github/) — wszystko, co GitHub potrafi w OpenChamber +- [Sesje worktree](/pl/worktrees/) — gdzie mogą zaczynać się sesje z issue +- [Magic Prompts](/pl/magic-prompts/) — edytuj prompt, od którego zaczyna się sesja Linear diff --git a/packages/docs/content/docs/pl/providers.mdx b/packages/docs/content/docs/pl/providers.mdx index 4c777176..de8297e9 100644 --- a/packages/docs/content/docs/pl/providers.mdx +++ b/packages/docs/content/docs/pl/providers.mdx @@ -45,6 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc ## Powiązane -- [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy +- [Integracje](/pl/integrations/) — połącz GitHub i Linear - [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia - [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś diff --git a/packages/docs/content/docs/providers.mdx b/packages/docs/content/docs/providers.mdx index 2505ced4..0a0dc954 100644 --- a/packages/docs/content/docs/providers.mdx +++ b/packages/docs/content/docs/providers.mdx @@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit ## Related -- [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider +- [Integrations](/integrations/) — connect GitHub and Linear - [MCP Servers](/mcp/) — add extra tools for agents - [Usage & Quotas](/usage/) — track how much you've used diff --git a/packages/docs/content/docs/pt-br/github.mdx b/packages/docs/content/docs/pt-br/github.mdx index 8379b8f6..349b6d66 100644 --- a/packages/docs/content/docs/pt-br/github.mdx +++ b/packages/docs/content/docs/pt-br/github.mdx @@ -9,11 +9,11 @@ Conecte sua conta do GitHub e o OpenChamber pode trazer issues e pull requests, ## Conectar o GitHub -1. Abra **Settings → Git**. -2. Em GitHub, escolha **Connect**. O OpenChamber mostra um link e um código curto. +1. Abra **Settings → Integrations** e expanda o cartão **GitHub**. +2. Escolha **Connect GitHub**. O OpenChamber mostra um link e um código curto. 3. Abra o link, insira o código e aprove. -Quando estiver conectado, sua conta aparece na seção do GitHub. Você pode conectar mais de uma conta e alternar entre elas, ou desconectar a qualquer momento. +Quando estiver conectado, sua conta aparece no cartão. Você pode conectar mais de uma conta e alternar entre elas, ou desconectar a qualquer momento. Se a CLI `gh` estiver logada na mesma máquina, o OpenChamber pode usá-la como alternativa. ## Começar o trabalho a partir de uma issue ou PR @@ -28,6 +28,10 @@ Isso o coloca direto em uma sessão com o contexto já carregado. Pela aba **PR** na [visão de git](/pt-br/git/), você pode criar um pull request, atualizá-lo, marcar um rascunho como pronto ou fazer merge. O OpenChamber pode gerar o título e a descrição do PR a partir das suas mudanças. +## Vincule uma issue ou PR a uma mensagem + +No compositor do chat, escolha **Link GitHub Issue** ou **Link GitHub PR**. A issue, ou o PR com seu diff, acompanha sua próxima mensagem como contexto, e o painel de status de trabalho da sessão mantém um link para ela. O mesmo seletor pode iniciar uma nova sessão com a issue como primeiro prompt. + ## Relacionado - [Fluxos de Git e GitHub](/pt-br/git/) — faça commit e gerencie branches diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index 82ed2e68..ecb3d272 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -1,54 +1,53 @@ --- title: Integrações -description: Use sua assinatura Claude ou Cursor como provedor. +description: Conecte o GitHub e o Linear ao OpenChamber. --- # Integrações -Uma integração é um pequeno plugin que adiciona um provedor ao OpenChamber usando uma assinatura que você já tem. Você as gerencia em **Settings → Integrations**. +Em **Configurações → Integrações** você entra nos serviços com os quais o OpenChamber trabalha. O login fica salvo no servidor do OpenChamber, então web, desktop e um telefone pareado o compartilham. -> **Recurso experimental.** Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco. +## GitHub -Integrações disponíveis: +Expanda o cartão **GitHub** e escolha **Connect GitHub** para entrar com um código curto de dispositivo. Conectado, o OpenChamber pode iniciar sessões a partir de issues e pull requests, anexá-los a mensagens e abrir ou atualizar PRs. Tudo o que o GitHub pode fazer está em [Issues e PRs do GitHub](/pt-br/github/). -- **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API -- **Cursor** — os limites de modelos do seu plano Cursor +## Linear -## Instalar uma integração +### Conecte um workspace -1. Abra **Settings → Integrations**. -2. Encontre a integração e escolha **Install**. -3. Reinicie o OpenCode quando solicitado — o provedor aparece após a reinicialização. -4. Escolha **Set up** e faça login. Os modelos aparecem então no seletor de modelos do chat. +1. Expanda o cartão **Linear** e escolha **Connect**. +2. Aprove o acesso na aba do navegador que se abre. Até lá, o cartão mostra **Waiting for Linear**. -As integrações são instaladas para o seu usuário, então funcionam em todos os projetos. Você pode atualizá-las ou removê-las do mesmo cartão a qualquer momento. +Conecte outra organização do Linear com **Add workspace**. Só um workspace é o atual por vez: troque com **Switch to**, e **Disconnect** remove apenas o atual. O mapeamento de projetos é mantido. -## Claude Code +### Navegue pelas issues -O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app Claude separado. +Com um workspace conectado, o painel **Linear** aparece na barra de contexto à direita. Ele lista as issues do workspace atual e permite: -1. Instale a integração (acima). -2. Escolha **Set up** e faça login. Se você ainda não tem a CLI do Claude Code, a configuração oferece instalá-la primeiro e depois fazer login. +- filtrar por status (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), responsável (**Anyone** ou **Assigned to me**), time e prioridade +- buscar por título, identificador ou URL do Linear +- abrir uma issue para ler descrição, comentários, labels e prioridade +- mudar o **Status** ou escolher **Close issue** +- trocar o **Workspace** atual -O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Cursor não exige sua CLI. +### Inicie uma sessão a partir de uma issue -**Como sua conta Claude fica protegida:** esta integração usa o Claude Agent SDK oficial da Anthropic e a CLI do Claude Code instalada em sua máquina. Ela não sequestra OAuth, não extrai nem reproduz tokens do navegador, não se passa por um cliente não suportado e não contorna a autenticação da Anthropic. Ela permanece no caminho de acesso suportado pela Anthropic, portanto não traz o risco de banimento de conta associado a sequestro de tokens ou a contornos de autenticação não autorizados. +- No painel do Linear, abra uma issue e escolha **Start session**. O botão fica no rodapé enquanto a issue rola. +- No chat, escolha **New Session From Linear Issue** para escolher uma da lista, com **Create in worktree** se quiser um branch novo. +- Na caixa de diálogo **New Worktree**, escolha **Start from Linear Issue**. -## Cursor +A sessão abre no projeto mapeado para o time da issue, com a issue como primeiro prompt. Mapeie os times no cartão do Linear: **Default project** cobre todos os times e **Team projects** o sobrescreve por time. Um time sem mapeamento pede que você o mapeie primeiro. A primeira mensagem é montada a partir do magic prompt **Issue Review**, editável em **Configurações → Magic Prompts → Linear**. -O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor. +### Vincule uma issue a uma mensagem -1. Instale a integração (acima). -2. Escolha **Set up**, abra o link e autorize o acesso no navegador. Nenhuma chave de API é necessária. A lista de modelos carrega automaticamente após o login. +No compositor do chat, escolha **Link Linear Issue**. A issue acompanha sua próxima mensagem como contexto, e o painel de status de trabalho da sessão mantém um link para ela. -## Atualizar ou remover +### Comentários de sessão -- **Update** instala a versão publicada mais recente do plugin. -- **Remove** exclui o plugin da sua configuração do OpenCode. O provedor deixa de ser carregado quando o OpenCode é recarregado. - -Se um cartão indicar que as entradas precisam de gerenciamento manual, escolha **Manage plugins** e limpe as duplicatas lá. +Ative **Session comments** no cartão do Linear e uma sessão iniciada a partir de uma issue publica um comentário nela ao começar, terminar ou falhar, cada um com um link que abre a sessão. Os comentários só são publicados quando este servidor do OpenChamber tem um endereço público, para que o link funcione para todos na issue; em um endereço local ou privado nada é publicado. ## Relacionado -- [Provedores, modelos e agentes](/pt-br/providers/) — conecte outros provedores e escolha modelos -- [Uso e cotas](/pt-br/usage/) — acompanhe quanto você usou +- [Issues e PRs do GitHub](/pt-br/github/) — tudo o que o GitHub pode fazer no OpenChamber +- [Sessões em worktree](/pt-br/worktrees/) — onde as sessões a partir de issues podem começar +- [Magic Prompts](/pt-br/magic-prompts/) — edite o prompt com que uma sessão do Linear começa diff --git a/packages/docs/content/docs/pt-br/providers.mdx b/packages/docs/content/docs/pt-br/providers.mdx index 70ece24f..2a73dc40 100644 --- a/packages/docs/content/docs/pt-br/providers.mdx +++ b/packages/docs/content/docs/pt-br/providers.mdx @@ -45,6 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e ## Relacionado -- [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor +- [Integrações](/pt-br/integrations/) — conecte o GitHub e o Linear - [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes - [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou diff --git a/packages/docs/content/docs/tr/github.mdx b/packages/docs/content/docs/tr/github.mdx index 41bda2bf..162341b3 100644 --- a/packages/docs/content/docs/tr/github.mdx +++ b/packages/docs/content/docs/tr/github.mdx @@ -9,11 +9,11 @@ GitHub hesabınızı bağlayın. OpenChamber issue'ları ve pull request'leri i ## GitHub'ı bağlayın -1. **Settings → Git** bölümünü açın. -2. GitHub altında **Connect** seçin. OpenChamber bir bağlantı ve kısa bir kod gösterir. +1. **Settings → Integrations** bölümünü açın ve **GitHub** kartını genişletin. +2. **Connect GitHub** seçin. OpenChamber bir bağlantı ve kısa bir kod gösterir. 3. Bağlantıyı açın, kodu girin ve onaylayın. -Bağlantı kurulunca hesabınız GitHub bölümünde görünür. Birden fazla hesap bağlayabilir, aralarında geçiş yapabilir ya da istediğiniz zaman bağlantıyı kesebilirsiniz. +Bağlantı kurulunca hesabınız kartta görünür. Birden fazla hesap bağlayabilir, aralarında geçiş yapabilir ya da istediğiniz zaman bağlantıyı kesebilirsiniz. Aynı makinede `gh` CLI ile giriş yapılmışsa OpenChamber onu yedek olarak kullanabilir. ## Bir issue veya PR'den işe başlayın @@ -28,6 +28,10 @@ Bu sizi, bağlam zaten yüklenmiş halde doğrudan bir oturuma sokar. [git view](/git/) içindeki **PR** sekmesinden pull request oluşturabilir, güncelleyebilir, taslağı hazır diye işaretleyebilir ya da merge edebilirsiniz. OpenChamber PR başlığını ve açıklamasını değişikliklerinizden oluşturabilir. +## Bir issue ya da PR'ı mesaja bağlayın + +Sohbet yazma alanında **Link GitHub Issue** ya da **Link GitHub PR** seçin. Issue ya da diff'iyle birlikte PR, bir sonraki mesajınıza bağlam olarak eklenir ve oturumun çalışma durumu paneli ona bir bağlantı tutar. Aynı seçiciden, issue'yu ilk istem olarak kullanan yeni bir oturum da başlatabilirsiniz. + ## İlgili - [Git & GitHub Workflows](/git/) — branch'leri commit edin ve yönetin diff --git a/packages/docs/content/docs/tr/integrations.mdx b/packages/docs/content/docs/tr/integrations.mdx index 38c05efa..2e8ff22d 100644 --- a/packages/docs/content/docs/tr/integrations.mdx +++ b/packages/docs/content/docs/tr/integrations.mdx @@ -1,54 +1,53 @@ --- title: Entegrasyonlar -description: Claude ya da Cursor aboneliğinizi bir provider olarak kullanın. +description: GitHub ve Linear’ı OpenChamber’a bağlayın. --- # Entegrasyonlar -Integration, sahip olduğunuz bir aboneliği kullanarak OpenChamber'a bir provider ekleyen küçük bir eklentidir. Bunları **Settings → Integrations** altında yönetirsiniz. +**Settings → Integrations**, OpenChamber’ın birlikte çalıştığı servislere giriş yaptığınız yerdir. Giriş OpenChamber sunucusunda saklanır; böylece web, masaüstü ve eşleştirilmiş telefon aynı girişi paylaşır. -> **Experimental feature.** Provider politikalarına uymaya çalışıyoruz, ama hesap kısıtlamaları ve askıya alma kararları yine her provider'ın kendi kararıdır. Integrations'ı kendi sorumluluğunuzda kullanın. +## GitHub -Kullanılabilir integrations: +**GitHub** kartını genişletin ve kısa bir cihaz koduyla giriş yapmak için **Connect GitHub** seçin. Bağlandıktan sonra OpenChamber issue’lardan ve pull request’lerden oturum başlatabilir, onları mesajlara ekleyebilir ve PR açıp güncelleyebilir. GitHub’ın yapabildiği her şey [GitHub Issues & PRs](/tr/github/) sayfasında. -- **Claude Code** — Claude Pro ya da Max planınız, API anahtarı gerekmez -- **Cursor** — Cursor planınızın model limitleri +## Linear -## Bir entegrasyon kurun +### Çalışma alanı bağlayın -1. **Settings → Integrations** bölümünü açın. -2. Integration'ı bulun ve **Install** seçin. -3. İstenince OpenCode'u yeniden başlatın. Provider yeniden başlatmadan sonra görünür. -4. **Set up** seçin ve giriş yapın. Modeller sonra sohbet model seçicisinde görünür. +1. **Linear** kartını genişletin ve **Connect** seçin. +2. Açılan tarayıcı sekmesinde erişimi onaylayın. O zamana kadar kart **Waiting for Linear** gösterir. -Integration'lar kullanıcı düzeyinde kurulur, bu yüzden her projede çalışır. Aynı karttan istediğiniz zaman güncelleyebilir ya da kaldırabilirsiniz. +Başka bir Linear organizasyonunu **Add workspace** ile bağlayın. Aynı anda tek bir çalışma alanı geçerlidir: **Switch to** ile geçiş yapın; **Disconnect** yalnızca geçerli olanı kaldırır. Proje eşlemeniz korunur. -## Claude Code +### Issue’lara göz atın -Claude Code, Claude Pro ya da Max planınızı kullanır. API anahtarı yoktur ve ayrı bir Claude uygulaması gerekmez. +Bir çalışma alanı bağlandığında sağdaki bağlam çubuğunda **Linear** paneli belirir. Geçerli çalışma alanının issue’larını listeler ve şunları yapmanızı sağlar: -1. Integration'ı kurun. Yukarıya bakın. -2. **Set up** seçin ve giriş yapın. Henüz Claude Code CLI'nız yoksa kurulum önce onu yüklemeyi önerir, sonra giriş yaptırır. +- duruma (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), atanan kişiye (**Anyone** ya da **Assigned to me**), takıma ve önceliğe göre filtreleme +- başlık, tanımlayıcı ya da Linear URL’siyle arama +- bir issue’yu açıp açıklamasını, yorumlarını, etiketlerini ve önceliğini okuma +- **Status** değiştirme ya da **Close issue** seçme +- geçerli **Workspace**’i değiştirme -Claude Code, burada provider CLI'sının kurulu ve giriş yapılmış olmasını gerektiren tek integration'dır. Cursor CLI gerektirmez. +### Bir issue’dan oturum başlatın -**Claude hesabınız nasıl güvende kalır:** Bu integration, Anthropic'in resmi Claude Agent SDK'sını ve kurulu Claude Code CLI'nızı kullanır. OAuth'u kaçırmaz, tarayıcı token'larını çıkarmaz ya da yeniden oynatmaz, desteklenmeyen bir istemciyi taklit etmez ve Anthropic'in kimlik doğrulama akışını atlamaz. Anthropic'in desteklediği erişim yolunda kalır. Bu yüzden token kaçırma ya da yetkisiz kimlik doğrulama kestirmelerinin hesap banı riskini taşımaz. +- Linear panelinde bir issue açın ve **Start session** seçin. Issue kaydırılırken düğme alt kısımda kalır. +- Sohbette listeden seçmek için **New Session From Linear Issue** seçin; yeni bir dal istiyorsanız **Create in worktree** kullanın. +- **New Worktree** iletişim kutusunda **Start from Linear Issue** seçin. -## Cursor +Oturum, issue’nun takımına eşlenen projede açılır ve issue ilk istem olur. Takımları Linear kartında eşleyin: **Default project** tüm takımları kapsar, **Team projects** takım bazında bunu geçersiz kılar. Eşlenmemiş bir takım için önce eşleme yapmanız istenir. İlk mesaj **Issue Review** magic prompt’undan oluşturulur; bunu **Settings → Magic Prompts → Linear** altında düzenleyebilirsiniz. -Cursor, Cursor planınıza dahil modelleri OpenChamber'da kullanılabilir hale getirir. +### Bir issue’yu mesaja bağlayın -1. Integration'ı kurun. Yukarıya bakın. -2. **Set up** seçin, bağlantıyı açın ve tarayıcınızda erişimi onaylayın. API anahtarı gerekmez. Giriş yaptıktan sonra model listesi otomatik yüklenir. +Sohbet yazma alanında **Link Linear Issue** seçin. Issue, bir sonraki mesajınıza bağlam olarak eklenir ve oturumun çalışma durumu paneli ona bir bağlantı tutar. -## Güncelleme ya da kaldırma +### Oturum yorumları -- **Update** eklentinin en son yayımlanmış sürümünü kurar. -- **Remove** eklentiyi OpenCode config'inizden siler. OpenCode yenilendikten sonra provider yüklenmeyi bırakır. - -Bir kartta girdilerin elle yönetilmesi gerektiği yazıyorsa, **Manage plugins** seçin ve kopyaları orada temizleyin. +Linear kartında **Session comments** açın; bir issue’dan başlatılan oturum başladığında, bittiğinde ya da başarısız olduğunda o issue’ya oturumu açan bir bağlantıyla yorum bırakır. Yorumlar yalnızca bu OpenChamber sunucusunun herkese açık bir adresi olduğunda gönderilir; böylece bağlantı issue’daki herkes için çalışır. Yerel ya da özel bir adreste hiçbir şey gönderilmez. ## İlgili -- [Providers, Models & Agents](/providers/) — diğer provider'ları bağlayın ve model seçin -- [Usage & Quotas](/usage/) — ne kadar kullandığınızı izleyin +- [GitHub Issues & PRs](/tr/github/) — GitHub’ın OpenChamber’da yapabildiği her şey +- [Worktree oturumları](/tr/worktrees/) — issue oturumlarının başlayabileceği yer +- [Magic Prompts](/tr/magic-prompts/) — bir Linear oturumunun başladığı istemi düzenleyin diff --git a/packages/docs/content/docs/tr/providers.mdx b/packages/docs/content/docs/tr/providers.mdx index fa773423..b4c39b81 100644 --- a/packages/docs/content/docs/tr/providers.mdx +++ b/packages/docs/content/docs/tr/providers.mdx @@ -57,6 +57,6 @@ Sağlayıcı oturum açmaları OpenChamber'da değil OpenCode'da saklanır. Bu y ## İlgili -- [Integrations](/integrations/) — Claude veya Cursor aboneliğini sağlayıcı olarak kullanın +- [Integrations](/integrations/) — GitHub ve Linear’ı bağlayın - [MCP Servers](/mcp/) — aracılar için ek araçlar ekleyin - [Usage & Quotas](/usage/) — ne kadar kullandığınızı izleyin diff --git a/packages/docs/content/docs/uk/github.mdx b/packages/docs/content/docs/uk/github.mdx index b18db2b9..ea1cc351 100644 --- a/packages/docs/content/docs/uk/github.mdx +++ b/packages/docs/content/docs/uk/github.mdx @@ -9,11 +9,11 @@ description: Підключіть GitHub і починайте сесії з iss ## Підключення GitHub -1. Відкрийте **Settings → Git**. -2. Під GitHub виберіть **Connect**. OpenChamber покаже посилання й короткий код. +1. Відкрийте **Settings → Integrations** і розгорніть картку **GitHub**. +2. Виберіть **Connect GitHub**. OpenChamber покаже посилання й короткий код. 3. Відкрийте посилання, уведіть код і підтвердьте. -Коли підключення виконано, ваш акаунт показується в розділі GitHub. Ви можете підключити більше одного акаунта й перемикатися між ними або відключитися будь-коли. +Коли підключення виконано, ваш акаунт показується на картці. Ви можете підключити більше одного акаунта й перемикатися між ними або відключитися будь-коли. Якщо на цій самій машині виконано вхід у `gh` CLI, OpenChamber може використати його як запасний варіант. ## Початок роботи з issue чи PR @@ -28,6 +28,10 @@ description: Підключіть GitHub і починайте сесії з iss З вкладки **PR** у [вигляді git](/uk/git/) ви можете створити pull request, оновити його, позначити чернетку готовою чи злити її. OpenChamber може згенерувати заголовок та опис PR з ваших змін. +## Прикріплення issue чи PR до повідомлення + +У полі вводу чату виберіть **Link GitHub Issue** або **Link GitHub PR**. Issue, або PR разом із його diff, піде як контекст із вашим наступним повідомленням, а панель статусу роботи сесії збереже посилання на нього. Через той самий вибір можна почати нову сесію з issue як першим промптом. + ## Пов'язане - [Робочі процеси Git і GitHub](/uk/git/) — комітьте та керуйте гілками diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index d6d075f4..a06475ae 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -1,54 +1,53 @@ --- title: Інтеграції -description: Використовуйте підписки Claude або Cursor як провайдерів. +description: Підключіть GitHub і Linear до OpenChamber. --- # Інтеграції -Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**. +У **Settings → Integrations** ви входите в сервіси, з якими працює OpenChamber. Вхід зберігається на сервері OpenChamber, тож веб, десктоп і спарений телефон користуються ним спільно. -> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик. +## GitHub -Доступні інтеграції: +Розгорніть картку **GitHub** і виберіть **Connect GitHub**, щоб увійти за коротким кодом пристрою. Після підключення OpenChamber може починати сесії з issue та pull request-ів, прикріплювати їх до повідомлень і відкривати чи оновлювати PR. Усе, що вміє GitHub, описано на сторінці [GitHub Issues та PR](/uk/github/). -- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів -- **Cursor** — ліміти моделей вашої підписки Cursor +## Linear -## Встановлення інтеграції +### Підключення робочого простору -1. Відкрийте **Settings → Integrations**. -2. Знайдіть інтеграцію та натисніть **Install**. -3. Перезапустіть OpenCode, коли про це попросять, — провайдер з'явиться після перезапуску. -4. Натисніть **Set up** і увійдіть. Після цього моделі з'являться в перемикачі моделей у чаті. +1. Розгорніть картку **Linear** і виберіть **Connect**. +2. Підтвердьте доступ у вкладці браузера, що відкриється. Доти картка показує **Waiting for Linear**. -Інтеграції встановлюються для вашого користувача, тож працюють у всіх проєктах. Оновити або видалити їх можна в будь-який момент з тієї ж карточки. +Іншу організацію Linear підключайте через **Add workspace**. Поточним є один робочий простір: перемикайтеся через **Switch to**, а **Disconnect** відключає лише поточний. Зіставлення проєктів зберігається. -## Claude Code +### Перегляд issues -Claude Code використовує вашу підписку Claude Pro або Max — без API-ключів і без окремого застосунку Claude. +Щойно робочий простір підключено, у правій панелі контексту з'являється розділ **Linear**. Він показує issues поточного простору й дозволяє: -1. Встановіть інтеграцію (вище). -2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід. +- фільтрувати за статусом (**Backlog**, **To Do**, **In Progress**, **In Review**, **Done**, **Canceled**, **Duplicate**), виконавцем (**Anyone** або **Assigned to me**), командою та пріоритетом +- шукати за назвою, ідентифікатором або посиланням Linear +- відкрити issue, щоб прочитати опис, коментарі, мітки та пріоритет +- змінити її **Status** або натиснути **Close issue** +- перемкнути поточний **Workspace** -Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен. +### Сесія з issue -**Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації. +- У панелі Linear відкрийте issue й натисніть **Start session**. Кнопка лишається внизу, поки issue прокручується. +- У чаті виберіть **New Session From Linear Issue**, щоб вибрати зі списку, з опцією **Create in worktree**, якщо потрібна окрема гілка. +- У діалозі **New Worktree** виберіть **Start from Linear Issue**. -## Cursor +Сесія відкривається в проєкті, зіставленому з командою issue, а сама issue стає першим промптом. Команди зіставляються в картці Linear: **Default project** діє для всіх команд, **Team projects** перевизначає його для окремих. Для незіставленої команди спершу попросять вибрати проєкт. Перше повідомлення будується з magic prompt **Issue Review**, який можна редагувати в **Settings → Magic Prompts → Linear**. -Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor. +### Прикріплення issue до повідомлення -1. Встановіть інтеграцію (вище). -2. Натисніть **Set up**, відкрийте посилання та підтвердьте доступ у браузері. API-ключ не потрібен. Список моделей завантажиться автоматично після входу. +У полі вводу чату виберіть **Link Linear Issue**. Issue піде як контекст із вашим наступним повідомленням, а панель статусу роботи сесії збереже посилання на неї. -## Оновлення та видалення +### Коментарі про сесію -- **Update** встановлює найновішу опубліковану версію плагіна. -- **Remove** видаляє плагін із конфігурації OpenCode. Провайдер перестане завантажуватися після оновлення OpenCode. - -Якщо карточка повідомляє, що записи потребують ручного керування, натисніть **Manage plugins** і приберіть дублікати там. +Увімкніть **Session comments** у картці Linear, і сесія, розпочата з issue, залишатиме на ній коментар на старті, після завершення чи в разі збою, кожен із посиланням, що відкриває сесію. Коментарі публікуються лише тоді, коли цей сервер OpenChamber має публічну адресу, щоб посилання працювало для всіх у issue; на локальній чи приватній адресі нічого не публікується. ## Пов'язане -- [Провайдери, моделі та агенти](/uk/providers/) — підключення інших провайдерів і вибір моделей -- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили +- [GitHub Issues та PR](/uk/github/) — усе, що GitHub уміє в OpenChamber +- [Worktree-сесії](/uk/worktrees/) — де можуть починатися сесії з issue +- [Magic Prompts](/uk/magic-prompts/) — редагування промпту, з якого починається сесія з Linear diff --git a/packages/docs/content/docs/uk/providers.mdx b/packages/docs/content/docs/uk/providers.mdx index 9f2777e5..0fe9d222 100644 --- a/packages/docs/content/docs/uk/providers.mdx +++ b/packages/docs/content/docs/uk/providers.mdx @@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте ## Пов'язане -- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів +- [Інтеграції](/uk/integrations/) — підключіть GitHub і Linear - [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти - [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили diff --git a/packages/docs/content/docs/zh-cn/github.mdx b/packages/docs/content/docs/zh-cn/github.mdx index 19bd4476..0d2ab6ba 100644 --- a/packages/docs/content/docs/zh-cn/github.mdx +++ b/packages/docs/content/docs/zh-cn/github.mdx @@ -9,11 +9,11 @@ description: 连接 GitHub 并从 issue 和拉取请求开始会话。 ## 连接 GitHub -1. 打开 **Settings → Git**。 -2. 在 GitHub 下,选择 **Connect**。OpenChamber 会显示一个链接和一个短码。 +1. 打开 **Settings → Integrations**,展开 **GitHub** 卡片。 +2. 选择 **Connect GitHub**。OpenChamber 会显示一个链接和一个短码。 3. 打开链接,输入短码并批准。 -连接后,你的账号会显示在 GitHub 部分下。你可以连接多个账号并在它们之间切换,或随时断开连接。 +连接后,你的账号会显示在卡片上。你可以连接多个账号并在它们之间切换,或随时断开连接。如果同一台机器上已登录 `gh` CLI,OpenChamber 可以将其作为备用方式。 ## 从 issue 或 PR 开始工作 @@ -28,6 +28,10 @@ description: 连接 GitHub 并从 issue 和拉取请求开始会话。 在 [git 视图](/zh-cn/git/) 的 **PR** 标签页中,你可以创建拉取请求、更新它、将草稿标记为就绪或合并它。OpenChamber 可以从你的变更生成 PR 标题和描述。 +## 将 issue 或 PR 关联到消息 + +在聊天输入框中选择 **Link GitHub Issue** 或 **Link GitHub PR**。该 issue,或带有 diff 的 PR,会作为上下文随你的下一条消息一起发送,会话的工作状态面板会保留指向它的链接。同一个选择器也可以用该 issue 作为第一条提示来开始新会话。 + ## 相关内容 - [Git 与 GitHub 工作流](/zh-cn/git/) — 提交和管理分支 diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index a5686e3c..39c73ae6 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -1,54 +1,53 @@ --- title: 集成 -description: 将你的 Claude 或 Cursor 订阅用作提供商。 +description: 将 GitHub 和 Linear 连接到 OpenChamber。 --- # 集成 -集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。 +在 **设置 → 集成** 中登录 OpenChamber 所配合使用的服务。登录信息保存在 OpenChamber 服务器上,因此网页端、桌面端和已配对的手机共用同一份登录。 -> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。 +## GitHub -可用的集成: +展开 **GitHub** 卡片并选择 **Connect GitHub**,用一个简短的设备码登录。连接后,OpenChamber 可以从 issue 和拉取请求开始会话、将它们附加到消息,以及创建或更新 PR。GitHub 能做的一切见 [GitHub Issue 与 PR](/zh-cn/github/)。 -- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥 -- **Cursor** — 你的 Cursor 套餐的模型额度 +## Linear -## 安装集成 +### 连接工作区 -1. 打开 **Settings → Integrations**。 -2. 找到所需的集成并选择 **Install**。 -3. 在提示时重启 OpenCode — 重启后提供商就会出现。 -4. 选择 **Set up** 并登录。之后模型会出现在聊天室的模型选择器中。 +1. 展开 **Linear** 卡片并选择 **Connect**。 +2. 在打开的浏览器标签页中批准访问。在此之前,卡片显示 **Waiting for Linear**。 -集成按用户安装,因此在所有项目中都可用。你可以随时在同一张卡片上更新或移除它们。 +用 **Add workspace** 连接另一个 Linear 组织。同一时间只有一个当前工作区:用 **Switch to** 切换,**Disconnect** 只移除当前工作区。项目映射会保留。 -## Claude Code +### 浏览 issue -Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也无需单独的 Claude 应用。 +连接工作区后,右侧上下文栏会出现 **Linear** 面板。它列出当前工作区的 issue,你可以: -1. 安装集成(见上文)。 -2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。 +- 按状态(**Backlog**、**To Do**、**In Progress**、**In Review**、**Done**、**Canceled**、**Duplicate**)、负责人(**Anyone** 或 **Assigned to me**)、团队和优先级筛选 +- 按标题、标识符或 Linear URL 搜索 +- 打开 issue 查看描述、评论、标签和优先级 +- 修改其 **Status** 或选择 **Close issue** +- 切换当前 **Workspace** -Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要其 CLI。 +### 从 issue 开始会话 -**你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。 +- 在 Linear 面板中打开 issue 并选择 **Start session**。滚动 issue 时按钮始终停留在底部。 +- 在聊天中选择 **New Session From Linear Issue** 从列表中挑选;需要新分支时选择 **Create in worktree**。 +- 在 **New Worktree** 对话框中选择 **Start from Linear Issue**。 -## Cursor +会话会在映射到该 issue 所属团队的项目中打开,issue 作为第一条提示。在 Linear 卡片中映射团队:**Default project** 适用于所有团队,**Team projects** 可按团队覆盖。未映射的团队会先要求你完成映射。第一条消息由 **Issue Review** 魔法提示生成,可在 **设置 → Magic Prompts → Linear** 中编辑。 -Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。 +### 将 issue 关联到消息 -1. 安装集成(见上文)。 -2. 选择 **Set up**,打开链接并在浏览器中授权访问。无需 API 密钥。登录后模型列表会自动加载。 +在聊天输入框中选择 **Link Linear Issue**。该 issue 会作为上下文随你的下一条消息一起发送,会话的工作状态面板会保留指向它的链接。 -## 更新或移除 +### 会话评论 -- **Update** 安装插件的最新发布版本。 -- **Remove** 从你的 OpenCode 配置中删除该插件。OpenCode 重新加载后,该提供商将不再加载。 - -如果卡片提示条目需要手动管理,请选择 **Manage plugins** 并在那里清理重复项。 +在 Linear 卡片中开启 **Session comments**,从 issue 开始的会话会在开始、完成或失败时在该 issue 下发布评论,每条评论都带有打开会话的链接。只有当此 OpenChamber 服务器拥有公网地址时才会发布评论,这样 issue 中的每个人都能打开链接;在本地或私有地址下不会发布任何内容。 ## 相关内容 -- [提供商、模型与智能体](/zh-cn/providers/) — 连接其他提供商并选择模型 -- [用量与配额](/zh-cn/usage/) — 跟踪你的使用量 +- [GitHub Issue 与 PR](/zh-cn/github/) — GitHub 在 OpenChamber 中能做的一切 +- [工作树会话](/zh-cn/worktrees/) — issue 会话可以从哪里开始 +- [Magic Prompts](/zh-cn/magic-prompts/) — 编辑 Linear 会话的起始提示 diff --git a/packages/docs/content/docs/zh-cn/providers.mdx b/packages/docs/content/docs/zh-cn/providers.mdx index 5e81e7d3..0ac8c3fe 100644 --- a/packages/docs/content/docs/zh-cn/providers.mdx +++ b/packages/docs/content/docs/zh-cn/providers.mdx @@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。 ## 相关内容 -- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商 +- [集成](/zh-cn/integrations/) — 连接 GitHub 和 Linear - [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具 - [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量 diff --git a/packages/electron/README.md b/packages/electron/README.md index 41490145..6cc110f9 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -40,14 +40,14 @@ bun install bun run electron:dev ``` -`bun run electron:dev` starts the web dev server with HMR, then launches Electron against `packages/electron/main.mjs`. +`bun run electron:dev` starts the web dev server with HMR, then launches Electron against `packages/electron/main.mjs`. On Windows, the HMR launcher resolves npm's `bun.cmd` shim to the underlying `bun.exe` before spawning Bun child processes. The Electron workspace package trusts Electron's install script so `bun install` downloads the platform runtime in fresh checkouts and worktrees. Electron's postinstall (`node install.js`) is run by `bun install` with the system Node. Older Electron releases bundled `extract-zip@2.0.1`, which under Node 24 silently unpacked only the first entry of the Electron zip, leaving `dist/` without the binary and `path.txt` missing. Electron 43+ ships its own fixed extractor (`@electron-internal/extract-zip`), but to keep interrupted or wrong-architecture installs from blocking desktop work: - The root `postinstall` runs `ensure-electron.mjs --best-effort`, which detects an incomplete Electron install (missing binary, stale `dist/version`/`path.txt`, or a binary of the wrong architecture) and repairs it by re-running the postinstall under Bun (which extracts correctly), falling back to Node. -- `electron-dev.mjs` runs the same check (fail-fast, not best-effort) before launching, so `bun run electron:dev` self-heals even when an install was interrupted. +- `electron-dev.mjs` runs the same check (fail-fast, not best-effort) before launching, so `bun run electron:dev` self-heals even when an install was interrupted. On Windows, the repair resolves npm's `bun.cmd` shim to the underlying `bun.exe` before spawning it from Node. - The check can be run on demand with `bun run --cwd packages/electron ensure:electron`; set `ELECTRON_SKIP_BINARY_DOWNLOAD=1` to skip repair (e.g. CI without a network). - Unit tests in `scripts/ensure-electron.test.mjs` (run via `bun run --cwd packages/electron test:architecture`) cover healthy/missing/stale installs, wrong-architecture binaries, repair fallback, and `--best-effort`. diff --git a/packages/electron/package.json b/packages/electron/package.json index 2281926a..de9c2525 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/electron", - "version": "1.22.0", + "version": "1.22.1", "private": true, "description": "Electron desktop runtime for OpenChamber", "author": "OpenChamber", diff --git a/packages/electron/scripts/ensure-electron.mjs b/packages/electron/scripts/ensure-electron.mjs index 57a12cda..affef54f 100644 --- a/packages/electron/scripts/ensure-electron.mjs +++ b/packages/electron/scripts/ensure-electron.mjs @@ -29,6 +29,8 @@ import path from 'node:path'; import { createRequire } from 'node:module'; import { fileURLToPath, pathToFileURL } from 'node:url'; +import { resolveBunExecutable } from '../../../scripts/lib/bun-executable.mjs'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoElectronDir = path.resolve(__dirname, '..'); @@ -225,7 +227,11 @@ export function isComplete(electronDir, expected = expectedArch()) { return true; } -function resolveInstallCommands(env) { +function resolveInstallCommands(env, bunResolver = resolveBunExecutable) { + const normalize = (commands) => commands.map(([bin, args]) => [ + bin === 'bun' ? bunResolver({ env }) : bin, + args, + ]); if (env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS) { try { const parsed = JSON.parse(env.OPENCHAMBER_ELECTRON_INSTALL_COMMANDS); @@ -233,22 +239,22 @@ function resolveInstallCommands(env) { Array.isArray(parsed) && parsed.every((command) => Array.isArray(command) && typeof command[0] === 'string') ) { - return parsed; + return normalize(parsed); } } catch { // Fall through to the default commands. } } - return [ + return normalize([ ['bun', ['install.js']], ['node', ['install.js']], - ]; + ]); } export function repair(electronDir, options = {}) { const env = options.env ?? process.env; const runner = options.runner ?? spawnSync; - const commands = options.commands ?? resolveInstallCommands(env); + const commands = options.commands ?? resolveInstallCommands(env, options.bunResolver); // A partial extraction can leave stale entries (and a stale path.txt) that // a re-run would merge with. Start clean so the repair is deterministic. @@ -265,6 +271,7 @@ export function repair(electronDir, options = {}) { cwd: electronDir, stdio: options.stdio ?? 'inherit', env: { ...env, ELECTRON_SKIP_BINARY_DOWNLOAD: undefined }, + windowsHide: true, }); if (result.error) { console.warn(`[electron:ensure] could not run \`${label}\`: ${result.error.message}`); diff --git a/packages/electron/scripts/ensure-electron.test.mjs b/packages/electron/scripts/ensure-electron.test.mjs index 0bc3aa08..01f1f505 100644 --- a/packages/electron/scripts/ensure-electron.test.mjs +++ b/packages/electron/scripts/ensure-electron.test.mjs @@ -259,6 +259,32 @@ test('repair skips a command whose runner errors and keeps trying the rest', (t) assert.deepEqual(calls, commands); }); +test('repair resolves Bun before invoking the install command', (t) => { + const dir = withFixture(t); + const calls = []; + const resolvedBun = 'C:\\npm\\node_modules\\bun\\bin\\bun.exe'; + const result = repair(dir, { + env: { TEST_ENV: 'present' }, + bunResolver: ({ env }) => { + assert.equal(env.TEST_ENV, 'present'); + return resolvedBun; + }, + runner: (bin, args, options) => { + calls.push([bin, args, options.windowsHide]); + if (bin !== resolvedBun) return { status: 1 }; + const platform = platformPath(); + fs.mkdirSync(path.join(dir, 'dist', path.dirname(platform)), { recursive: true }); + fs.writeFileSync(path.join(dir, 'dist', 'version'), '41.2.1'); + fs.writeFileSync(path.join(dir, 'path.txt'), platform); + fs.writeFileSync(path.join(dir, 'dist', platform), headerBytesForArch(expectedArch())); + return { status: 0 }; + }, + }); + + assert.equal(result, true); + assert.deepEqual(calls, [[resolvedBun, ['install.js'], true]]); +}); + test('repair returns false when the runner reports a failure status for every command', (t) => { const dir = withFixture(t); const calls = []; diff --git a/packages/ui/package.json b/packages/ui/package.json index 31181a9e..794bb87d 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/ui", - "version": "1.22.0", + "version": "1.22.1", "private": true, "type": "module", "main": "src/main.tsx", @@ -45,7 +45,7 @@ "@dnd-kit/utilities": "^3.2.2", "@legendapp/list": "3.3.8", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "@pierre/diffs": "1.3.0-beta.6", "@replit/codemirror-vim": "^6.4.0", "@simplewebauthn/browser": "13.3.0", diff --git a/packages/ui/src/apps/AppEffects.tsx b/packages/ui/src/apps/AppEffects.tsx index 6114c8d8..43d739ac 100644 --- a/packages/ui/src/apps/AppEffects.tsx +++ b/packages/ui/src/apps/AppEffects.tsx @@ -1,6 +1,7 @@ import React from 'react'; import { useKeyboardShortcuts } from '@/hooks/useKeyboardShortcuts'; import { usePwaManifestSync } from '@/hooks/usePwaManifestSync'; +import { useMessageQueueHoldSync } from '@/hooks/useMessageQueueHoldSync'; import { useQueuedMessageAutoSend } from '@/hooks/useQueuedMessageAutoSend'; import { useSessionAutoCleanup } from '@/hooks/useSessionAutoCleanup'; import { useWindowControlsOverlayLayout } from '@/hooks/useWindowControlsOverlayLayout'; @@ -8,6 +9,7 @@ import { setOptimisticRefs } from '@/sync/session-actions'; import { markSessionViewed } from '@/sync/notification-store'; import { setExternallyViewedSession } from '@/sync/sync-context'; import { useSync } from '@/sync/use-sync'; +import { isServerOwnedMessageQueue } from '@/stores/messageQueueStore'; const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence'; @@ -66,7 +68,10 @@ export function SyncRuntimeEffects({ embeddedBackgroundWorkEnabled }: { embeddedBackgroundWorkEnabled: boolean; }) { useSessionAutoCleanup(embeddedBackgroundWorkEnabled); - useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled); + // Web, desktop, and mobile hand the queue to the OpenChamber server, which + // delivers it with or without a UI; only VS Code still sends from the UI. + useQueuedMessageAutoSend(embeddedBackgroundWorkEnabled && !isServerOwnedMessageQueue()); + useMessageQueueHoldSync(); return ; } diff --git a/packages/ui/src/apps/MobileSessionsSheet.tsx b/packages/ui/src/apps/MobileSessionsSheet.tsx index c10b4540..a2fe76e3 100644 --- a/packages/ui/src/apps/MobileSessionsSheet.tsx +++ b/packages/ui/src/apps/MobileSessionsSheet.tsx @@ -362,6 +362,15 @@ const SessionRenameForm: React.FC<{ const { t } = useI18n(); const [value, setValue] = React.useState(initialTitle); + // Opens with the whole title selected, so the first keystroke replaces it. + // Stable ref callback: an inline one would re-run on every render and + // re-select the text mid-edit. + const focusRenameInput = React.useCallback((node: HTMLInputElement | null) => { + if (!node) return; + node.focus(); + node.select(); + }, []); + const commit = () => { const next = value.trim(); if (!next || next === initialTitle.trim()) { @@ -385,7 +394,7 @@ const SessionRenameForm: React.FC<{ }} > setValue(event.target.value)} onKeyDown={(event) => { diff --git a/packages/ui/src/apps/runtimeEndpointReset.ts b/packages/ui/src/apps/runtimeEndpointReset.ts index afd90a7d..4369d101 100644 --- a/packages/ui/src/apps/runtimeEndpointReset.ts +++ b/packages/ui/src/apps/runtimeEndpointReset.ts @@ -7,6 +7,7 @@ import { useProjectContextStore } from '@/stores/useProjectContextStore'; import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { usePermissionStore } from '@/stores/permissionStore'; +import { useMessageQueueStore } from '@/stores/messageQueueStore'; import { useFileSearchStore } from '@/stores/useFileSearchStore'; import { useGitStore } from '@/stores/useGitStore'; import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore'; @@ -70,6 +71,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD // restarts the resume window so the switch is treated as a fresh load. resetSessionActivityTiming(); usePermissionStore.getState().reset(); + useMessageQueueStore.getState().resetForRuntimeSwitch(detail.previousRuntimeKey); useFileSearchStore.getState().resetForRuntimeSwitch(); useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey); useGitHubPrStatusStore.getState().resetForRuntimeSwitch(); diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index a62582c3..45525cd0 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -3,11 +3,11 @@ import { ComposerDictation } from '@/components/dictation/ComposerDictation'; // sessionStore removed — currentSessionId comes from useSessionUIStore import { useConfigStore } from '@/stores/useConfigStore'; import { useUIStore } from '@/stores/useUIStore'; -import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedMessage } from '@/stores/messageQueueStore'; +import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore, type QueuedContextPart, type QueuedMessage } from '@/stores/messageQueueStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSelectionStore } from '@/sync/selection-store'; -import { prepareLocalAttachments, useInputStore } from '@/sync/input-store'; +import { prepareLocalAttachments, useInputStore, type SyntheticContextPart } from '@/sync/input-store'; import { ACCEPTED_ATTACHMENT_EXTENSIONS, ATTACHMENT_ACCEPT, @@ -49,6 +49,7 @@ import type { SnippetAutocompleteHandle } from './SnippetAutocomplete'; import { cn } from "@/lib/utils"; import { ModelControls } from './ModelControls'; import { parseAgentMentions } from '@/lib/messages/agentMentions'; +import { CONTEXT_METADATA_KEY, draftFromContextPayload } from '@/lib/messages/contextParts'; import { ComposerStatusBar } from './ComposerStatusBar'; import { PendingChangesBar } from './PendingChangesBar'; import { useChatColumnSession } from './chatColumnSession'; @@ -138,7 +139,7 @@ import { toProjectRelativeMentionPath, toServerFileUrl, } from './composer/attachments/filePaths'; -import { buildOutgoingMessage } from './composer/submit/buildOutgoingMessage'; +import { buildComposerContext, buildOutgoingMessage } from './composer/submit/buildOutgoingMessage'; import { buildCommandVariables, canRunCommand, @@ -186,7 +187,6 @@ const MAX_MOBILE_COMPOSER_LINES = 16; */ const MOBILE_COMPOSER_BOUND_GAP_PX = 4; const EMPTY_QUEUE: QueuedMessage[] = []; -const EMPTY_SENDING_IDS: string[] = []; const COMPACT_CHAT_PLACEHOLDER_MAX_WIDTH = 560; const renameFileForAttachmentCitation = (file: File, filename: string): File => { if (file.name === filename) { @@ -216,6 +216,70 @@ const buildSkillMentionInstruction = (skillNames: string[]): string | null => { return `The user explicitly mentioned these skills in their message: ${formatted}. Use the corresponding skill tool when it is relevant to accomplishing the user's request.`; }; +type LinkedReferenceAuthor = { login: string; avatarUrl?: string }; +type LinkedGitHubIssue = { number: number; title: string; url: string; contextText: string; author?: LinkedReferenceAuthor }; +type LinkedGitHubPr = { + number: number; + title: string; + url: string; + head: string; + base: string; + includeDiff: boolean; + instructionsText: string; + contextText: string; + author?: LinkedReferenceAuthor; +}; +type LinkedLinearIssueRef = { identifier: string; title: string; url: string; contextText: string; author?: LinkedReferenceAuthor }; +type LinkedReferences = { issue: LinkedGitHubIssue | null; pr: LinkedGitHubPr | null; linear: LinkedLinearIssueRef | null }; + +/** + * Record what a session was pointed at, so the work-status panel can show it + * as a context source long after the message scrolled away. A snapshot only — + * never re-fetched, never authoritative. Failures are swallowed: the message + * went out (or was queued), and a missing bookkeeping entry must not surface + * as an error. + */ +const recordLinkedReferences = ( + sessionId: string, + directory: Parameters[1], + refs: LinkedReferences, +) => { + const attachedThread = refs.issue + ? { attachment: refs.issue, kind: 'issue' as const } + : refs.pr + ? { attachment: refs.pr, kind: 'pull' as const } + : null; + if (attachedThread) { + void sessionActions.setLinkedIssue( + sessionId, + directory, + buildLinkedIssue({ + url: attachedThread.attachment.url, + number: attachedThread.attachment.number, + title: attachedThread.attachment.title, + kind: attachedThread.kind, + author: attachedThread.attachment.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + } + if (refs.linear) { + void sessionActions.setLinkedIssue( + sessionId, + directory, + buildLinkedLinearIssue({ + identifier: refs.linear.identifier, + title: refs.linear.title, + url: refs.linear.url, + author: refs.linear.author, + linkedAt: Date.now(), + }), + true, + ).catch(() => undefined); + } +}; + const hasUserMessages = (sessionId: string, directory?: string) => { return getSyncMessages(sessionId, directory).some((message) => message.role === 'user'); }; @@ -726,37 +790,61 @@ const ChatInputComponent: React.FC = ({ attachments, }; }, [resolveInlineFileMention]); + + type DocumentMentionPreparation = + | { status: 'ready'; prepared: Map } + | { status: 'failed'; filename: string } + | { status: 'runtime-changed' }; + + /** + * Document mentions (`@notes.pdf`) are sent as converted attachments. Their + * sources are fetched up front — by the send, or by queueing, since the + * server that later delivers a queued message cannot read them. + */ + const prepareDocumentMentions = React.useCallback(async ( + texts: readonly string[], + reservedFilenames: Set, + runtimeKey: string, + ): Promise => { + const prepared = new Map(); + for (const rawText of texts) { + for (const token of scanMentions(rawText)) { + const mention = resolveInlineFileMention(token.name); + if ( + !mention + || !isDocumentAttachmentFilename(mention.filename) + || prepared.has(mention.serverPath) + ) { + continue; + } + try { + const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } }); + if (!response.ok) throw new Error(`Failed to read ${mention.filename}`); + const sourceBlob = await response.blob(); + if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' }; + const source = new File([sourceBlob], mention.filename); + const converted = await prepareLocalAttachments(source, reservedFilenames); + if (!converted || converted.length === 0) throw new Error(`Failed to prepare ${mention.filename}`); + if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' }; + prepared.set(mention.serverPath, converted); + for (const attachment of converted) reservedFilenames.add(attachment.filename); + } catch { + if (getRuntimeKey() !== runtimeKey) return { status: 'runtime-changed' }; + return { status: 'failed', filename: mention.filename }; + } + } + } + return { status: 'ready', prepared }; + }, [resolveInlineFileMention]); const prevWasAbortedRef = React.useRef(false); // Issue linking state const [issuePickerOpen, setIssuePickerOpen] = React.useState(false); const [prPickerOpen, setPrPickerOpen] = React.useState(false); const [linearPickerOpen, setLinearPickerOpen] = React.useState(false); - const [linkedIssue, setLinkedIssue] = React.useState<{ - number: number; - title: string; - url: string; - contextText: string; - author?: { login: string; avatarUrl?: string }; - } | null>(null); - const [linkedPr, setLinkedPr] = React.useState<{ - number: number; - title: string; - url: string; - head: string; - base: string; - includeDiff: boolean; - instructionsText: string; - contextText: string; - author?: { login: string; avatarUrl?: string }; - } | null>(null); - const [linkedLinearIssue, setLinkedLinearIssue] = React.useState<{ - identifier: string; - title: string; - url: string; - contextText: string; - author?: { login: string; avatarUrl?: string }; - } | null>(null); + const [linkedIssue, setLinkedIssue] = React.useState(null); + const [linkedPr, setLinkedPr] = React.useState(null); + const [linkedLinearIssue, setLinkedLinearIssue] = React.useState(null); // Message queue const messageQueueTarget = currentSessionId @@ -774,8 +862,7 @@ const ChatInputComponent: React.FC = ({ ) ); const addToQueue = useMessageQueueStore((state) => state.addToQueue); - const clearQueue = useMessageQueueStore((state) => state.clearQueue); - const removeFromQueue = useMessageQueueStore((state) => state.removeFromQueue); + const takeForSend = useMessageQueueStore((state) => state.takeForSend); // Inline comment drafts const inlineDraftSessionKey = currentSessionId ?? (newSessionDraftOpen ? 'draft' : ''); @@ -921,25 +1008,56 @@ const ChatInputComponent: React.FC = ({ const handleSubmitRef = React.useRef<(options?: SubmitOptions) => Promise>(async () => {}); // Add message to queue instead of sending - const handleQueueMessage = React.useCallback(() => { + const handleQueueMessage = React.useCallback(async () => { const inputSnapshot = getCurrentInputSnapshot(); if (!inputSnapshot.hasContent || !currentSessionId || !messageQueueTarget) return; - - // Context drafts stay in their store: the send that later delivers the - // queue consumes them and attaches them as structured context parts. + const queueRuntimeKey = getRuntimeKey(); + const queueTarget = messageQueueTarget; + const queueSessionId = currentSessionId; const messageToQueue = inputSnapshot.message.replace(/^\n+|\n+$/g, ''); - const attachmentsToQueue = sanitizeAttachmentsForSend(attachedFiles); + const composerAttachments = sanitizeAttachmentsForSend(attachedFiles); - addToQueue(messageQueueTarget, { - content: messageToQueue, - attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, - sendConfig: currentProviderId && currentModelId ? { - providerID: currentProviderId, - modelID: currentModelId, - agent: currentAgentName ?? undefined, - variant: currentVariant ?? undefined, - } : undefined, - }); + // A queued message is resolved now, not at delivery: the server that + // sends it has no agent list, no confirmed mentions, and no way to read + // a document the user named — and the mention must match what was + // visible when the user typed it. + const documentMentions = await prepareDocumentMentions( + [messageToQueue], + new Set(composerAttachments.map((attachment) => attachment.filename)), + queueRuntimeKey, + ); + if (documentMentions.status === 'runtime-changed') return; + if (documentMentions.status === 'failed') { + toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: documentMentions.filename })); + return; + } + const { sanitizedText, mention } = parseAgentMentions(messageToQueue, agents); + const { attachments: mentionAttachments } = extractInlineFileMentions(sanitizedText, documentMentions.prepared); + const availableSkillNames = new Set( + selectSkillsForDirectory(useSkillsStore.getState(), currentDirectory).map((skill) => skill.name), + ); + const skillInstruction = buildSkillMentionInstruction(collectInlineSkillMentions(sanitizedText, availableSkillNames)); + + // Everything attached to the composer leaves with the message: the + // chips are part of what was queued, and come back if it is edited. + const syntheticParts = consumePendingSyntheticParts() ?? []; + const draftTarget = inlineDraftTarget; + const drafts = draftTarget ? consumeDrafts(draftTarget) : []; + const linked: LinkedReferences = { issue: linkedIssue, pr: linkedPr, linear: linkedLinearIssue }; + const context = buildComposerContext({ + inlineComments: drafts, + syntheticTexts: syntheticParts.map((part) => part.text), + linkedIssue: linked.issue + ? { number: linked.issue.number, title: linked.issue.title, url: linked.issue.url, contextText: linked.issue.contextText } + : null, + linkedPr: linked.pr + ? { number: linked.pr.number, title: linked.pr.title, url: linked.pr.url, instructions: linked.pr.instructionsText, context: linked.pr.contextText } + : null, + linkedLinearIssue: linked.linear + ? { identifier: linked.linear.identifier, title: linked.linear.title, url: linked.linear.url, contextText: linked.linear.contextText } + : null, + }, skillInstruction); + const attachmentsToQueue = [...composerAttachments, ...mentionAttachments]; // Sending while the agent works must still take the reader to the // live edge — a queued message produces no user row yet, so the @@ -947,26 +1065,116 @@ const ChatInputComponent: React.FC = ({ // parked mid-history. scrollToLatest?.(); - // Clear input and attachments - // Note: confirmedMentionsRef is NOT cleared here because queued messages - // are processed later in handleSubmit which reads the ref via extractInlineFileMentions. - // The ref is cleared in handleSubmit after all queued messages are sent. + // Clear the composer. The mentions it had confirmed were resolved + // above, so nothing later needs them. setMessage(''); - if (attachmentsToQueue.length > 0) { + confirmedMentionsRef.current.clear(); + if (composerAttachments.length > 0) { clearAttachedFiles(); } - + setLinkedIssue(null); + setLinkedPr(null); + setLinkedLinearIssue(null); if (!isMobile) { composerRef.current?.focus(); } - }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, addToQueue, clearAttachedFiles, isMobile, currentProviderId, currentModelId, currentAgentName, currentVariant, scrollToLatest]); - const handleQueuedMessageEdit = React.useCallback((content: string) => { - setMessage(content); + try { + await addToQueue(queueTarget, { + content: messageToQueue, + text: sanitizedText, + agentMention: mention?.name, + attachments: attachmentsToQueue.length > 0 ? attachmentsToQueue : undefined, + context: context.length > 0 ? context : undefined, + sendConfig: currentProviderId && currentModelId ? { + providerID: currentProviderId, + modelID: currentModelId, + agent: currentAgentName ?? undefined, + variant: currentVariant ?? undefined, + } : undefined, + }); + } catch (error) { + console.warn('[queue] failed to queue message:', error); + toast.error(t('chat.queuedMessage.toast.queueFailed')); + // The composer was cleared on queueing; give everything back. The + // text is appended if the user has already typed something new. + const currentInput = composerRef.current?.getValue() ?? messageRef.current; + if (!currentInput) { + setMessage(messageToQueue); + } else { + useInputStore.getState().setPendingInputText(messageToQueue, 'append'); + } + if (composerAttachments.length > 0) { + useInputStore.getState().setAttachedFiles([...useInputStore.getState().attachedFiles, ...composerAttachments]); + } + if (draftTarget && drafts.length > 0) { + useInlineCommentDraftStore.getState().restoreDrafts(draftTarget, drafts); + } + if (syntheticParts.length > 0) { + useInputStore.getState().setPendingSyntheticParts(syntheticParts); + } + setLinkedIssue(linked.issue); + setLinkedPr(linked.pr); + setLinkedLinearIssue(linked.linear); + return; + } + recordLinkedReferences(queueSessionId, queueTarget.directory, linked); + }, [getCurrentInputSnapshot, currentSessionId, messageQueueTarget, attachedFiles, sanitizeAttachmentsForSend, prepareDocumentMentions, extractInlineFileMentions, agents, currentDirectory, consumePendingSyntheticParts, inlineDraftTarget, consumeDrafts, linkedIssue, linkedPr, linkedLinearIssue, scrollToLatest, clearAttachedFiles, isMobile, addToQueue, currentProviderId, currentModelId, currentAgentName, currentVariant, t]); + + /** Put the context a queued message was captured with back on the composer chips. */ + const restoreQueuedContext = React.useCallback((context: readonly QueuedContextPart[]) => { + const synthetic: SyntheticContextPart[] = []; + for (const part of context) { + if (part.kind === 'synthetic') { + synthetic.push({ text: part.text, synthetic: true }); + continue; + } + // An instruction is derived from the text, and derived again on send. + if (part.kind !== 'context') continue; + const payload = part.metadata[CONTEXT_METADATA_KEY]; + if (payload.kind === 'github-issue') { + setLinkedIssue({ number: payload.number, title: payload.title, url: payload.url, contextText: part.text }); + setLinkedPr(null); + setLinkedLinearIssue(null); + } else if (payload.kind === 'github-pr') { + // The captured context is final: whatever diff it includes is + // already in the text, and the branches were not captured. + setLinkedPr({ + number: payload.number, + title: payload.title, + url: payload.url, + head: '', + base: '', + includeDiff: false, + instructionsText: part.instructions ?? '', + contextText: part.text, + }); + setLinkedIssue(null); + setLinkedLinearIssue(null); + } else if (payload.kind === 'linear-issue') { + setLinkedLinearIssue({ identifier: payload.identifier, title: payload.title, url: payload.url, contextText: part.text }); + setLinkedIssue(null); + setLinkedPr(null); + } else { + const draft = draftFromContextPayload(payload); + if (draft && inlineDraftTarget) { + useInlineCommentDraftStore.getState().addDraft(inlineDraftTarget, draft); + } + } + } + if (synthetic.length > 0) { + const pending = useInputStore.getState().pendingSyntheticParts ?? []; + useInputStore.getState().setPendingSyntheticParts([...pending, ...synthetic]); + } + }, [inlineDraftTarget]); + + const handleQueuedMessageEdit = React.useCallback((queued: QueuedMessage) => { + setMessage(queued.content); + restoreQueuedContext(queued.context ?? []); setTimeout(() => { composerRef.current?.focus(); }, 0); - }, []); + }, [restoreQueuedContext]); const handleQueuedMessageSend = React.useCallback((messageId: string) => { // Force-sending from the queue during a busy session counts as steer @@ -1023,28 +1231,23 @@ const ChatInputComponent: React.FC = ({ hasContent: options.presetText.trim().length > 0 || attachedFiles.length > 0 || hasDrafts, } : getCurrentInputSnapshot(); - // A queued item stays in the queue until its own send resolves, so the - // auto-send hook may already be delivering one of these. Merging it here - // would send the same message twice (the window is seconds over a relay). - const sendingIds = messageQueueTarget - ? useMessageQueueStore.getState().sendingIds[getMessageQueueKey(messageQueueTarget)] ?? EMPTY_SENDING_IDS - : EMPTY_SENDING_IDS; - const queuedMessagesToSend = (queuedMessageId - ? queuedMessages.filter((message) => message.id === queuedMessageId) - : queuedMessages - ).filter((message) => !sendingIds.includes(message.id)); - if (queuedOnly && autoReviewRunning) { return; } if (queuedOnly) { - if (queuedMessagesToSend.length === 0 || !currentSessionId) return; + if (!queuedMessages.some((message) => !queuedMessageId || message.id === queuedMessageId) || !currentSessionId) return; } else if ((!inputSnapshot.hasContent && !hasQueuedMessages) || (!currentSessionId && !newSessionDraftOpen)) { return; } - const capturedSendConfig = queuedOnly ? queuedMessagesToSend[0]?.sendConfig : undefined; + // The projection knows the captured send configuration; the full + // messages are taken from the queue only once nothing below can still + // bail out, so an early return leaves the queue untouched. + const queuedProjection = queuedMessageId + ? queuedMessages.filter((message) => message.id === queuedMessageId) + : queuedMessages; + const capturedSendConfig = queuedOnly ? queuedProjection[0]?.sendConfig : undefined; const providerIdToSend = capturedSendConfig?.providerID ?? currentProviderId; const modelIdToSend = capturedSendConfig?.modelID ?? currentModelId; const agentNameToSend = capturedSendConfig?.agent ?? currentAgentName; @@ -1066,7 +1269,7 @@ const ChatInputComponent: React.FC = ({ // rejected turn winds down and the session returns to idle. This avoids // aborting the turn (which would surface an "aborted" notice). if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) { - handleQueueMessage(); + void handleQueueMessage(); return; } @@ -1088,7 +1291,7 @@ const ChatInputComponent: React.FC = ({ sessionActions.dismissOpenQuestionsForSession(currentSessionId), ]); if (deniedPermissions || dismissedQuestions) { - handleQueueMessage(); + void handleQueueMessage(); return; } } @@ -1112,48 +1315,43 @@ const ChatInputComponent: React.FC = ({ } if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery; - const preparedDocumentMentions = new Map(); + // Queued messages resolved their mentions when they were queued; only + // the composer's own text can still name a document. const reservedFilenames = new Set([ ...attachedFiles.map((attachment) => attachment.filename), - ...queuedMessagesToSend.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []), + ...queuedProjection.flatMap((queued) => queued.attachments?.map((attachment) => attachment.filename) ?? []), ]); - const mentionTexts = [ - ...queuedMessagesToSend.map((queued) => queued.content), - ...(!queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : []), - ]; - for (const rawText of mentionTexts) { - for (const token of scanMentions(rawText)) { - const mention = resolveInlineFileMention(token.name); - if ( - !mention - || !isDocumentAttachmentFilename(mention.filename) - || preparedDocumentMentions.has(mention.serverPath) - ) { - continue; - } - try { - const response = await runtimeFetch('/api/fs/raw', { query: { path: mention.serverPath } }); - if (!response.ok) throw new Error(`Failed to read ${mention.filename}`); - const sourceBlob = await response.blob(); - if (getRuntimeKey() !== submitRuntimeKey) return; - const source = new File([sourceBlob], mention.filename); - const prepared = await prepareLocalAttachments(source, reservedFilenames); - if (!prepared || prepared.length === 0) throw new Error(`Failed to prepare ${mention.filename}`); - if (getRuntimeKey() !== submitRuntimeKey) return; - preparedDocumentMentions.set(mention.serverPath, prepared); - for (const attachment of prepared) reservedFilenames.add(attachment.filename); - } catch { - if (getRuntimeKey() !== submitRuntimeKey) return; - toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: mention.filename })); - return; - } + const documentMentions = await prepareDocumentMentions( + !queuedOnly && inputSnapshot.hasContent ? [inputSnapshot.message] : [], + reservedFilenames, + submitRuntimeKey, + ); + if (documentMentions.status === 'runtime-changed') return; + if (documentMentions.status === 'failed') { + toast.error(t('chat.chatInput.toast.attachNamedFailed', { name: documentMentions.filename })); + return; + } + const preparedDocumentMentions = documentMentions.prepared; + + // The composer delivers these itself, so they leave the queue now — the + // queue's own delivery (server-side, or the auto-send hook in VS Code) + // skips anything already in flight, and a message already being + // delivered stays out of this send so it cannot go out twice. + let queuedMessagesToSend: QueuedMessage[] = []; + if (capturedTarget && hasQueuedMessages) { + try { + queuedMessagesToSend = await takeForSend(capturedTarget, queuedMessageId); + } catch (error) { + console.warn('[queue] failed to take queued messages for sending:', error); + toast.error(t('chat.queuedMessage.toast.takeFailed')); + return; } + if (queuedOnly && queuedMessagesToSend.length === 0) return; } // Inline review comments and synthetic context are consumed before - // assembly so a failed send can restore exactly what it took. Context - // drafts ride with whichever send goes out next, including queued - // auto-sends: queueing leaves them in the store on purpose. + // assembly so a failed send can restore exactly what it took. What is + // here belongs to this send: queueing took its own context with it. const syntheticParts = consumePendingSyntheticParts(); const consumedDraftTarget = inlineDraftTarget; const drafts: InlineCommentDraft[] = consumedDraftTarget @@ -1201,12 +1399,7 @@ const ChatInputComponent: React.FC = ({ if (outgoing.isEmpty) return; - // Clear queue and input - if (capturedTarget && queuedMessageId) { - removeFromQueue(capturedTarget, queuedMessageId); - } else if (capturedTarget && hasQueuedMessages) { - clearQueue(capturedTarget); - } + // Clear input (the queue was taken above) if (!queuedOnly) { setMessage(''); confirmedMentionsRef.current.clear(); @@ -1381,16 +1574,6 @@ const ChatInputComponent: React.FC = ({ }; void sendPromise.then(() => { - // Record what this session was pointed at, so the work-status panel - // can show it as a context source long after the message scrolled - // away. A snapshot only — never re-fetched, never authoritative. - // Failures are swallowed: the message went out, and a missing - // bookkeeping entry must not surface as a send error. - const attachedThread = linkedIssue - ? { attachment: linkedIssue, kind: 'issue' as const } - : linkedPr - ? { attachment: linkedPr, kind: 'pull' as const } - : null; // On a draft there is no session yet in this closure: the send path // creates one and makes it current before resolving, so the id is // read from the store. The fallback is used only when the closure @@ -1403,47 +1586,14 @@ const ChatInputComponent: React.FC = ({ : sessionState.currentSessionDirectory ?? (linkTargetSessionId ? sessionState.getDirectoryForSession(linkTargetSessionId) : null) ?? currentDirectory; - - if (attachedThread && linkTargetSessionId) { - void sessionActions.setLinkedIssue( - linkTargetSessionId, - linkTargetDirectory, - buildLinkedIssue({ - url: attachedThread.attachment.url, - number: attachedThread.attachment.number, - title: attachedThread.attachment.title, - kind: attachedThread.kind, - author: attachedThread.attachment.author, - linkedAt: Date.now(), - }), - true, - ).catch(() => undefined); - } - if (linkedLinearIssue && linkTargetSessionId) { - void sessionActions.setLinkedIssue( - linkTargetSessionId, - linkTargetDirectory, - buildLinkedLinearIssue({ - identifier: linkedLinearIssue.identifier, - title: linkedLinearIssue.title, - url: linkedLinearIssue.url, - author: linkedLinearIssue.author, - linkedAt: Date.now(), - }), - true, - ).catch(() => undefined); + if (linkTargetSessionId) { + recordLinkedReferences(linkTargetSessionId, linkTargetDirectory, { issue: linkedIssue, pr: linkedPr, linear: linkedLinearIssue }); } - // Clear linked issue after successful message send - if (linkedIssue) { - setLinkedIssue(null); - } - if (linkedPr) { - setLinkedPr(null); - } - if (linkedLinearIssue) { - setLinkedLinearIssue(null); - } + // Linked references were sent; clear them from the composer. + setLinkedIssue(null); + setLinkedPr(null); + setLinkedLinearIssue(null); }).catch((error: unknown) => { const rawMessage = error instanceof Error @@ -1531,7 +1681,7 @@ const ChatInputComponent: React.FC = ({ const inputSnapshot = getCurrentInputSnapshot(); const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning); if (followUpBehavior === 'queue' && canQueue) { - handleQueueMessage(); + void handleQueueMessage(); } else if (followUpBehavior === 'steer' && canQueue) { void handleSubmitRef.current({ delivery: 'steer' }); } else { @@ -1738,7 +1888,7 @@ const ChatInputComponent: React.FC = ({ if (isCtrlEnter || !canQueue) { handleSubmit(); } else { - handleQueueMessage(); + void handleQueueMessage(); } } else { // steer: Enter steers into the running turn, Ctrl+Enter sends now. @@ -2815,7 +2965,7 @@ const ChatInputComponent: React.FC = ({ title={linkedPr.title} url={linkedPr.url} author={linkedPr.author} - branches={{ head: linkedPr.head, base: linkedPr.base }} + branches={linkedPr.head && linkedPr.base ? { head: linkedPr.head, base: linkedPr.base } : undefined} openInBrowserLabel={t('chat.chatInput.linked.pr.openInBrowserAria')} removeLabel={t('chat.chatInput.linked.pr.removeAria')} onReopenPicker={() => setPrPickerOpen(true)} diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index d09b0004..697d0e7f 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -326,7 +326,13 @@ export const ModelControls: React.FC = ({ const currentModelId = useConfigStore((state) => state.currentModelId); const effectiveCurrentVariant = useConfigStore((state) => state.currentVariant); const currentVariantSelection = useConfigStore((state) => state.currentVariantSelection); - const currentVariant = currentVariantSelection.override ?? undefined; + // What the picker shows is what the next send carries: an explicit choice + // when there is one, "Default" when "Default" was picked, and otherwise the + // inherited effort — showing "Default" while an inherited effort is in + // force is how a switch away from it looks like it did not stick. + const currentVariant = currentVariantSelection.override === null + ? undefined + : currentVariantSelection.override ?? effectiveCurrentVariant; const currentAgentName = useConfigStore((state) => state.currentAgentName); const settingsDefaultVariant = useConfigStore((state) => state.settingsDefaultVariant); const settingsDefaultAgent = useConfigStore((state) => state.settingsDefaultAgent); @@ -719,7 +725,14 @@ export const ModelControls: React.FC = ({ return candidates.find((candidate) => candidate !== undefined && variantOptions.includes(candidate)); }, [agents, currentAgentName, currentModelId, currentProviderId, currentSessionId, currentVariantSelection, effectiveCurrentVariant, getModelVariantOptions, settingsDefaultVariant, uiAgentName]); - const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string) => { + /** + * The session's recorded choice for this model, in the selection store's + * three states: an effort name, `null` for an explicit "Default", and + * `undefined` for no choice at all. Collapsing `null` into `undefined` here + * would hand a real "Default" back to the callers as "nothing chosen", and + * they would re-record it as a choice on the next write. + */ + const resolveModelVariantSelection = React.useCallback((providerId: string, modelId: string): string | null | undefined => { const variantOptions = getModelVariantOptions(providerId, modelId); if (variantOptions.length === 0) { return undefined; @@ -728,6 +741,10 @@ export const ModelControls: React.FC = ({ const effectiveAgentName = uiAgentName || currentAgentName; if (currentSessionId && effectiveAgentName) { const savedVariant = getAgentModelVariantForSession(currentSessionId, effectiveAgentName, providerId, modelId); + // An explicit "Default" is a choice: it stops the fallbacks below. + if (savedVariant === null) { + return null; + } if (savedVariant && variantOptions.includes(savedVariant)) { return savedVariant; } @@ -760,7 +777,16 @@ export const ModelControls: React.FC = ({ return liveConfigAgentName || currentAgentName; }, [currentAgentName, currentSessionId]); - const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => { + /** + * Records `variant` as this session's effort for the model, in the same + * three states the selection store defines: an effort name, `null` for an + * explicit "Default", `undefined` for no choice so the inherited effort + * applies. Callers decide which one they mean — the picker turns its own + * "Default" into `null`, while restore paths pass `undefined` through when + * they found nothing, because "the history carries no effort" is not the + * user having chosen "Default". + */ + const commitVariantSelectionForModel = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => { const variantOptions = getModelVariantOptions(providerId, modelId); if (variantOptions.length === 0) { manualVariantSelectionRef.current = false; @@ -770,10 +796,10 @@ export const ModelControls: React.FC = ({ manualVariantSelectionRef.current = true; setCurrentVariantOverride( - variant ?? null, + variant, resolveInheritedVariantForModel(providerId, modelId, agentNameOverride), ); - addRecentEffort(providerId, modelId, variant); + addRecentEffort(providerId, modelId, variant ?? undefined); const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName(); if (currentSessionId && effectiveAgentName) { @@ -790,7 +816,7 @@ export const ModelControls: React.FC = ({ setCurrentVariantOverride, ]); - const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | undefined, agentNameOverride?: string | null) => { + const applyModelSelectionWithVariant = React.useCallback((providerId: string, modelId: string, variant: string | null | undefined, agentNameOverride?: string | null) => { const effectiveAgentName = agentNameOverride ?? resolveLiveAgentName() ?? undefined; const result = tryApplyModelSelection(providerId, modelId, effectiveAgentName); if (result !== 'applied') { @@ -852,25 +878,34 @@ export const ModelControls: React.FC = ({ && getModelVariantOptions(latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID).includes(latestLoadedUserChoice.variant) ? latestLoadedUserChoice.variant : undefined; + const restoreAgentName = latestLoadedUserChoice.agent || currentAgentName || undefined; + // A message carrying no effort is not evidence that the user has none: + // a send under an explicit "Default" carries none either, and the echo + // of that very send arrives here. Keep what the session already + // recorded, and let a concrete historical effort replace it. + const restoredVariant = historicalVariant ?? (currentSessionId && restoreAgentName + ? getAgentModelVariantForSession( + currentSessionId, + restoreAgentName, + latestLoadedUserChoice.providerID, + latestLoadedUserChoice.modelID, + ) + : undefined); const applyResult = applyModelSelectionWithVariant( latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID, - historicalVariant, - latestLoadedUserChoice.agent || currentAgentName || undefined, + restoredVariant, + restoreAgentName, ); if (applyResult !== 'applied') { return; } + // The effort is not written again here: `applyModelSelectionWithVariant` + // above already recorded `historicalVariant` for this same agent and + // model, and a second write can only disagree with the first. if (latestLoadedUserChoice.agent) { saveSessionAgentSelection(currentSessionId, latestLoadedUserChoice.agent); - saveAgentModelVariantForSession( - currentSessionId, - latestLoadedUserChoice.agent, - latestLoadedUserChoice.providerID, - latestLoadedUserChoice.modelID, - historicalVariant, - ); } saveSessionModelSelection(currentSessionId, latestLoadedUserChoice.providerID, latestLoadedUserChoice.modelID); latestLoadedUserChoiceRestoreRef.current = restoreKey; @@ -884,11 +919,11 @@ export const ModelControls: React.FC = ({ latestLoadedUserChoice, setAgent, applyModelSelectionWithVariant, + getAgentModelVariantForSession, getModelVariantOptions, getSessionModelSelection, resolveModelVariantSelection, saveSessionAgentSelection, - saveAgentModelVariantForSession, saveSessionModelSelection, ]); @@ -1113,11 +1148,10 @@ export const ModelControls: React.FC = ({ return; } + // The chosen effort does not exist on this model: drop the choice and + // inherit, rather than pin an explicit "Default" the user never picked. if (currentVariant && !availableVariants.includes(currentVariant)) { - setCurrentVariantOverride( - null, - resolveInheritedVariantForModel(currentProviderId, currentModelId), - ); + setCurrentVariant(resolveInheritedVariantForModel(currentProviderId, currentModelId)); return; } @@ -1143,7 +1177,8 @@ export const ModelControls: React.FC = ({ const inheritedVariant = resolveInheritedVariantForModel(currentProviderId, currentModelId); if (savedVariant && availableVariants.includes(savedVariant)) { setCurrentVariantOverride(savedVariant, inheritedVariant); - } else if (currentVariantSelection.override === null) { + } else if (savedVariant === null || currentVariantSelection.override === null) { + // "Default" was picked for this session, or is picked right now. setCurrentVariantOverride(null, inheritedVariant); } else { setCurrentVariant(inheritedVariant); @@ -1172,7 +1207,9 @@ export const ModelControls: React.FC = ({ const handleVariantSelect = React.useCallback((variant: string | undefined) => { if (currentProviderId && currentModelId) { - commitVariantSelectionForModel(currentProviderId, currentModelId, variant); + // Picked in the effort menu, so no effort means the user picked + // "Default" — a choice, recorded as `null`. + commitVariantSelectionForModel(currentProviderId, currentModelId, variant ?? null); } }, [commitVariantSelectionForModel, currentModelId, currentProviderId]); @@ -1234,8 +1271,10 @@ export const ModelControls: React.FC = ({ ) => { try { const effectiveAgentName = options?.agentName ?? resolveLiveAgentName() ?? undefined; + // `applyVariant` is only set when the user adjusted the effort in + // the model picker, so no effort means an explicit "Default". const result = options?.applyVariant - ? applyModelSelectionWithVariant(providerId, modelId, options.variant, effectiveAgentName) + ? applyModelSelectionWithVariant(providerId, modelId, options.variant ?? null, effectiveAgentName) : tryApplyModelSelection(providerId, modelId, effectiveAgentName); if (result !== 'applied') { if (result === 'provider-missing') { @@ -1592,8 +1631,10 @@ export const ModelControls: React.FC = ({ } } - const handleMobileModelApply = (providerId: string, modelId: string, variant: string | undefined) => { - const result = applyModelSelectionWithVariant(providerId, modelId, variant); + const handleMobileModelApply = (providerId: string, modelId: string, variant: string | null | undefined) => { + // Chosen in the mobile model sheet, and the row already showed this + // effort: no effort there means the user is applying "Default". + const result = applyModelSelectionWithVariant(providerId, modelId, variant ?? null); if (result !== 'applied') { if (result === 'provider-missing') { console.error('[ModelControls] Provider not available for selection:', providerId); @@ -1630,7 +1671,8 @@ export const ModelControls: React.FC = ({ const variantOptions = getModelVariantOptions(providerId, modelId); const hasVariants = variantOptions.length > 0; const resolvedVariant = resolveModelVariantSelection(providerId, modelId); - const variantLabel = hasVariants ? formatEffortLabel(resolvedVariant) : null; + // Both an explicit "Default" and no choice at all read as "Default". + const variantLabel = hasVariants ? formatEffortLabel(resolvedVariant ?? undefined) : null; const isExpanded = expandedMobileModelKey === rowKey; const inlineVariantOptions = [undefined, ...variantOptions].slice(0, MAX_INLINE_MOBILE_VARIANT_OPTIONS); const hasVariantOverflow = inlineVariantOptions.length < variantOptions.length + 1; @@ -1935,7 +1977,8 @@ export const ModelControls: React.FC = ({ }; const handleSelect = (variant: string | undefined) => { - const result = applyModelSelectionWithVariant(targetProviderId, targetModelId, variant); + // Chosen in the mobile effort panel: no effort means "Default". + const result = applyModelSelectionWithVariant(targetProviderId, targetModelId, variant ?? null); if (result !== 'applied') { return; } diff --git a/packages/ui/src/components/chat/ModelControls.variant.behavior.test.tsx b/packages/ui/src/components/chat/ModelControls.variant.behavior.test.tsx new file mode 100644 index 00000000..a9fec6dd --- /dev/null +++ b/packages/ui/src/components/chat/ModelControls.variant.behavior.test.tsx @@ -0,0 +1,406 @@ +import React, { act } from 'react'; +import { beforeEach, describe, expect, mock, test } from 'bun:test'; +import { createRoot } from 'react-dom/client'; +import { Window } from 'happy-dom'; +import { create } from 'zustand'; + +/** + * Restoring a session must not invent an effort choice. + * + * The selection store keeps three states for a session's effort: an effort + * name, `null` for an explicit "Default", and `undefined` for no choice at all. + * Only the picker may write `null`. When a restore path writes it instead, the + * session latches onto "Default" — `null` outranks the agent and settings + * defaults by design — and the concrete effort the session's own history + * carries can never come back. These tests pin who writes what. + */ + +type VariantChoice = string | null | undefined; + +type UserModelChoice = { + id: string; + agent?: string; + providerID: string; + modelID: string; + variant?: string; +}; + +const PROVIDER_ID = 'openai'; +const MODEL_ID = 'gpt-5.5'; +const AGENT = 'build'; +const SESSION_ID = 'ses_restore'; + +const model = { + id: MODEL_ID, + name: MODEL_ID, + providerID: PROVIDER_ID, + variants: { low: {}, high: {} }, +}; +const provider = { id: PROVIDER_ID, name: PROVIDER_ID, models: [model] }; +const agent = { name: AGENT, mode: 'primary' as const }; + +let latestUserChoice: UserModelChoice | null = null; +let forcePreserveManualOverride: boolean | null = null; + +/** Every effort written for the session, in order, including `undefined`. */ +const variantWrites: VariantChoice[] = []; +/** Every `(override, inherited)` pair pushed into the config store. */ +const overrideWrites: Array<{ override: VariantChoice; inherited: string | undefined }> = []; + +type ConfigState = { + providers: typeof provider[]; + agents: typeof agent[]; + modelsMetadata: Record; + currentProviderId: string; + currentModelId: string; + currentVariant: string | undefined; + currentVariantSelection: { override: VariantChoice; inherited: string | undefined }; + currentAgentName: string | undefined; + settingsDefaultVariant: string | undefined; + settingsDefaultAgent: string | undefined; + selectionSource: 'auto' | 'manual'; + setProvider: (providerId: string) => void; + setSelectedProvider: (providerId: string) => void; + setModel: (modelId: string) => void; + setAgent: (agentName: string) => void; + setCurrentVariant: (variant: string | undefined) => void; + setCurrentVariantOverride: (override: VariantChoice, inherited: string | undefined) => void; + getCurrentProvider: () => typeof provider; + getCurrentAgent: () => typeof agent; + getVisibleAgents: () => typeof agent[]; + getCurrentModelVariants: () => string[]; + getModelMetadata: () => undefined; +}; + +const useConfigStore = create((set) => ({ + providers: [provider], + agents: [agent], + modelsMetadata: {}, + currentProviderId: PROVIDER_ID, + currentModelId: MODEL_ID, + currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, + currentAgentName: AGENT, + settingsDefaultVariant: undefined, + settingsDefaultAgent: undefined, + selectionSource: 'auto', + setProvider: (providerId) => set({ currentProviderId: providerId }), + setSelectedProvider: () => undefined, + setModel: (modelId) => set({ currentModelId: modelId }), + setAgent: (agentName) => set({ currentAgentName: agentName }), + // Mirrors the real store, including its no-op guard: without that guard an + // unchanged write returns a fresh state object every render and the + // component's variant effects never settle. + setCurrentVariant: (variant) => { + useConfigStore.getState().setCurrentVariantOverride(undefined, variant); + }, + setCurrentVariantOverride: (override, inherited) => { + set((state) => { + const currentVariant = override === null ? undefined : override ?? inherited; + if ( + state.currentVariant === currentVariant + && state.currentVariantSelection.override === override + && state.currentVariantSelection.inherited === inherited + ) { + return state; + } + overrideWrites.push({ override, inherited }); + return { currentVariant, currentVariantSelection: { override, inherited } }; + }); + }, + getCurrentProvider: () => provider, + getCurrentAgent: () => agent, + getVisibleAgents: () => [agent], + getCurrentModelVariants: () => Object.keys(model.variants), + getModelMetadata: () => undefined, +})); + +type SelectionState = { + savedVariant: VariantChoice; + sessionAgentSelections: Map; + getSessionModelSelection: () => { providerId: string; modelId: string } | null; + getSessionAgentSelection: () => string | null; + getAgentModelForSession: () => { providerId: string; modelId: string } | null; + getAgentModelVariantForSession: () => VariantChoice; + saveSessionModelSelection: () => void; + saveSessionAgentSelection: () => void; + saveAgentModelForSession: () => void; + saveAgentModelVariantForSession: ( + sessionId: string, + agentName: string, + providerId: string, + modelId: string, + variant: VariantChoice, + ) => void; +}; + +const useSelectionStore = create((set, get) => ({ + savedVariant: undefined, + sessionAgentSelections: new Map([[SESSION_ID, AGENT]]), + getSessionModelSelection: () => ({ providerId: PROVIDER_ID, modelId: MODEL_ID }), + getSessionAgentSelection: () => AGENT, + getAgentModelForSession: () => ({ providerId: PROVIDER_ID, modelId: MODEL_ID }), + getAgentModelVariantForSession: () => get().savedVariant, + saveSessionModelSelection: () => undefined, + saveSessionAgentSelection: () => undefined, + saveAgentModelForSession: () => undefined, + saveAgentModelVariantForSession: (_sessionId, _agentName, _providerId, _modelId, variant) => { + variantWrites.push(variant); + set({ savedVariant: variant }); + }, +})); + +const useSessionUIStore = create(() => ({ + currentSessionId: SESSION_ID, + getDirectoryForSession: () => '/workspace/project', +})); + +const useUIStore = create(() => ({ + isMobile: false, + isModelSelectorOpen: false, + hiddenModels: [], + providerOrder: [], + shortcutOverrides: {}, + isFavoriteModel: () => false, + toggleFavoriteModel: () => undefined, + reorderFavoriteModel: () => undefined, + setProviderOrder: () => undefined, + setModelSelectorOpen: () => undefined, + setSettingsDialogOpen: () => undefined, + setSettingsPage: () => undefined, + addRecentAgent: () => undefined, + addRecentModel: () => undefined, + addRecentEffort: () => undefined, +})); + +const passthrough = ({ children }: React.PropsWithChildren) =>
{children}
; + +// Captured by value before the module is replaced: reading it back off the +// namespace afterwards would resolve to the replacement and recurse. +const { shouldPreserveManualModelOverride: realShouldPreserveManualModelOverride } = + await import('@/lib/messages/userModelChoice'); + +mock.module('@/lib/messages/userModelChoice', () => ({ + findLatestUserModelChoice: () => latestUserChoice, + // The real guard, unless a test opts out: whether it fires decides which + // restore branch runs, and the branch that erased a recorded Default is the + // one it declines to protect. + shouldPreserveManualModelOverride: (args: Parameters[0]) => ( + forcePreserveManualOverride ?? realShouldPreserveManualModelOverride(args) + ), +})); + +mock.module('@/stores/useConfigStore', () => ({ useConfigStore })); +mock.module('@/sync/selection-store', () => ({ useSelectionStore })); +mock.module('@/sync/session-ui-store', () => ({ useSessionUIStore })); +mock.module('@/stores/useUIStore', () => ({ useUIStore })); +mock.module('@/stores/contextStore', () => ({ + useContextStore: (selector: (state: { hasHydrated: boolean }) => T): T => selector({ hasHydrated: true }), +})); + +mock.module('@/sync/sync-context', () => ({ + useSessionMessages: () => [], + useSessionRenderable: () => true, +})); +mock.module('@/sync/use-sync', () => ({ useSync: () => ({ sessions: [] }) })); +mock.module('@/sync/sync-refs', () => ({ getSyncParts: () => [] })); + +mock.module('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuItem: passthrough, + DropdownMenuLabel: passthrough, + DropdownMenuSeparator: () => null, + DropdownMenuTrigger: passthrough, +})); +mock.module('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); +mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough })); +mock.module('@/components/ui/ProviderLogo', () => ({ ProviderLogo: () => null })); +mock.module('@/components/ui/ScrollableOverlay', () => ({ ScrollableOverlay: passthrough })); +mock.module('@/components/ui/tooltip', () => ({ + Tooltip: passthrough, + TooltipContent: passthrough, + TooltipTrigger: passthrough, +})); +mock.module('@/components/icon/Icon', () => ({ Icon: () => null })); +mock.module('@/components/model-picker/ModelPickerList', () => ({ ModelPickerList: () => null })); +mock.module('@/hooks/useRuntimeAPIs', () => ({ useIsVSCodeRuntime: () => false })); +mock.module('@/hooks/useModelLists', () => ({ useModelLists: () => ({ favoriteModels: [], recentModels: [] }) })); +mock.module('@/hooks/useIsTextTruncated', () => ({ useIsTextTruncated: () => false })); +mock.module('@/hooks/useOpenCodeReadiness', () => ({ + useOpenCodeReadiness: () => ({ isReady: true, isUnavailable: false }), +})); +mock.module('@/lib/device', () => ({ useDeviceInfo: () => ({ isTouch: false }) })); +mock.module('@/lib/desktop', () => ({ isDesktopShell: () => false })); +mock.module('@/lib/startupTrace', () => ({ markStartupTrace: () => undefined })); + +const { ModelControls } = await import('./ModelControls'); +const { I18nProvider } = await import('@/lib/i18n'); + +const DOM_GLOBAL_NAMES = [ + 'window', + 'document', + 'navigator', + 'Node', + 'Element', + 'HTMLElement', + 'HTMLIFrameElement', + 'localStorage', + 'requestAnimationFrame', + 'cancelAnimationFrame', + 'IS_REACT_ACT_ENVIRONMENT', +] as const; + +const frameTimers = new Map>(); +let nextFrameHandle = 1; + +const installDom = () => { + const happyWindow = new Window({ url: 'http://localhost' }); + const previous = DOM_GLOBAL_NAMES.map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, + ); + const values = { + window: happyWindow, + document: happyWindow.document, + navigator: happyWindow.navigator, + Node: happyWindow.Node, + Element: happyWindow.Element, + HTMLElement: happyWindow.HTMLElement, + HTMLIFrameElement: happyWindow.HTMLIFrameElement, + localStorage: happyWindow.localStorage, + // The component focuses the composer through rAF on several paths. + requestAnimationFrame: (callback: FrameRequestCallback) => { + const handle = nextFrameHandle++; + frameTimers.set(handle, happyWindow.setTimeout(() => { + frameTimers.delete(handle); + callback(0); + }, 0)); + return handle; + }, + cancelAnimationFrame: (handle: number) => { + const timer = frameTimers.get(handle); + if (timer === undefined) return; + frameTimers.delete(handle); + happyWindow.clearTimeout(timer); + }, + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const name of DOM_GLOBAL_NAMES) { + Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true }); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + return { + container, + restore: () => { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +const renderModelControls = async () => { + const dom = installDom(); + const root = createRoot(dom.container); + await act(async () => root.render( + + + , + )); + return { + dom, + cleanup: async () => { + await act(async () => root.unmount()); + dom.restore(); + }, + }; +}; + +describe('ModelControls effort restore', () => { + beforeEach(() => { + variantWrites.length = 0; + overrideWrites.length = 0; + latestUserChoice = null; + forcePreserveManualOverride = null; + useSelectionStore.setState({ savedVariant: undefined }); + useConfigStore.setState({ + currentProviderId: PROVIDER_ID, + currentModelId: MODEL_ID, + currentAgentName: AGENT, + currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, + settingsDefaultVariant: undefined, + selectionSource: 'auto', + }); + }); + + test('restores the concrete effort the session history carries', async () => { + latestUserChoice = { id: 'msg-1', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'low' }; + + const { cleanup } = await renderModelControls(); + try { + expect(variantWrites).toContain('low'); + expect(variantWrites).not.toContain(null); + expect(useSelectionStore.getState().savedVariant).toBe('low'); + expect(useConfigStore.getState().currentVariantSelection.override).toBe('low'); + } finally { + await cleanup(); + } + }); + + test('history without an effort records no choice instead of an explicit Default', async () => { + latestUserChoice = { id: 'msg-2', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID }; + + const { cleanup } = await renderModelControls(); + try { + expect(variantWrites).not.toContain(null); + expect(useSelectionStore.getState().savedVariant).toBe(undefined); + expect(useConfigStore.getState().currentVariantSelection.override).toBe(undefined); + } finally { + await cleanup(); + } + }); + + test('the echo of a Default send does not erase the recorded Default', async () => { + // The reported repro. The send under "Default" carried no effort, so the + // message it echoes back carries none either, and its model matches the one + // the send saved — which is exactly when the manual-override guard declines + // to protect the selection and the history branch runs. + latestUserChoice = { id: 'msg-echo', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID }; + useSelectionStore.setState({ savedVariant: null }); + useConfigStore.setState({ + selectionSource: 'manual', + settingsDefaultVariant: 'low', + currentVariantSelection: { override: null, inherited: 'low' }, + }); + + const { cleanup } = await renderModelControls(); + try { + expect(useSelectionStore.getState().savedVariant).toBeNull(); + expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); + expect(useConfigStore.getState().currentVariant).toBe(undefined); + } finally { + await cleanup(); + } + }); + + test('a preserved manual override keeps a recorded explicit Default', async () => { + latestUserChoice = { id: 'msg-3', agent: AGENT, providerID: PROVIDER_ID, modelID: MODEL_ID, variant: 'high' }; + forcePreserveManualOverride = true; + useSelectionStore.setState({ savedVariant: null }); + useConfigStore.setState({ selectionSource: 'manual' }); + + const { cleanup } = await renderModelControls(); + try { + expect(useSelectionStore.getState().savedVariant).toBeNull(); + expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); + } finally { + await cleanup(); + } + }); +}); diff --git a/packages/ui/src/components/chat/QueuedMessageChips.tsx b/packages/ui/src/components/chat/QueuedMessageChips.tsx index b64a3f4f..11596a02 100644 --- a/packages/ui/src/components/chat/QueuedMessageChips.tsx +++ b/packages/ui/src/components/chat/QueuedMessageChips.tsx @@ -20,6 +20,7 @@ import { useInputStore } from '@/sync/input-store'; import { useI18n } from '@/lib/i18n'; import { Icon } from "@/components/icon/Icon"; import { Button } from '@/components/ui/button'; +import { toast } from '@/components/ui'; import { cn } from '@/lib/utils'; interface QueuedMessageChipProps { @@ -102,7 +103,8 @@ const QueuedMessageChip = memo(({ message, target, onEdit, onSend }: QueuedMessa QueuedMessageChip.displayName = 'QueuedMessageChip'; interface QueuedMessageChipsProps { - onEditMessage: (content: string, attachments?: QueuedMessage['attachments']) => void; + /** The message was taken from the queue in full; the composer restores it. */ + onEditMessage: (message: QueuedMessage) => void; onSendMessage: (messageId: string) => void; } @@ -149,16 +151,21 @@ export const QueuedMessageChips = memo(({ onEditMessage, onSendMessage }: Queued const handleEdit = React.useCallback((message: QueuedMessage) => { if (!target) return; - - const popped = popToInput(target, message.id); - if (popped) { + + // The full message (attachments included) comes back from the queue's + // owner; the chip itself only knows the summary. + void popToInput(target, message.id).then((popped) => { + if (!popped) return; if (popped.attachments && popped.attachments.length > 0) { const currentAttachments = useInputStore.getState().attachedFiles; useInputStore.getState().setAttachedFiles([...currentAttachments, ...popped.attachments]); } - onEditMessage(popped.content, popped.attachments); - } - }, [target, popToInput, onEditMessage]); + onEditMessage(popped); + }).catch((error) => { + console.warn('[queue] failed to take queued message for editing:', error); + toast.error(t('chat.queuedMessage.toast.takeFailed')); + }); + }, [target, popToInput, onEditMessage, t]); const handleSend = React.useCallback((message: QueuedMessage) => { onSendMessage(message.id); diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 9d842660..f347eaf7 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -159,8 +159,14 @@ and the send path reading the same grammar. linked issue/PR) becomes its own synthetic text part carrying structured metadata** built by `lib/messages/contextParts.ts`; the timeline reads that metadata back to render context blocks. PR instructions precede the PR diff. - Queueing a message leaves context drafts in their store on purpose — the send - that later delivers the queue consumes them. + The same module's `buildComposerContext` captures that context when a message + is **queued** instead of sent: the chips leave the composer with the message + (as `QueuedContextPart`s on the queue item), the server or the VS Code + auto-send delivers them through `queuedContextToParts`, and editing the + queued message puts them back. A queued message is placed as captured — its + mention, file mentions, and skill instruction were resolved when it was + queued, never at delivery — and its context follows it before the next + queued message. - `state/useComposerDraft.ts` — a draft belongs to a (runtime, directory, session) identity. Writes are debounced while typing but forced at every edge where the page may stop running, because a pending timer is not a saved diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index cd774c29..e4c2b916 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -17,6 +17,7 @@ import React from 'react'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending'; import { formatDirectoryName } from '@/lib/utils'; import { useGitBranches, useGitStore, useIsGitRepo } from '@/stores/useGitStore'; import { useProjectsStore } from '@/stores/useProjectsStore'; @@ -191,6 +192,14 @@ export function useDraftTarget(enabled: boolean) { [newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.directoryOverride, selectedDraftProjectPath], ); + // The draft's own pending flags clear once the directory exists, which is + // before setup commands and the initial git reset finish; the bootstrap + // state covers that remaining window (and creations the draft never knew + // about, such as the New Worktree dialog), so the probe never reads the + // transient bootstrap files as the branch being dirty. + const selectedDraftDirectoryBootstrapPending = useWorktreeBootstrapPending(selectedDraftDirectory); + const draftDirectoryNeedsFreshStatusRef = React.useRef(null); + React.useEffect(() => { if ( !enabled @@ -198,15 +207,26 @@ export function useDraftTarget(enabled: boolean) { || selectedDraftProject?.kind === 'chat' || newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory + || selectedDraftDirectoryBootstrapPending ) { + if (selectedDraftDirectoryBootstrapPending && selectedDraftDirectory) { + draftDirectoryNeedsFreshStatusRef.current = selectedDraftDirectory; + } setDirtyDraftDirectory(null); return; } let cancelled = false; setDirtyDraftDirectory(null); - getGitStatus(selectedDraftDirectory, { mode: 'light' }) + const needsFreshStatus = draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory; + const statusRequest = needsFreshStatus + ? getGitStatus(selectedDraftDirectory, { mode: 'light', fresh: true }) + : getGitStatus(selectedDraftDirectory, { mode: 'light' }); + statusRequest .then((status) => { + if (!cancelled && needsFreshStatus && draftDirectoryNeedsFreshStatusRef.current === selectedDraftDirectory) { + draftDirectoryNeedsFreshStatusRef.current = null; + } if (!cancelled && (status.files?.length ?? 0) > 0) { setDirtyDraftDirectory(selectedDraftDirectory); } @@ -218,7 +238,7 @@ export function useDraftTarget(enabled: boolean) { return () => { cancelled = true; }; - }, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftProject?.kind]); + }, [enabled, newSessionDraft?.bootstrapPendingDirectory, newSessionDraft?.pendingWorktreeRequestId, selectedDraftDirectory, selectedDraftDirectoryBootstrapPending, selectedDraftProject?.kind]); const shouldKeepMissingSelectedDraftDirectory = React.useMemo(() => { const pendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); diff --git a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts index 972ab91f..d2212d5f 100644 --- a/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts +++ b/packages/ui/src/components/chat/composer/submit/__tests__/buildOutgoingMessage.test.ts @@ -3,8 +3,12 @@ import { describe, expect, test } from 'bun:test'; import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; import { CONTEXT_METADATA_KEY, contextPayloadFromDraft } from '@/lib/messages/contextParts'; +import type { QueuedContextPart } from '@/stores/messageQueueStore'; import { + buildComposerContext, buildOutgoingMessage, + queuedContextToParts, + type ComposerContextInput, type OutgoingMessageDeps, type OutgoingMessageInput, } from '../buildOutgoingMessage'; @@ -78,7 +82,7 @@ describe('the composer text alone', () => { describe('queued messages', () => { test('the oldest becomes primary and the rest follow in order', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: 'first' }, { content: 'second' }, { content: 'third' }], + queued: [{ text: 'first' }, { text: 'second' }, { text: 'third' }], }), deps()); expect(result.primaryText).toBe('first'); expect(result.additionalParts.map((p) => p.text)).toEqual(['second', 'third']); @@ -86,18 +90,43 @@ describe('queued messages', () => { test('the composer text lands after everything queued', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: 'queued' }], + queued: [{ text: 'queued' }], composerText: 'typed now', }), deps()); expect(result.primaryText).toBe('queued'); expect(result.additionalParts.map((p) => p.text)).toEqual(['typed now']); }); + test('the context a message was queued with follows it, before the next message', () => { + const metadata = { [CONTEXT_METADATA_KEY]: { kind: 'github-issue' as const, number: 3, title: 'Bug', url: 'https://x/issues/3' } }; + const result = buildOutgoingMessage(input({ + queued: [ + { text: 'first', context: [{ kind: 'context', text: 'issue body', metadata }, { kind: 'instruction', text: 'use: deploy' }] }, + { text: 'second' }, + ], + composerText: 'typed now', + }), deps()); + expect(result.primaryText).toBe('first'); + expect(result.additionalParts.map((p) => p.text)).toEqual(['issue body', 'use: deploy', 'second', 'typed now']); + expect(result.additionalParts[0]).toEqual({ text: 'issue body', synthetic: true, metadata }); + expect(result.additionalParts[1]).toEqual({ text: 'use: deploy', synthetic: true }); + }); + + test('a queued message is placed as captured, never re-resolved', () => { + const result = buildOutgoingMessage(input({ + queued: [{ text: '@agent:plan see @file:doc and /deploy' }], + }), deps()); + expect(result.primaryText).toBe('@agent:plan see @file:doc and /deploy'); + expect(result.primaryAttachments).toEqual([]); + expect(result.agentMentionName).toBe(undefined); + expect(result.additionalParts).toEqual([]); + }); + test('each queued message keeps its own attachments', () => { const result = buildOutgoingMessage(input({ queued: [ - { content: 'a', attachments: [attachment('one')] }, - { content: 'b', attachments: [attachment('two')] }, + { text: 'a', attachments: [attachment('one')] }, + { text: 'b', attachments: [attachment('two')] }, ], }), deps()); expect(result.primaryAttachments.map((a) => a.id)).toEqual(['one']); @@ -113,14 +142,14 @@ describe('agent mentions', () => { test('the first mention wins across queued messages', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: '@agent:plan a' }, { content: '@agent:build b' }], + queued: [{ text: 'a', agentMention: 'plan' }, { text: 'b', agentMention: 'build' }], }), deps()); expect(result.agentMentionName).toBe('plan'); }); test('a queued mention outranks one typed later', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: '@agent:plan a' }], + queued: [{ text: 'a', agentMention: 'plan' }], composerText: '@agent:build b', }), deps()); expect(result.agentMentionName).toBe('plan'); @@ -230,10 +259,9 @@ describe('synthetic context', () => { expect(result.additionalParts.at(-1)).toEqual({ text: 'use: deploy', synthetic: true }); }); - test('skills are collected across every authored body, without duplicates', () => { + test('skills named in the composer are collected without duplicates', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: '/deploy a' }], - composerText: '/deploy and /audit', + composerText: '/deploy and /audit and /deploy', }), deps()); expect(result.additionalParts.at(-1)?.text).toBe('use: deploy,audit'); }); @@ -262,7 +290,7 @@ describe('synthetic context', () => { describe('full assembly order', () => { test('queued, then typed, then synthetic, then references, then skills', () => { const result = buildOutgoingMessage(input({ - queued: [{ content: 'q1' }, { content: 'q2' }], + queued: [{ text: 'q1' }, { text: 'q2' }], composerText: 'typed /deploy', syntheticTexts: ['synthetic'], linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' }, @@ -283,3 +311,52 @@ describe('full assembly order', () => { ]); }); }); + +describe('capturing composer context for the queue', () => { + const contextInput = (overrides: Partial = {}): ComposerContextInput => ({ + inlineComments: [], + syntheticTexts: [], + linkedIssue: null, + linkedPr: null, + linkedLinearIssue: null, + ...overrides, + }); + + test('captures everything attached, in send order, with the skill instruction last', () => { + const context = buildComposerContext(contextInput({ + inlineComments: [commentDraft()], + syntheticTexts: ['conflict note'], + linkedIssue: { number: 3, title: 'Bug', url: 'https://x/issues/3', contextText: 'issue' }, + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' }, + linkedLinearIssue: { identifier: 'ENG-12', title: 'Login', url: 'https://linear.app/x/issue/ENG-12', contextText: 'linear' }, + }), 'use: deploy'); + + expect(context.map((part) => part.kind)).toEqual(['context', 'synthetic', 'context', 'context', 'context', 'instruction']); + expect(context[0]?.kind).toBe('context'); + expect(context[0]?.text).toContain('Comment on `src/app.ts` lines 3-5 (modified):'); + expect(context[0]?.kind === 'context' ? context[0].metadata : null) + .toEqual({ [CONTEXT_METADATA_KEY]: contextPayloadFromDraft(commentDraft()) }); + expect(context[3]).toEqual({ + kind: 'context', + text: 'pr-diff', + instructions: 'pr-how', + metadata: { [CONTEXT_METADATA_KEY]: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } }, + }); + expect(context.at(-1)).toEqual({ kind: 'instruction', text: 'use: deploy' }); + }); + + test('nothing attached captures nothing', () => { + expect(buildComposerContext(contextInput(), null)).toEqual([]); + }); + + test('delivering captured context reproduces the composer parts exactly', () => { + const input = contextInput({ + inlineComments: [commentDraft()], + syntheticTexts: ['conflict note'], + linkedPr: { number: 7, title: 'PR', url: 'https://x/pr/7', instructions: 'pr-how', context: 'pr-diff' }, + }); + const captured: QueuedContextPart[] = buildComposerContext(input, 'use: deploy'); + const direct = buildOutgoingMessage({ ...input, queued: [], composerText: 'use /deploy', composerAttachments: [] }, deps()); + expect(queuedContextToParts(captured)).toEqual(direct.additionalParts); + }); +}); diff --git a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts index b85893f5..75c6b1ad 100644 --- a/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts +++ b/packages/ui/src/components/chat/composer/submit/buildOutgoingMessage.ts @@ -15,6 +15,7 @@ import type { AttachedFile } from '@/stores/types/sessionTypes'; import type { InlineCommentDraft } from '@/stores/useInlineCommentDraftStore'; +import type { QueuedContextPart } from '@/stores/messageQueueStore'; import { contextPayloadFromDraft, createContextPart, type ContextPartMetadata } from '@/lib/messages/contextParts'; export interface OutgoingPart { @@ -36,17 +37,20 @@ export interface OutgoingMessage { isEmpty: boolean; } +/** + * A queued message is already resolved: its agent mention was stripped, its + * file mentions became attachments, and the context the composer had attached + * travels with it. Assembly only places it. + */ export interface QueuedInput { - content: string; + text: string; + agentMention?: string; attachments?: AttachedFile[]; + context?: readonly QueuedContextPart[]; } -export interface OutgoingMessageInput { - /** Messages queued while a turn was running, oldest first. */ - queued: readonly QueuedInput[]; - /** The composer's own text, or null when this send skips it. */ - composerText: string | null; - composerAttachments: readonly AttachedFile[]; +/** What the composer has attached besides text and files. */ +export interface ComposerContextInput { /** Context drafts (code comments, terminal selections, annotations, PR context). */ inlineComments: readonly InlineCommentDraft[]; /** Synthetic context produced elsewhere (conflict resolution, and such). */ @@ -56,6 +60,14 @@ export interface OutgoingMessageInput { linkedLinearIssue: { identifier: string; title: string; url: string; contextText: string } | null; } +export interface OutgoingMessageInput extends ComposerContextInput { + /** Messages queued while a turn was running, oldest first. */ + queued: readonly QueuedInput[]; + /** The composer's own text, or null when this send skips it. */ + composerText: string | null; + composerAttachments: readonly AttachedFile[]; +} + /** * The parts of assembly that depend on stores or async config, injected so the * assembly itself stays pure. @@ -104,20 +116,19 @@ export function buildOutgoingMessage( }; // Queued messages come first, in the order they were queued: the oldest - // becomes the primary message so the turn reads chronologically. + // becomes the primary message so the turn reads chronologically. Each one + // is followed by the context it was queued with. input.queued.forEach((queued, index) => { - const resolved = resolve(queued.content); - const attachments = [ - ...deps.sanitizeAttachments(queued.attachments), - ...resolved.attachments, - ]; + noteAgent(queued.agentMention); + const attachments = deps.sanitizeAttachments(queued.attachments); if (index === 0) { - primaryText = resolved.text; + primaryText = queued.text; primaryAttachments = attachments; - return; + } else { + additionalParts.push({ text: queued.text, attachments }); } - additionalParts.push({ text: resolved.text, attachments }); + additionalParts.push(...queuedContextToParts(queued.context ?? [])); }); // The composer's own text follows, becoming primary only when nothing was @@ -137,40 +148,10 @@ export function buildOutgoingMessage( } } - // Everything below is context for the model, never plain user text. Each - // attached context item becomes its own synthetic part carrying structured - // metadata, so the timeline can render it as a context block after the - // server echoes the message back. - for (const draft of input.inlineComments) { - additionalParts.push(createContextPart(contextPayloadFromDraft(draft))); - } - - for (const text of input.syntheticTexts) { - additionalParts.push({ text, synthetic: true }); - } - - if (input.linkedIssue) { - const { number, title, url, contextText } = input.linkedIssue; - additionalParts.push(createContextPart({ kind: 'github-issue', number, title, url }, contextText)); - } - - if (input.linkedPr) { - // Instructions before context: the model is told how to read the diff - // before it is given the diff. - const { number, title, url, instructions, context } = input.linkedPr; - additionalParts.push({ text: instructions, synthetic: true }); - additionalParts.push(createContextPart({ kind: 'github-pr', number, title, url }, context)); - } - - if (input.linkedLinearIssue) { - const { identifier, title, url, contextText } = input.linkedLinearIssue; - additionalParts.push(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText)); - } - - const skillInstruction = deps.buildSkillInstruction(skillNames); - if (skillInstruction) { - additionalParts.push({ text: skillInstruction, synthetic: true }); - } + // Everything the composer had attached follows its text. + additionalParts.push(...queuedContextToParts( + buildComposerContext(input, deps.buildSkillInstruction(skillNames)), + )); return { primaryText, @@ -180,3 +161,68 @@ export function buildOutgoingMessage( isEmpty: !primaryText && primaryAttachments.length === 0 && additionalParts.length === 0, }; } + +/** + * Everything the composer has attached besides text and files, in send + * order. Each attached context item becomes its own synthetic part carrying + * structured metadata, so the timeline can render it as a context block after + * the server echoes the message back. Used both when sending and when queueing: + * a queued message takes this context with it, so whoever delivers it later + * sends exactly what the composer would have. + */ +export function buildComposerContext( + input: ComposerContextInput, + skillInstruction: string | null, +): QueuedContextPart[] { + const context: QueuedContextPart[] = []; + const attach = (part: { text: string; metadata: ContextPartMetadata }, instructions?: string) => { + const entry: QueuedContextPart = { kind: 'context', text: part.text, metadata: part.metadata }; + if (instructions) entry.instructions = instructions; + context.push(entry); + }; + + for (const draft of input.inlineComments) { + attach(createContextPart(contextPayloadFromDraft(draft))); + } + + for (const text of input.syntheticTexts) { + context.push({ kind: 'synthetic', text }); + } + + if (input.linkedIssue) { + const { number, title, url, contextText } = input.linkedIssue; + attach(createContextPart({ kind: 'github-issue', number, title, url }, contextText)); + } + + if (input.linkedPr) { + // Instructions before context: the model is told how to read the diff + // before it is given the diff. + const { number, title, url, instructions, context: prContext } = input.linkedPr; + attach(createContextPart({ kind: 'github-pr', number, title, url }, prContext), instructions); + } + + if (input.linkedLinearIssue) { + const { identifier, title, url, contextText } = input.linkedLinearIssue; + attach(createContextPart({ kind: 'linear-issue', identifier, title, url }, contextText)); + } + + if (skillInstruction) { + context.push({ kind: 'instruction', text: skillInstruction }); + } + + return context; +} + +/** The synthetic parts a captured context is delivered as, in order. */ +export function queuedContextToParts(context: readonly QueuedContextPart[]): OutgoingPart[] { + const parts: OutgoingPart[] = []; + for (const part of context) { + if (part.kind !== 'context') { + parts.push({ text: part.text, synthetic: true }); + continue; + } + if (part.instructions) parts.push({ text: part.instructions, synthetic: true }); + parts.push({ text: part.text, synthetic: true, metadata: part.metadata }); + } + return parts; +} diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index b7f69f5e..3d066526 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -56,6 +56,7 @@ import { isEmbeddedSessionChat } from '@/components/layout/contextPanelEmbeddedC import { useProviderLogo } from '@/hooks/useProviderLogo'; import { getAgentColor } from '@/lib/agentColors'; import { isCapacitorMobileApp } from '@/apps/mobileNativeChrome'; +import { WorktreeRequiresGitRepositoryError } from '@/lib/worktrees/worktreeCreate'; const CONTAIN_LAYOUT_STYLE = { contain: 'layout' as const, transform: 'translateZ(0)' }; @@ -1331,17 +1332,14 @@ const AssistantMessageBody = React.memo(({ const effectiveStreamPhase: StreamPhase = hasStopFinish ? 'completed' : streamPhase; const availableWorktreesByProject = useSessionUIStore((state) => state.availableWorktreesByProject); - const currentProjectRef = React.useMemo(() => { - if (!canUseProjectPlanActions) { - return null; - } - + const sessionProjectRef = React.useMemo(() => { const directory = effectiveDirectory ?? (currentSessionId ? getDirectoryForSession(currentSessionId) : null) ?? ''; const resolved = resolveProjectForSessionDirectory(projects, availableWorktreesByProject, directory); return resolved ? { id: resolved.id, path: resolved.path } : null; - }, [availableWorktreesByProject, canUseProjectPlanActions, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]); + }, [availableWorktreesByProject, currentSessionId, effectiveDirectory, getDirectoryForSession, projects]); + const currentProjectRef = canUseProjectPlanActions ? sessionProjectRef : null; const isActiveTool = React.useCallback((toolPart: ToolPartType): boolean => { const state = (toolPart as Record).state as Record | undefined ?? {}; @@ -1377,28 +1375,48 @@ const AssistantMessageBody = React.memo(({ (event: React.MouseEvent) => { event.stopPropagation(); event.preventDefault(); - if (!createSessionFromAssistantMessage || !assistantPlanText.trim()) { + if (!assistantPlanText.trim()) { return; } setIsForkDialogOpen(true); }, - [createSessionFromAssistantMessage, assistantPlanText] + [assistantPlanText] ); const handleConfirmFork = React.useCallback( async (execution: ForkSessionExecution) => { - if (!createSessionFromAssistantMessage) { - return; - } setIsForkSubmitting(true); try { - await createSessionFromAssistantMessage(messageId, execution); + if (!sessionId) { + throw new Error('Source session is unavailable'); + } + const sourceDirectory = effectiveDirectory ?? getDirectoryForSession(sessionId); + if (!sourceDirectory) { + throw new Error('Source session directory is unavailable'); + } + await createSessionFromAssistantMessage({ + sessionId, + directory: sourceDirectory, + text: assistantPlanText, + }, execution); setIsForkDialogOpen(false); + } catch (error) { + console.error('Failed to start a session from an assistant message:', error); + if (error instanceof WorktreeRequiresGitRepositoryError) { + toast.error(t('rightSidebar.contextNotesTodo.toast.worktreeRequiresGitRepo')); + return; + } + + const description = error instanceof Error ? error.message : undefined; + toast.error( + t('rightSidebar.contextNotesTodo.toast.createSessionFailed'), + description ? { description } : undefined + ); } finally { setIsForkSubmitting(false); } }, - [createSessionFromAssistantMessage, messageId] + [assistantPlanText, createSessionFromAssistantMessage, effectiveDirectory, getDirectoryForSession, sessionId, t] ); const handleForkMultiRunClick = React.useCallback( @@ -2136,6 +2154,8 @@ const AssistantMessageBody = React.memo(({ open={isForkDialogOpen} onOpenChange={setIsForkDialogOpen} projectDirectory={effectiveDirectory ?? null} + sourceSessionId={sessionId ?? null} + worktreeProjectDirectory={sessionProjectRef?.path ?? null} submitting={isForkSubmitting} onConfirm={handleConfirmFork} /> diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index adf49da3..29425e2e 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -14,7 +14,6 @@ import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory'; import { useSessionUIStore } from '@/sync/session-ui-store'; import { useSessionMessageRecords, useEnsureSessionMessages } from '@/sync/sync-context'; import { useUIStore } from '@/stores/useUIStore'; -import { sessionEvents } from '@/lib/sessionEvents'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; import { Button } from '@/components/ui/button'; import { toast } from '@/components/ui'; @@ -57,7 +56,6 @@ import { extractFirstChangedLineFromDiff, getDiffPatchEntries, getFirstChangedLineFromMetadata, - getMutatedToolPaths, getPatchText, getPrimaryDiffFromMetadata, getPrimaryToolPath, @@ -108,14 +106,6 @@ const normalizeToolName = (toolName: string | undefined | null): string => { return trimmed; }; -const GIT_REFRESH_MUTATING_TOOLS = new Set([ - 'bash', - 'edit', - 'write', - 'apply_patch', - 'patch', -]); - const formatDuration = (start: number, end?: number, now: number = Date.now()) => { const duration = Math.max(0, (end ?? now) - start); const seconds = duration / 1000; @@ -1699,19 +1689,16 @@ const ToolPartContent: React.FC = ({ const status = state?.status as string | undefined; const isFinalized = status === 'completed' || status === 'error' || status === 'aborted' || status === 'failed' || status === 'timeout' || status === 'cancelled'; - const isSuccessfullyFinalized = status === 'completed'; const isError = status === 'error' || status === 'failed'; const [activeLatched, setActiveLatched] = React.useState(!isFinalized); const previousPartIdRef = React.useRef(part.id); - const observedActiveGitToolRef = React.useRef(!isFinalized); React.useEffect(() => { if (previousPartIdRef.current === part.id) { return; } previousPartIdRef.current = part.id; - observedActiveGitToolRef.current = !isFinalized; // Reset latch only when tool identity changes. setActiveLatched(!isFinalized); }, [isFinalized, part.id]); @@ -1722,36 +1709,6 @@ const ToolPartContent: React.FC = ({ } }, [isFinalized]); - React.useEffect(() => { - if (!isFinalized) { - observedActiveGitToolRef.current = true; - return; - } - - // Historical completed tools can remount when the timeline changes. - // Refresh only for a tool whose active state this instance observed. - const finalizedAfterObservedActive = observedActiveGitToolRef.current; - if (!finalizedAfterObservedActive) { - return; - } - - if (!isSuccessfullyFinalized || !GIT_REFRESH_MUTATING_TOOLS.has(normalizedPartTool)) { - observedActiveGitToolRef.current = false; - return; - } - if (!currentDirectory) { - return; - } - - observedActiveGitToolRef.current = false; - const paths = getMutatedToolPaths(normalizedPartTool, input, metadata) - .map((path) => getRelativePath(path, currentDirectory)); - sessionEvents.requestGitRefresh({ - directory: currentDirectory, - ...(paths.length > 0 ? { paths } : {}), - }); - }, [currentDirectory, input, isFinalized, isSuccessfullyFinalized, metadata, normalizedPartTool]); - const expandedContentRef = React.useRef(null); React.useLayoutEffect(() => { diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts index b40456c0..e0352fed 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.test.ts @@ -5,7 +5,6 @@ import { getApplyPatchFilePath, getDiffPatchEntries, getFirstChangedLineFromMetadata, - getMutatedToolPaths, getPrimaryDiffFromMetadata, getPrimaryToolPath, getRenderablePatchInfo, @@ -57,28 +56,6 @@ describe('toolDiffUtils', () => { })).toBe('/workspace/project/src/second.ts'); }); - test('lists every apply_patch mutation path, including both sides of a move', () => { - expect(getMutatedToolPaths('apply_patch', undefined, { - files: [ - { filePath: '/workspace/project/src/deleted.ts', type: 'delete' }, - { - filePath: '/workspace/project/src/old.ts', - movePath: '/workspace/project/src/new.ts', - type: 'move', - }, - ], - })).toEqual([ - '/workspace/project/src/deleted.ts', - '/workspace/project/src/new.ts', - '/workspace/project/src/old.ts', - ]); - }); - - test('does not invent paths for bash or task tools', () => { - expect(getMutatedToolPaths('bash', { command: 'date' }, undefined)).toEqual([]); - expect(getMutatedToolPaths('task', { description: 'inspect' }, undefined)).toEqual([]); - }); - test('selects the move patch and line from the same non-deleted file', () => { const deletedPatch = '@@ -3 +3 @@\n-old\n+deleted'; const movedPatch = '@@ -42 +42 @@\n-before\n+after'; diff --git a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts index 0818130e..cfe1796e 100644 --- a/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts +++ b/packages/ui/src/components/chat/message/parts/toolDiffUtils.ts @@ -200,29 +200,6 @@ export const getPrimaryToolPath = ( return null; }; -export const getMutatedToolPaths = ( - toolName: string, - input: Record | undefined, - metadata: Record | undefined, -): string[] => { - if (toolName === 'apply_patch') { - const files = Array.isArray(metadata?.files) ? metadata.files : []; - const paths = new Set(); - for (const file of files) { - if (!isRecord(file)) continue; - const filePath = getApplyPatchFilePath(file); - if (filePath) paths.add(filePath); - if (file.type === 'move' && typeof file.filePath === 'string') { - paths.add(file.filePath); - } - } - return [...paths]; - } - - const primaryPath = getPrimaryToolPath(toolName, input, metadata); - return primaryPath ? [primaryPath] : []; -}; - const supportsDiffMetadata = (toolName: string): boolean => ( toolName === 'edit' || toolName === 'multiedit' || toolName === 'apply_patch' ); diff --git a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md index d96a6b6d..1c43a17f 100644 --- a/packages/ui/src/components/chat/work-status/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/work-status/DOCUMENTATION.md @@ -179,6 +179,13 @@ including edits the user made by hand and excluding session edits that are already committed. If a session-authored count is ever needed, it has to come from aggregating message summaries, not from `Session.summary`. +One exception: while the directory is a worktree whose creation has not +finished (`useWorktreeBootstrapPending`), the working tree transiently holds +bootstrap files that the initial git reset is about to remove. Those are not +changes on the branch, so the panel neither fetches status nor renders the +changed-files row until the bootstrap settles, then forces one status fetch so +the row reflects the reset tree rather than a mid-creation snapshot. + ## Section order Ordering is by durability, not category: diff --git a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx index fc0cada6..412f1f3c 100644 --- a/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx +++ b/packages/ui/src/components/chat/work-status/WorkStatusPrimaryGroup.tsx @@ -3,6 +3,7 @@ import { useI18n } from '@/lib/i18n'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending'; import { runBackgroundNetworkTask } from '@/lib/background-network'; import { useFreshestPrVisualSummaryForBranch } from '@/stores/useGitHubPrStatusStore'; import { useSessionMessages } from '@/sync/sync-context'; @@ -67,12 +68,45 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, ), ); + // A worktree that is still being created transiently looks dirty until its + // setup commands and initial git reset finish. Those files are not changes + // on the branch, so the changed-files readout stays hidden while the + // bootstrap runs — and stays hidden until one fresh status fetch completes + // afterwards, because the shared cache may still hold a snapshot captured + // mid-creation (refresh hints fire while setup commands touch files) and + // lifting the gate onto it would flash the transient state. + const worktreeCreationPending = useWorktreeBootstrapPending(gitDirectory); + const [postBootstrapRefreshDirectory, setPostBootstrapRefreshDirectory] = React.useState(null); + const awaitingPostBootstrapStatus = postBootstrapRefreshDirectory !== null + && postBootstrapRefreshDirectory === gitDirectory; + // Warm the shared git cache through the background-network gate so the panel // never competes with the chat's own bootstrap traffic for sockets. React.useEffect(() => { if (!showRepository || !gitDirectory || !git) return; + if (worktreeCreationPending) { + setPostBootstrapRefreshDirectory(gitDirectory); + return; + } + if (awaitingPostBootstrapStatus) { + let cancelled = false; + void runBackgroundNetworkTask(() => fetchStatus(gitDirectory, git, { + force: true, + silent: true, + throwOnError: true, + })) + .then(() => { + if (!cancelled) { + setPostBootstrapRefreshDirectory((current) => (current === gitDirectory ? null : current)); + } + }) + .catch(() => undefined); + return () => { + cancelled = true; + }; + } void runBackgroundNetworkTask(() => ensureStatus(gitDirectory, git)); - }, [gitDirectory, git, ensureStatus, showRepository]); + }, [gitDirectory, git, ensureStatus, fetchStatus, showRepository, worktreeCreationPending, awaitingPostBootstrapStatus]); // Own the live invalidation for the repository readout. The desktop // composer's changed-files row no longer renders, so this panel must not @@ -175,6 +209,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, // event is reset to an empty array too, and carries real content only on // revert. Git status is the one authoritative, already-cached answer. const changed = React.useMemo(() => { + if (worktreeCreationPending || awaitingPostBootstrapStatus) return null; const files = gitStatus?.files ?? []; if (files.length === 0) return null; const stats = gitStatus?.diffStats; @@ -187,7 +222,7 @@ export const WorkStatusPrimaryGroup: React.FC = ({ sessionId, directory, } } return { files: files.length, additions, deletions, hasStats: Boolean(stats) }; - }, [gitStatus?.files, gitStatus?.diffStats]); + }, [gitStatus?.files, gitStatus?.diffStats, worktreeCreationPending, awaitingPostBootstrapStatus]); const attentionReason = gitStatus?.attentionReason ?? (gitStatus?.rebaseInProgress ? 'rebase' : null) diff --git a/packages/ui/src/components/icon/sprite.ts b/packages/ui/src/components/icon/sprite.ts index 733e6a4b..7c0a85a1 100644 --- a/packages/ui/src/components/icon/sprite.ts +++ b/packages/ui/src/components/icon/sprite.ts @@ -52,7 +52,6 @@ export const iconSpriteData = { "checkbox-blank-circle-fill": ``, "checkbox-circle": ``, "checkbox-multiple": ``, - "claude-code": ``, "clipboard": ``, "close": ``, "close-circle": ``, @@ -69,7 +68,6 @@ export const iconSpriteData = { "computer": ``, "contract-up-down": ``, "corner-down-left": ``, - "cursor": ``, "database-2": ``, "delete-bin": ``, "discord-fill": ``, diff --git a/packages/ui/src/components/layout/Header.tsx b/packages/ui/src/components/layout/Header.tsx index e89c2ec1..408f1236 100644 --- a/packages/ui/src/components/layout/Header.tsx +++ b/packages/ui/src/components/layout/Header.tsx @@ -783,6 +783,15 @@ export const Header: React.FC = () => { const beginHeaderSessionRenameRef = React.useRef(beginHeaderSessionRename); beginHeaderSessionRenameRef.current = beginHeaderSessionRename; + // The rename field opens with the whole title selected, so the first + // keystroke replaces it. Stable ref callback: an inline one would re-run on + // every render and re-select the text mid-edit. + const focusHeaderRenameInput = React.useCallback((node: HTMLInputElement | null) => { + if (!node) return; + node.focus(); + node.select(); + }, []); + React.useEffect(() => { setIsHeaderSessionMenuOpen(false); setPendingHeaderRetentionAction(null); @@ -1422,9 +1431,9 @@ export const Header: React.FC = () => { }} > setHeaderSessionTitleDraft(event.target.value)} - autoFocus onKeyDown={(event) => { event.stopPropagation(); if (event.key === 'Escape') { @@ -1579,9 +1588,9 @@ export const Header: React.FC = () => { }} > setHeaderSessionTitleDraft(event.target.value)} - autoFocus onKeyDown={(event) => { event.stopPropagation(); if (event.key === 'Escape') { diff --git a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx index a3b5956d..ff9fa654 100644 --- a/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx +++ b/packages/ui/src/components/sections/integrations/IntegrationsPage.tsx @@ -6,23 +6,13 @@ import { isVSCodeRuntime } from '@/lib/desktop'; import { getRegisteredRuntimeAPIs } from '@/contexts/runtimeAPIRegistry'; import { GitHubIntegration } from './GitHubIntegration'; import { LinearSettings } from './LinearSettings'; -import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection'; -interface IntegrationsPageProps { - onOpenProviderSetup: (providerId: string) => Promise; - onOpenPluginManager: () => void; -} - -export const IntegrationsPage: React.FC = ({ - onOpenProviderSetup, - onOpenPluginManager, -}) => { +export const IntegrationsPage: React.FC = () => { const { t } = useI18n(); // GitHub sign-in is an OpenChamber server feature; the VS Code extension // uses the editor's own GitHub session instead. const hasGitHub = !isVSCodeRuntime(); const hasLinear = Boolean(getRegisteredRuntimeAPIs()?.linear); - const hasBuiltIn = hasGitHub || hasLinear; return ( = ({ description={t('settings.page.integrations.description')} showSaveStatus > - {hasBuiltIn ? ( - - {hasGitHub ? : null} - {hasLinear ? : null} - - ) : null} - + + {hasGitHub ? : null} + {hasLinear ? : null} + ); }; diff --git a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx b/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx deleted file mode 100644 index 542f765b..00000000 --- a/packages/ui/src/components/sections/integrations/ThirdPartyIntegrationsSection.tsx +++ /dev/null @@ -1,448 +0,0 @@ -import React from 'react'; -import { useShallow } from 'zustand/react/shallow'; -import { Button } from '@/components/ui/button'; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from '@/components/ui/dialog'; -import { toast } from '@/components/ui'; -import { Icon } from '@/components/icon/Icon'; -import { SettingsSection } from '@/components/sections/shared/SettingsSection'; -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'; -import { useI18n } from '@/lib/i18n'; -import { openExternalUrl } from '@/lib/url'; -import { cn } from '@/lib/utils'; -import { - usePluginsStore, - type PluginMutationResult, -} from '@/stores/usePluginsStore'; -import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore'; -import { - getCatalogPluginPrimaryAction, - getCatalogPluginPresentation, - getCatalogPluginState, - getLatestNpmSpec, - THIRD_PARTY_PLUGINS, - type ThirdPartyPluginDefinition, -} from './thirdPartyPlugins'; - -type PendingAction = 'install' | 'update' | 'setup' | 'remove'; - -type RemoveTarget = ThirdPartyPluginDefinition | null; - -interface ThirdPartyIntegrationsSectionProps { - divider?: boolean; - onOpenProviderSetup: (providerId: string) => Promise; - onOpenPluginManager: () => void; -} - -const requiresRestart = (result: PluginMutationResult): boolean => - result.restartDeferred === true - || result.requiresManualRestart === true - || result.reloadFailed === true; - -export const ThirdPartyIntegrationsSection: React.FC = ({ - divider = true, - onOpenProviderSetup, - onOpenPluginManager, -}) => { - const { t } = useI18n(); - const { - entries, - registryInfo, - loadPlugins, - loadRegistryInfo, - createEntry, - updateEntry, - deleteEntry, - } = usePluginsStore( - useShallow((state) => ({ - entries: state.entries, - registryInfo: state.registryInfo, - loadPlugins: state.loadPlugins, - loadRegistryInfo: state.loadRegistryInfo, - createEntry: state.createEntry, - updateEntry: state.updateEntry, - deleteEntry: state.deleteEntry, - })), - ); - - const [registryLoadFailed, setRegistryLoadFailed] = React.useState(false); - const [pendingAction, setPendingAction] = React.useState<{ - pluginId: string; - action: PendingAction; - } | null>(null); - const [restartRequiredIds, setRestartRequiredIds] = React.useState>( - () => new Set(), - ); - const [providerUnavailableIds, setProviderUnavailableIds] = React.useState>( - () => new Set(), - ); - const [removeTarget, setRemoveTarget] = React.useState(null); - const [openPluginIds, setOpenPluginIds] = React.useState>(() => new Set()); - - const refresh = React.useCallback(async () => { - const pluginsLoaded = await loadPlugins({ force: true }); - if (!pluginsLoaded) { - setRegistryLoadFailed(true); - return; - } - - const latestEntries = usePluginsStore.getState().entries; - const specs = new Set(THIRD_PARTY_PLUGINS.map((plugin) => plugin.packageName)); - for (const entry of latestEntries) { - if (THIRD_PARTY_PLUGINS.some((plugin) => entry.spec === plugin.packageName || entry.spec.startsWith(`${plugin.packageName}@`))) { - specs.add(entry.spec); - } - } - const registryLoaded = await loadRegistryInfo({ specs: [...specs], force: true }); - setRegistryLoadFailed(!registryLoaded); - }, [loadPlugins, loadRegistryInfo]); - - React.useEffect(() => { - void refresh(); - }, [refresh]); - - const pendingPluginRestartCount = usePendingOpenCodeRestartStore( - (state) => state.changes.filter((change) => change.scope === 'plugins').length, - ); - const isApplyingRestart = usePendingOpenCodeRestartStore((state) => state.isApplying); - const previousPluginRestartCountRef = React.useRef(pendingPluginRestartCount); - - // When deferred plugin restarts are applied (pending plugins scope clears), drop - // local restart/unavailable flags and reload so statuses update immediately. - React.useEffect(() => { - const previousCount = previousPluginRestartCountRef.current; - previousPluginRestartCountRef.current = pendingPluginRestartCount; - - if (isApplyingRestart) { - return; - } - if (previousCount <= 0 || pendingPluginRestartCount > 0) { - return; - } - - setRestartRequiredIds(new Set()); - setProviderUnavailableIds(new Set()); - void refresh(); - }, [isApplyingRestart, pendingPluginRestartCount, refresh]); - - const setRestartRequired = React.useCallback((pluginId: string, required: boolean) => { - setRestartRequiredIds((current) => { - const next = new Set(current); - if (required) next.add(pluginId); - else next.delete(pluginId); - return next; - }); - }, []); - - const setProviderUnavailable = React.useCallback((pluginId: string, unavailable: boolean) => { - setProviderUnavailableIds((current) => { - const next = new Set(current); - if (unavailable) next.add(pluginId); - else next.delete(pluginId); - return next; - }); - }, []); - - const runMutation = React.useCallback(async ( - plugin: ThirdPartyPluginDefinition, - action: Exclude, - run: () => Promise, - ) => { - setPendingAction({ pluginId: plugin.id, action }); - try { - const result = await run(); - if (!result.ok) { - toast.error(t('settings.integrations.thirdParty.toast.actionFailed')); - return; - } - - setProviderUnavailable(plugin.id, false); - const restartNeeded = requiresRestart(result); - setRestartRequired(plugin.id, restartNeeded); - const toastOptions = restartNeeded - ? { description: t('settings.integrations.thirdParty.toast.restartRequired') } - : undefined; - if (action === 'install') { - toast.success(t('settings.integrations.thirdParty.toast.installed', { name: t(plugin.nameKey) }), toastOptions); - } else if (action === 'update') { - toast.success(t('settings.integrations.thirdParty.toast.updated', { name: t(plugin.nameKey) }), toastOptions); - } else { - toast.success(t('settings.integrations.thirdParty.toast.removed', { name: t(plugin.nameKey) }), toastOptions); - } - await refresh(); - } finally { - setPendingAction(null); - } - }, [refresh, setProviderUnavailable, setRestartRequired, t]); - - const handlePrimaryAction = React.useCallback(async (plugin: ThirdPartyPluginDefinition) => { - const state = getCatalogPluginState(entries, plugin.packageName, registryInfo); - const action = getCatalogPluginPrimaryAction(state, plugin.packageName); - - if (action === 'manage') { - onOpenPluginManager(); - return; - } - - if (action === 'setup') { - setPendingAction({ pluginId: plugin.id, action }); - try { - const opened = await onOpenProviderSetup(plugin.providerId); - setProviderUnavailable(plugin.id, !opened); - if (!opened) { - toast.error(t('settings.integrations.thirdParty.toast.providerUnavailable')); - } - } finally { - setPendingAction(null); - } - return; - } - - const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry); - if (!latestSpec) { - setRegistryLoadFailed(true); - return; - } - - if (action === 'install') { - await runMutation(plugin, 'install', () => createEntry({ spec: latestSpec, scope: 'user' })); - return; - } - - if (state.userEntry) { - await runMutation(plugin, 'update', () => updateEntry(state.userEntry!.id, { spec: latestSpec })); - } - }, [createEntry, entries, onOpenPluginManager, onOpenProviderSetup, registryInfo, runMutation, setProviderUnavailable, t, updateEntry]); - - const handleRemove = React.useCallback(async () => { - const plugin = removeTarget; - if (!plugin) return; - const state = getCatalogPluginState(entries, plugin.packageName, registryInfo); - if (!state.userEntry || state.userEntryIsAmbiguous) { - setRemoveTarget(null); - onOpenPluginManager(); - return; - } - setRemoveTarget(null); - await runMutation(plugin, 'remove', () => deleteEntry(state.userEntry!.id)); - }, [deleteEntry, entries, onOpenPluginManager, registryInfo, removeTarget, runMutation]); - - const setPluginOpen = React.useCallback((pluginId: string, open: boolean) => { - setOpenPluginIds((current) => { - const next = new Set(current); - if (open) next.add(pluginId); - else next.delete(pluginId); - return next; - }); - }, []); - - const renderPlugin = (plugin: ThirdPartyPluginDefinition) => { - const state = getCatalogPluginState(entries, plugin.packageName, registryInfo); - const primaryAction = getCatalogPluginPrimaryAction(state, plugin.packageName); - const latestSpec = getLatestNpmSpec(plugin.packageName, state.registry); - const isPending = pendingAction?.pluginId === plugin.id; - const isRestartRequired = restartRequiredIds.has(plugin.id); - const isProviderUnavailable = providerUnavailableIds.has(plugin.id); - const registryUnavailable = registryLoadFailed || state.registry?.kind === 'npm-network'; - const actionDisabled = isPending - || isRestartRequired - || ((primaryAction === 'install' || primaryAction === 'update') && (registryUnavailable || !latestSpec)); - const presentation = getCatalogPluginPresentation(state, { - registryUnavailable, - restartRequired: isRestartRequired, - providerUnavailable: isProviderUnavailable, - }); - let status: string; - switch (presentation.status) { - case 'installed-version': - status = presentation.latestVersion - ? t('settings.integrations.thirdParty.status.installedVersion', { - version: presentation.latestVersion, - }) - : t('settings.integrations.thirdParty.status.installed'); - break; - case 'update-available': - status = presentation.latestVersion - ? t('settings.integrations.thirdParty.status.updateAvailable', { - version: presentation.latestVersion, - }) - : t('settings.integrations.thirdParty.status.unpinned'); - break; - case 'not-installed': - status = t('settings.integrations.thirdParty.status.notInstalled'); - break; - case 'installed': - status = t('settings.integrations.thirdParty.status.installed'); - break; - case 'unpinned': - status = t('settings.integrations.thirdParty.status.unpinned'); - break; - case 'ambiguous': - status = t('settings.integrations.thirdParty.status.ambiguous'); - break; - case 'restart-required': - status = t('settings.integrations.thirdParty.status.restartRequired'); - break; - case 'registry-unavailable': - status = t('settings.integrations.thirdParty.status.registryUnavailable'); - break; - case 'provider-unavailable': - status = t('settings.integrations.thirdParty.status.providerUnavailable'); - break; - } - const statusClassName = presentation.status === 'installed-version' - ? 'bg-[var(--status-success)]/15 text-[var(--status-success)]' - : presentation.status === 'update-available' - || presentation.status === 'ambiguous' - || presentation.status === 'restart-required' - || presentation.status === 'registry-unavailable' - || presentation.status === 'provider-unavailable' - ? 'bg-[var(--status-warning)]/15 text-[var(--status-warning)]' - : 'bg-[var(--surface-muted)] text-muted-foreground'; - - const primaryLabel = { - install: t('settings.integrations.thirdParty.actions.install'), - update: t('settings.integrations.thirdParty.actions.update'), - setup: t('settings.integrations.thirdParty.actions.setup'), - manage: t('settings.integrations.thirdParty.actions.managePlugins'), - }[primaryAction]; - - const open = openPluginIds.has(plugin.id); - - return ( - setPluginOpen(plugin.id, nextOpen)} - > -
- -
- -
-
-
{t(plugin.nameKey)}
-

- {t(plugin.descriptionKey)} -

-
- - {status} - - -
- -
- {state.projectEntries.length > 0 ? ( -

- {t('settings.integrations.thirdParty.status.projectInstalled')} -

- ) : null} -
- - - {state.userEntry && !state.userEntryIsAmbiguous ? ( - - ) : null} -
-
-
-
-
- ); - }; - - return ( - <> - -
- -

- {t('settings.integrations.experimentalWarning')} -

-
- {THIRD_PARTY_PLUGINS.map(renderPlugin)} -
- - !open && setRemoveTarget(null)}> - - - {t('settings.integrations.thirdParty.dialog.remove.title')} - - {t('settings.integrations.thirdParty.dialog.remove.description', { - name: removeTarget ? t(removeTarget.nameKey) : '', - })} - - - - - - - - - - ); -}; diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts deleted file mode 100644 index 83a9dd65..00000000 --- a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.test.ts +++ /dev/null @@ -1,213 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore'; -import * as thirdPartyCatalog from './thirdPartyPlugins'; -import { - getCatalogPluginState, - getCatalogPluginPrimaryAction, - getLatestNpmSpec, - specMatchesPackage, -} from './thirdPartyPlugins'; - -type CatalogPresentationStatus = - | 'not-installed' - | 'installed' - | 'installed-version' - | 'update-available' - | 'unpinned' - | 'ambiguous' - | 'restart-required' - | 'registry-unavailable' - | 'provider-unavailable'; - -type GetCatalogPluginPresentation = ( - state: ReturnType, - options?: { - registryUnavailable?: boolean; - restartRequired?: boolean; - providerUnavailable?: boolean; - }, -) => { - status: CatalogPresentationStatus; - latestVersion: string | null; -}; - -const getCatalogPluginPresentation = ( - thirdPartyCatalog as unknown as { - getCatalogPluginPresentation?: GetCatalogPluginPresentation; - } -).getCatalogPluginPresentation; - -const claudePackage = '@openchamber/opencode-claude'; - -const entry = (spec: string, scope: PluginEntry['scope'] = 'user'): PluginEntry => ({ - id: `config:${scope}:${spec}`, - spec, - scope, - kind: 'config', - parsedKind: 'npm', -}); - -const registry = (spec: string, currentVersion: string | null, latestVersion = '0.7.0'): RegistryResult => ({ - kind: 'npm-ok', - spec, - name: claudePackage, - currentVersion, - latestVersion, - versions: ['0.6.0', latestVersion], - hasUpdate: currentVersion !== null && currentVersion !== latestVersion, -}); - -describe('third-party plugin catalog helpers', () => { - test('derives compact-card status with explicit transient-state priority', () => { - expect(typeof getCatalogPluginPresentation).toBe('function'); - if (!getCatalogPluginPresentation) return; - - const notInstalled = getCatalogPluginState([], claudePackage, {}); - expect(getCatalogPluginPresentation(notInstalled)).toEqual({ - status: 'not-installed', - latestVersion: null, - }); - - const current = getCatalogPluginState( - [entry(`${claudePackage}@0.7.0`)], - claudePackage, - { [`${claudePackage}@0.7.0`]: registry(`${claudePackage}@0.7.0`, '0.7.0') }, - ); - expect(getCatalogPluginPresentation(current)).toEqual({ - status: 'installed-version', - latestVersion: '0.7.0', - }); - - const outdated = getCatalogPluginState( - [entry(`${claudePackage}@0.6.0`)], - claudePackage, - { [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') }, - ); - expect(getCatalogPluginPresentation(outdated)).toEqual({ - status: 'update-available', - latestVersion: '0.7.0', - }); - expect(getCatalogPluginPresentation(outdated, { registryUnavailable: true })).toEqual({ - status: 'registry-unavailable', - latestVersion: '0.7.0', - }); - expect(getCatalogPluginPresentation(outdated, { providerUnavailable: true })).toEqual({ - status: 'provider-unavailable', - latestVersion: '0.7.0', - }); - expect(getCatalogPluginPresentation(outdated, { - providerUnavailable: true, - restartRequired: true, - })).toEqual({ - status: 'restart-required', - latestVersion: '0.7.0', - }); - - const ambiguous = getCatalogPluginState( - [entry(claudePackage), entry(`${claudePackage}@0.6.0`)], - claudePackage, - {}, - ); - expect(getCatalogPluginPresentation(ambiguous)).toEqual({ - status: 'ambiguous', - latestVersion: null, - }); - }); - - test('matches only a package or its versioned spec', () => { - expect(specMatchesPackage(claudePackage, claudePackage)).toBe(true); - expect(specMatchesPackage(`${claudePackage}@0.6.0`, claudePackage)).toBe(true); - expect(specMatchesPackage('@openchamber/opencode-claude-extra@0.6.0', claudePackage)).toBe(false); - }); - - test('points catalog plugins at the OpenChamber GitHub and npm packages', () => { - expect(thirdPartyCatalog.THIRD_PARTY_PLUGINS.map((plugin) => ({ - id: plugin.id, - packageName: plugin.packageName, - homepage: plugin.homepage, - }))).toEqual([ - { - id: 'opencode-claude', - packageName: '@openchamber/opencode-claude', - homepage: 'https://github.com/openchamber/opencode-claude', - }, - { - id: 'opencode-cursor-oauth', - packageName: '@openchamber/opencode-cursor', - homepage: 'https://github.com/openchamber/opencode-cursor', - }, - ]); - }); - - test('uses the configured user entry and its registry result', () => { - const installed = entry(`${claudePackage}@0.6.0`); - const state = getCatalogPluginState( - [installed], - claudePackage, - { [installed.spec]: registry(installed.spec, '0.6.0') }, - ); - - expect(state.userEntry).toEqual(installed); - expect(state.userEntryIsAmbiguous).toBe(false); - expect(state.projectEntries).toEqual([]); - expect(state.registry).toEqual(registry(installed.spec, '0.6.0')); - }); - - test('does not choose an entry when multiple user specs would make a mutation ambiguous', () => { - const state = getCatalogPluginState( - [entry(claudePackage), entry(`${claudePackage}@0.6.0`), entry(claudePackage, 'project')], - claudePackage, - {}, - ); - - expect(state.userEntry).toBeNull(); - expect(state.userEntryIsAmbiguous).toBe(true); - expect(state.projectEntries).toHaveLength(1); - }); - - test('returns an exact latest spec only from a valid npm registry result', () => { - expect(getLatestNpmSpec(claudePackage, registry(claudePackage, null))).toBe(`${claudePackage}@0.7.0`); - expect(getLatestNpmSpec(claudePackage, { - kind: 'npm-network', - spec: claudePackage, - error: 'offline', - })).toBeNull(); - }); - - test('chooses an update for a bare or outdated user-wide entry', () => { - const bare = getCatalogPluginState( - [entry(claudePackage)], - claudePackage, - { [claudePackage]: registry(claudePackage, null) }, - ); - const outdated = getCatalogPluginState( - [entry(`${claudePackage}@0.6.0`)], - claudePackage, - { [`${claudePackage}@0.6.0`]: registry(`${claudePackage}@0.6.0`, '0.6.0') }, - ); - - expect(getCatalogPluginPrimaryAction(bare, claudePackage)).toBe('update'); - expect(getCatalogPluginPrimaryAction(outdated, claudePackage)).toBe('update'); - }); - - test('keeps setup as the primary action once the exact latest spec is installed', () => { - const installed = entry(`${claudePackage}@0.7.0`); - const state = getCatalogPluginState( - [installed], - claudePackage, - { [installed.spec]: registry(installed.spec, '0.7.0') }, - ); - - expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('setup'); - }); - - test('sends ambiguous entries to manual plugin management', () => { - const state = getCatalogPluginState( - [entry(claudePackage), entry(`${claudePackage}@0.6.0`)], - claudePackage, - {}, - ); - - expect(getCatalogPluginPrimaryAction(state, claudePackage)).toBe('manage'); - }); -}); diff --git a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts b/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts deleted file mode 100644 index 5b3cb4f5..00000000 --- a/packages/ui/src/components/sections/integrations/thirdPartyPlugins.ts +++ /dev/null @@ -1,156 +0,0 @@ -import type { IconName } from '@/components/icon/icons'; -import type { I18nKey } from '@/lib/i18n'; -import type { PluginEntry, RegistryResult } from '@/stores/usePluginsStore'; - -export interface ThirdPartyPluginDefinition { - id: string; - packageName: string; - providerId: string; - icon: IconName; - /** Brand mark tint (e.g. Claude orange); neutral marks use text-foreground. */ - brandClassName: string; - nameKey: I18nKey; - descriptionKey: I18nKey; - homepage: string; -} - -export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [ - { - id: 'opencode-claude', - packageName: '@openchamber/opencode-claude', - providerId: 'claude-code', - icon: 'claude-code', - brandClassName: 'text-[#D97757]', - nameKey: 'settings.integrations.thirdParty.opencodeClaude.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description', - homepage: 'https://github.com/openchamber/opencode-claude', - }, - { - id: 'opencode-cursor-oauth', - packageName: '@openchamber/opencode-cursor', - providerId: 'cursor', - icon: 'cursor', - brandClassName: 'text-foreground', - nameKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description', - homepage: 'https://github.com/openchamber/opencode-cursor', - }, -] as const; - -export interface CatalogPluginState { - userEntry: PluginEntry | null; - userEntryIsAmbiguous: boolean; - projectEntries: PluginEntry[]; - registry: RegistryResult | null; -} - -export type CatalogPluginPrimaryAction = 'install' | 'update' | 'setup' | 'manage'; - -type CatalogPluginPresentationStatus = - | 'not-installed' - | 'installed' - | 'installed-version' - | 'update-available' - | 'unpinned' - | 'ambiguous' - | 'restart-required' - | 'registry-unavailable' - | 'provider-unavailable'; - -interface CatalogPluginPresentationOptions { - registryUnavailable?: boolean; - restartRequired?: boolean; - providerUnavailable?: boolean; -} - -interface CatalogPluginPresentation { - status: CatalogPluginPresentationStatus; - latestVersion: string | null; -} - -export const specMatchesPackage = (spec: string, packageName: string): boolean => - spec === packageName || spec.startsWith(`${packageName}@`); - -export function getCatalogPluginState( - entries: PluginEntry[], - packageName: string, - registryInfo: Record, -): CatalogPluginState { - const matchingEntries = entries.filter((entry) => specMatchesPackage(entry.spec, packageName)); - const userEntries = matchingEntries.filter((entry) => entry.scope === 'user'); - const projectEntries = matchingEntries.filter((entry) => entry.scope === 'project'); - const userEntry = userEntries.length === 1 ? userEntries[0] : null; - const registry = registryInfo[userEntry?.spec ?? packageName] ?? registryInfo[packageName] ?? null; - - return { - userEntry, - userEntryIsAmbiguous: userEntries.length > 1, - projectEntries, - registry, - }; -} - -export function getLatestNpmSpec( - packageName: string, - registry: RegistryResult | null | undefined, -): string | null { - if (registry?.kind !== 'npm-ok' || registry.name !== packageName || !registry.latestVersion) { - return null; - } - return `${packageName}@${registry.latestVersion}`; -} - -export function getCatalogPluginPrimaryAction( - state: CatalogPluginState, - packageName: string, -): CatalogPluginPrimaryAction { - if (state.userEntryIsAmbiguous) { - return 'manage'; - } - - if (!state.userEntry) { - return 'install'; - } - - const latestSpec = getLatestNpmSpec(packageName, state.registry); - return latestSpec && latestSpec !== state.userEntry.spec ? 'update' : 'setup'; -} - -/** - * Converts catalog and temporary mutation state into the one compact-card - * status. Transient states intentionally outrank installed/version metadata. - */ -export function getCatalogPluginPresentation( - state: CatalogPluginState, - options: CatalogPluginPresentationOptions = {}, -): CatalogPluginPresentation { - const latestVersion = state.registry?.kind === 'npm-ok' - ? state.registry.latestVersion - : null; - - if (state.userEntryIsAmbiguous) { - return { status: 'ambiguous', latestVersion }; - } - if (options.restartRequired) { - return { status: 'restart-required', latestVersion }; - } - if (options.providerUnavailable) { - return { status: 'provider-unavailable', latestVersion }; - } - if (options.registryUnavailable) { - return { status: 'registry-unavailable', latestVersion }; - } - if (!state.userEntry) { - return { status: 'not-installed', latestVersion }; - } - if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === state.registry.latestVersion) { - return { status: 'installed-version', latestVersion }; - } - if (state.registry?.kind === 'npm-ok' && state.registry.currentVersion === null) { - return { status: 'unpinned', latestVersion }; - } - if (state.registry?.kind === 'npm-ok' && latestVersion) { - return { status: 'update-available', latestVersion }; - } - return { status: 'installed', latestVersion }; -} diff --git a/packages/ui/src/components/session/ForkSessionDialog.tsx b/packages/ui/src/components/session/ForkSessionDialog.tsx index b16ab0da..b15a4dab 100644 --- a/packages/ui/src/components/session/ForkSessionDialog.tsx +++ b/packages/ui/src/components/session/ForkSessionDialog.tsx @@ -17,6 +17,9 @@ import { isPrimaryMode } from '@/components/chat/mobileControlsUtils'; import { EXECUTION_FORK_DEFAULT_INSTRUCTIONS, EXECUTION_FORK_GOAL_INSTRUCTIONS } from '@/lib/messages/executionMeta'; import { useI18n } from '@/lib/i18n'; import { isVSCodeRuntime } from '@/lib/desktop'; +import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; +import { useGitStore, useIsGitRepo } from '@/stores/useGitStore'; +import { useSessionUIStore } from '@/sync/session-ui-store'; export type ForkSessionExecution = { providerID: string; @@ -32,13 +35,22 @@ type ForkSessionDialogProps = { open: boolean; onOpenChange: (open: boolean) => void; projectDirectory: string | null; + sourceSessionId: string | null; + worktreeProjectDirectory: string | null; submitting?: boolean; onConfirm: (execution: ForkSessionExecution) => Promise | void; }; export function ForkSessionDialog(props: ForkSessionDialogProps) { const { t } = useI18n(); - const { open, onOpenChange, projectDirectory, submitting = false, onConfirm } = props; + const { open, onOpenChange, projectDirectory, sourceSessionId, worktreeProjectDirectory, submitting = false, onConfirm } = props; + const metadataProjectDirectory = useSessionUIStore((state) => ( + sourceSessionId ? state.worktreeMetadata.get(sourceSessionId)?.projectDirectory ?? null : null + )); + const resolvedWorktreeProjectDirectory = metadataProjectDirectory ?? worktreeProjectDirectory; + const git = useRuntimeAPIs().git; + const isGitRepository = useIsGitRepo(resolvedWorktreeProjectDirectory); + const ensureGitStatus = useGitStore((state) => state.ensureStatus); const loadProviders = useConfigStore((state) => state.loadProviders); const loadConfigAgents = useConfigStore((state) => state.loadAgents); @@ -56,7 +68,7 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { const [instructions, setInstructions] = React.useState(EXECUTION_FORK_DEFAULT_INSTRUCTIONS); const [createWorktree, setCreateWorktree] = React.useState(false); const [runAsGoal, setRunAsGoal] = React.useState(false); - const showCreateWorktree = React.useMemo(() => !isVSCodeRuntime(), []); + const showCreateWorktree = !isVSCodeRuntime() && isGitRepository === true; // The goal loop lives in the web server; VS Code only renders goal state. const showRunAsGoal = React.useMemo(() => !isVSCodeRuntime(), []); @@ -79,6 +91,11 @@ export function ForkSessionDialog(props: ForkSessionDialogProps) { void loadAgentsStoreAgents(); }, [open, loadProviders, loadConfigAgents, loadAgentsStoreAgents, projectDirectory]); + React.useEffect(() => { + if (!open || !resolvedWorktreeProjectDirectory || !git) return; + void ensureGitStatus(resolvedWorktreeProjectDirectory, git); + }, [ensureGitStatus, git, open, resolvedWorktreeProjectDirectory]); + // Reset only when the dialog transitions to open. Reading the store snapshot // here (instead of subscribing) avoids clobbering in-progress user edits when // the config store refreshes in the background while the dialog is open. diff --git a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx index 966ff6ea..03bf5a3a 100644 --- a/packages/ui/src/components/session/GitHubIntegrationDialog.tsx +++ b/packages/ui/src/components/session/GitHubIntegrationDialog.tsx @@ -29,14 +29,14 @@ import { useI18n } from '@/lib/i18n'; type GitHubTab = 'issues' | 'prs'; +export type GitHubWorktreeSelection = + | { type: 'issue'; item: GitHubIssue; includeDiff?: boolean } + | { type: 'pr'; item: GitHubPullRequestSummary; includeDiff?: boolean }; + interface GitHubIntegrationDialogProps { open: boolean; onOpenChange: (open: boolean) => void; - onSelect: (result: { - type: 'issue' | 'pr'; - item: GitHubIssue | GitHubPullRequestSummary; - includeDiff?: boolean; - } | null) => void; + onSelect: (result: GitHubWorktreeSelection | null) => void; } interface ValidationResult { diff --git a/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx b/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx new file mode 100644 index 00000000..a638032b --- /dev/null +++ b/packages/ui/src/components/session/NewWorktreeDialog.behavior.test.tsx @@ -0,0 +1,215 @@ +import React, { act } from 'react'; +import { describe, expect, mock, test } from 'bun:test'; +import { createRoot } from 'react-dom/client'; +import { Window } from 'happy-dom'; +import { create } from 'zustand'; + +type GitHubSelection = { + type: 'issue'; + item: { number: number; title: string }; +}; + +type WorktreeState = { + availableWorktreesByProject: Map>; +}; + +const project = { id: 'project-a', path: '/workspace/project-a' }; +const useWorktreeStore = create(() => ({ + availableWorktreesByProject: new Map(), +})); +let selectGitHubItem: ((selection: GitHubSelection) => void) | null = null; + +const projectStoreState = { getActiveProject: () => project }; +const githubAuthState = { status: { connected: true }, hasChecked: true }; +const linearAuthState = { status: null, hasChecked: true }; +const uiState = { isMobile: false }; +const gitState = { fetchBranches: async () => undefined }; + +const selectProjectState = (selector: (state: typeof projectStoreState) => T): T => selector(projectStoreState); +const selectGitHubAuthState = (selector: (state: typeof githubAuthState) => T): T => selector(githubAuthState); +const selectLinearAuthState = (selector: (state: typeof linearAuthState) => T): T => selector(linearAuthState); +const selectUIState = (selector: (state: typeof uiState) => T): T => selector(uiState); +const selectGitState = (selector: (state: typeof gitState) => T): T => selector(gitState); + +const passthrough = ({ children }: React.PropsWithChildren) =>
{children}
; + +mock.module('@/components/ui/dialog', () => ({ + Dialog: ({ children, open }: React.PropsWithChildren<{ open: boolean }>) => open ? <>{children} : null, + DialogContent: passthrough, + DialogHeader: passthrough, + DialogTitle: passthrough, + DialogFooter: passthrough, +})); + +mock.module('@/components/ui/input', () => ({ + Input: (props: React.InputHTMLAttributes) => , +})); + +mock.module('@/components/ui/button', () => ({ + Button: ({ children, ...props }: React.ButtonHTMLAttributes) => ( + + ), +})); + +mock.module('@/components/ui', () => ({ + toast: { error: () => undefined, success: () => undefined }, +})); + +mock.module('@/components/ui/dropdown-menu', () => ({ + DropdownMenu: passthrough, + DropdownMenuContent: passthrough, + DropdownMenuTrigger: passthrough, +})); + +mock.module('@/components/ui/command', () => ({ + Command: passthrough, + CommandEmpty: passthrough, + CommandGroup: passthrough, + CommandInput: () => null, + CommandItem: passthrough, + CommandList: passthrough, + CommandSeparator: () => null, +})); + +mock.module('@/components/ui/sortable-tabs-strip', () => ({ SortableTabsStrip: () => null })); +mock.module('@/components/ui/MobileOverlayPanel', () => ({ MobileOverlayPanel: passthrough })); +mock.module('@/components/icon/Icon', () => ({ Icon: () => null })); +mock.module('@/components/ui/dropdown-trigger', () => ({ dropdownTriggerVariants: () => '' })); +mock.module('@/lib/utils', () => ({ cn: (...values: Array) => values.filter(Boolean).join(' ') })); + +mock.module('@/stores/useProjectsStore', () => ({ + useProjectsStore: selectProjectState, +})); +mock.module('@/stores/useGitHubAuthStore', () => ({ + useGitHubAuthStore: selectGitHubAuthState, +})); +mock.module('@/stores/useLinearAuthStore', () => ({ + useLinearAuthStore: selectLinearAuthState, +})); +mock.module('@/stores/useUIStore', () => ({ + useUIStore: selectUIState, +})); +mock.module('@/sync/session-ui-store', () => ({ + materializeOpenDraftSession: async () => null, + useSessionUIStore: useWorktreeStore, +})); +mock.module('@/sync/session-actions', () => ({ + createSession: async () => null, + updateSessionTitle: async () => undefined, +})); +mock.module('@/hooks/useRuntimeAPIs', () => ({ + useRuntimeAPIs: () => ({ github: {}, git: null, linear: null }), +})); +mock.module('@/stores/useGitStore', () => ({ + useGitBranches: () => ({ all: ['main'] }), + useGitLoadingBranches: () => false, + useGitStore: selectGitState, +})); +mock.module('@/lib/worktrees/worktreeManager', () => ({ + validateWorktreeCreate: async () => ({ ok: true, errors: [] }), +})); +mock.module('@/lib/worktrees/worktreeCreate', () => ({ createWorktreeWithDefaults: async () => null })); +mock.module('@/lib/worktrees/worktreeBootstrap', () => ({ waitForWorktreeBootstrap: async () => undefined })); +mock.module('@/lib/openchamberConfig', () => ({ + getWorktreeSetupCommands: async () => [], + getWorktreeSetupWaitEnabled: async () => false, +})); +mock.module('@/lib/worktrees/worktreeStatus', () => ({ getRootBranch: async () => 'main' })); +mock.module('@/lib/git/branchNameGenerator', () => ({ generateBranchSlug: () => 'draft-name' })); + +mock.module('./GitHubIntegrationDialog', () => ({ + GitHubIntegrationDialog: ({ onSelect }: { onSelect: (selection: GitHubSelection) => void }) => { + selectGitHubItem = onSelect; + return null; + }, +})); +mock.module('./LinearIssuePickerDialog', () => ({ LinearIssuePickerDialog: () => null })); + +const { NewWorktreeDialog } = await import('./NewWorktreeDialog'); +const { I18nProvider } = await import('@/lib/i18n'); + +const DOM_GLOBAL_NAMES = [ + 'window', + 'document', + 'navigator', + 'Node', + 'Element', + 'HTMLElement', + 'HTMLIFrameElement', + 'localStorage', + 'IS_REACT_ACT_ENVIRONMENT', +] as const; + +const installDom = () => { + const happyWindow = new Window({ url: 'http://localhost' }); + const previous = DOM_GLOBAL_NAMES.map( + (name) => [name, Object.getOwnPropertyDescriptor(globalThis, name)] as const, + ); + const values = { + window: happyWindow, + document: happyWindow.document, + navigator: happyWindow.navigator, + Node: happyWindow.Node, + Element: happyWindow.Element, + HTMLElement: happyWindow.HTMLElement, + HTMLIFrameElement: happyWindow.HTMLIFrameElement, + localStorage: happyWindow.localStorage, + IS_REACT_ACT_ENVIRONMENT: true, + }; + for (const name of DOM_GLOBAL_NAMES) { + Object.defineProperty(globalThis, name, { value: values[name], configurable: true, writable: true }); + } + + const container = document.createElement('div'); + document.body.appendChild(container); + return { + container, + restore: () => { + for (const [name, descriptor] of previous) { + if (descriptor) Object.defineProperty(globalThis, name, descriptor); + else Reflect.deleteProperty(globalThis, name); + } + }, + }; +}; + +describe('NewWorktreeDialog behavior', () => { + test('preserves selected issue values when available worktree names change', async () => { + const dom = installDom(); + const root = createRoot(dom.container); + useWorktreeStore.setState({ availableWorktreesByProject: new Map() }); + + try { + await act(async () => root.render( + + undefined} /> + , + )); + if (!selectGitHubItem) throw new Error('Expected GitHub selection handler'); + + await act(async () => selectGitHubItem?.({ + type: 'issue', + item: { number: 42, title: 'Keep the selected issue' }, + })); + + const [branchInput, worktreeInput] = dom.container.querySelectorAll('input'); + expect(branchInput?.value).toBe('issue-42-draft-name'); + expect(worktreeInput?.value).toBe('issue-42-draft-name'); + expect(dom.container.textContent).toContain('Keep the selected issue'); + + await act(async () => useWorktreeStore.setState({ + availableWorktreesByProject: new Map([ + [project.path, [{ name: 'newly-created-worktree' }]], + ]), + })); + + expect(branchInput?.value).toBe('issue-42-draft-name'); + expect(worktreeInput?.value).toBe('issue-42-draft-name'); + expect(dom.container.textContent).toContain('Keep the selected issue'); + } finally { + await act(async () => root.unmount()); + selectGitHubItem = null; + dom.restore(); + } + }); +}); diff --git a/packages/ui/src/components/session/NewWorktreeDialog.tsx b/packages/ui/src/components/session/NewWorktreeDialog.tsx index f427e827..425e9281 100644 --- a/packages/ui/src/components/session/NewWorktreeDialog.tsx +++ b/packages/ui/src/components/session/NewWorktreeDialog.tsx @@ -51,7 +51,7 @@ import { } from '@/lib/worktrees/worktreeSourceBranchPreference'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; import { useGitBranches, useGitStore, useGitLoadingBranches } from '@/stores/useGitStore'; -import { GitHubIntegrationDialog } from './GitHubIntegrationDialog'; +import { GitHubIntegrationDialog, type GitHubWorktreeSelection } from './GitHubIntegrationDialog'; import { LinearIssuePickerDialog } from './LinearIssuePickerDialog'; import { SortableTabsStrip } from '@/components/ui/sortable-tabs-strip'; import { MobileOverlayPanel } from '@/components/ui/MobileOverlayPanel'; @@ -65,7 +65,7 @@ import type { LinearIssue, LinearIssueComment, } from '@/lib/api/types'; -import type { ProjectRef } from '@/lib/worktrees/worktreeManager'; +import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; import { useI18n } from '@/lib/i18n'; type Mode = 'new-branch' | 'existing-branch'; @@ -456,6 +456,7 @@ export function NewWorktreeDialog({ // Creation state const [isCreating, setIsCreating] = React.useState(false); const [validationAbortController, setValidationAbortController] = React.useState(null); + const initializedForCurrentOpen = React.useRef(false); const resolveDefaultAgentName = React.useCallback((): string | undefined => { const configState = useConfigStore.getState(); @@ -493,9 +494,7 @@ export function NewWorktreeDialog({ : undefined; const provider = configState.providers.find((p) => p.id === providerID); - const model = provider?.models.find((m: Record) => (m as { id?: string }).id === modelID) as - | { variants?: Record } - | undefined; + const model = provider?.models.find((m) => m.id === modelID); const variants = model?.variants; if (!variants) return settingsDefaultVariant || currentVariant || undefined; if (settingsDefaultVariant && Object.prototype.hasOwnProperty.call(variants, settingsDefaultVariant)) return settingsDefaultVariant; @@ -763,7 +762,12 @@ export function NewWorktreeDialog({ // Reset state on each open. Resetting on close would empty the form during // the close animation, causing visible flicker. React.useLayoutEffect(() => { - if (!open) return; + if (!open) { + initializedForCurrentOpen.current = false; + return; + } + if (initializedForCurrentOpen.current) return; + initializedForCurrentOpen.current = true; setMode('new-branch'); setExistingBranchState({ @@ -840,14 +844,15 @@ export function NewWorktreeDialog({ if (normalizedBranch && normalizedWorktree) { const linkedPr = mode === 'new-branch' ? newBranchState.linkedPr : null; const prConfig = linkedPr ? resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches) : null; - const result = await validateWorktreeCreate(projectRef, { + const validateArgs: CreateWorktreeArgs = { mode: mode === 'existing-branch' || prConfig ? 'existing' : 'new', branchName: normalizedBranch, worktreeName: normalizedWorktree, existingBranch: prConfig?.existingBranch ?? (mode === 'existing-branch' ? normalizedBranch : undefined), - ...(prConfig?.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), - ...(prConfig?.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), - }); + }; + if (prConfig?.ensureRemoteName) validateArgs.ensureRemoteName = prConfig.ensureRemoteName; + if (prConfig?.ensureRemoteUrl) validateArgs.ensureRemoteUrl = prConfig.ensureRemoteUrl; + const result = await validateWorktreeCreate(projectRef, validateArgs); if (abortController.signal.aborted) return; @@ -961,13 +966,13 @@ export function NewWorktreeDialog({ const sourceBranch = newBranchState.sourceBranch; let sourceLabel = ''; - const args = (() => { + const args: CreateWorktreeArgs = (() => { if (linkedPr) { const prConfig = resolvePrWorktreeConfig(linkedPr, localBranches, remoteBranches); sourceLabel = prConfig.sourceLabel; - return { + const prArgs: CreateWorktreeArgs = { preferredName: normalizedBranch || normalizedWorktree, - mode: 'existing' as const, + mode: 'existing', branchName: normalizedBranch, worktreeName: normalizedWorktree, existingBranch: prConfig.existingBranch, @@ -976,22 +981,24 @@ export function NewWorktreeDialog({ upstreamRemote: prConfig.upstreamRemote, upstreamBranch: prConfig.upstreamBranch, returnAfterDirectoryCreated: true, - ...(prConfig.ensureRemoteName ? { ensureRemoteName: prConfig.ensureRemoteName } : {}), - ...(prConfig.ensureRemoteUrl ? { ensureRemoteUrl: prConfig.ensureRemoteUrl } : {}), }; + if (prConfig.ensureRemoteName) prArgs.ensureRemoteName = prConfig.ensureRemoteName; + if (prConfig.ensureRemoteUrl) prArgs.ensureRemoteUrl = prConfig.ensureRemoteUrl; + return prArgs; } sourceLabel = mode === 'new-branch' ? sourceBranch : ''; - return { + const baseArgs: CreateWorktreeArgs = { preferredName: normalizedBranch || normalizedWorktree, - mode: mode === 'existing-branch' ? 'existing' as const : 'new' as const, + mode: mode === 'existing-branch' ? 'existing' : 'new', branchName: mode === 'existing-branch' ? undefined : normalizedBranch, worktreeName: normalizedWorktree, existingBranch: mode === 'existing-branch' ? normalizedBranch : undefined, setupCommands, returnAfterDirectoryCreated: true, - ...(sourceBranch && mode === 'new-branch' ? { startRef: sourceBranch } : {}), }; + if (sourceBranch && mode === 'new-branch') baseArgs.startRef = sourceBranch; + return baseArgs; })(); const metadata = await createWorktreeWithDefaults(projectRef, args); @@ -1084,11 +1091,7 @@ export function NewWorktreeDialog({ }; // Handle GitHub selection - const handleGitHubSelect = (result: { - type: 'issue' | 'pr'; - item: GitHubIssue | GitHubPullRequestSummary; - includeDiff?: boolean; - } | null) => { + const handleGitHubSelect = (result: GitHubWorktreeSelection | null) => { if (!result) { setNewBranchState(prev => ({ ...prev, @@ -1102,7 +1105,7 @@ export function NewWorktreeDialog({ } if (result.type === 'issue') { - const issue = result.item as GitHubIssue; + const issue = result.item; const newBranchName = `issue-${issue.number}-${generateBranchSlug()}`; setNewBranchState(prev => ({ ...prev, @@ -1115,7 +1118,7 @@ export function NewWorktreeDialog({ isSyncingWorktreeName: true, })); } else if (result.type === 'pr') { - const pr = result.item as GitHubPullRequestSummary; + const pr = result.item; setNewBranchState(prev => ({ ...prev, linkedPr: pr, @@ -1261,7 +1264,11 @@ export function NewWorktreeDialog({ { id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: }, ]} activeId={mode} - onSelect={(id) => handleModeChange(id as Mode)} + onSelect={(id) => { + if (id === 'new-branch' || id === 'existing-branch') { + handleModeChange(id); + } + }} variant="active-pill" layoutMode="fit" className="w-full" @@ -1767,7 +1774,11 @@ export function NewWorktreeDialog({ { id: 'existing-branch', label: t('session.newWorktree.mode.existingBranch'), icon: }, ]} activeId={mode} - onSelect={(id) => handleModeChange(id as Mode)} + onSelect={(id) => { + if (id === 'new-branch' || id === 'existing-branch') { + handleModeChange(id); + } + }} variant="active-pill" layoutMode="fit" className="w-full" diff --git a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx index 1ff494a6..2d4e1200 100644 --- a/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx +++ b/packages/ui/src/components/session/sidebar/sessions/SessionNodeItem.tsx @@ -347,6 +347,8 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode const renameDraftRef = React.useRef(renameDraft); renameDraftRef.current = renameDraft; const renameTargetRef = React.useRef(null); + const pendingRenameSelectRef = React.useRef(false); + const renameInputRef = React.useRef(null); const formRef = React.useRef(null); const session = node.session; @@ -633,9 +635,24 @@ function SessionNodeItemComponent(props: SessionNodeItemProps): React.ReactNode } if (renameTargetRef.current === session.id) return; renameTargetRef.current = session.id; + pendingRenameSelectRef.current = true; setRenameDraft(editTitle); }, [editingId, editTitle, session.id]); + // Entering rename mode selects the whole title, so the first keystroke + // replaces it instead of appending to it. The selection waits for the commit + // that actually renders `editTitle`: the draft state above is seeded when the + // row mounts, so on a session whose title changed since then the input still + // holds the old text during the commit that opens the form. + React.useLayoutEffect(() => { + if (editingId !== session.id || !pendingRenameSelectRef.current) return; + const input = renameInputRef.current; + if (!input || input.value !== editTitle) return; + pendingRenameSelectRef.current = false; + input.focus(); + input.select(); + }, [editingId, editTitle, renameDraft, session.id]); + if (editingId === session.id) { return (
setRenameDraft(event.target.value)} className="flex-1 min-w-0 bg-transparent typography-ui-label outline-none placeholder:text-muted-foreground" diff --git a/packages/ui/src/components/views/GitView.tsx b/packages/ui/src/components/views/GitView.tsx index 7ff07e05..d5ba4cb2 100644 --- a/packages/ui/src/components/views/GitView.tsx +++ b/packages/ui/src/components/views/GitView.tsx @@ -20,6 +20,7 @@ import { useGitLoadingLog, } from '@/stores/useGitStore'; import { useNestedGitDirectory } from '@/hooks/useNestedGitDirectory'; +import { useWorktreeBootstrapPending } from '@/hooks/useWorktreeBootstrapPending'; import { NestedRepoResolutionStates } from './git/NestedRepoResolutionStates'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { ScrollShadow } from '@/components/ui/ScrollShadow'; @@ -203,8 +204,14 @@ export const GitView: React.FC = ({ isActive }) => { const { t } = useI18n(); const { git } = useRuntimeAPIs(); const currentDirectory = useEffectiveDirectory(); - const [worktreeBootstrapStatus, setWorktreeBootstrapStatus] = React.useState<'pending' | 'ready' | 'failed' | null>(null); - const [isWaitingForGitRefreshAfterBootstrap, setIsWaitingForGitRefreshAfterBootstrap] = React.useState(false); + const [worktreeBootstrapSnapshot, setWorktreeBootstrapSnapshot] = React.useState<{ + directory: string; + status: 'pending' | 'ready' | 'failed' | null; + } | null>(null); + const [postBootstrapRefresh, setPostBootstrapRefresh] = React.useState<{ + directory: string; + status: 'refreshing' | 'failed'; + } | null>(null); const currentSessionId = useSessionUIStore((s) => s.currentSessionId); const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft); const setDraftBootstrapPendingDirectory = useSessionUIStore((s) => s.setDraftBootstrapPendingDirectory); @@ -285,7 +292,6 @@ export const GitView: React.FC = ({ isActive }) => { const isLogLoading = useGitLoadingLog(gitDirectory ?? null); const { setActiveDirectory, - fetchAll, ensureAll, fetchStatus, fetchBranches, @@ -301,7 +307,6 @@ export const GitView: React.FC = ({ isActive }) => { selectNestedRepo, } = useGitStore(useShallow((state) => ({ setActiveDirectory: state.setActiveDirectory, - fetchAll: state.fetchAll, ensureAll: state.ensureAll, fetchStatus: state.fetchStatus, fetchBranches: state.fetchBranches, @@ -329,7 +334,6 @@ export const GitView: React.FC = ({ isActive }) => { }); const navigateToDiff = useUIStore((state) => state.navigateToDiff); - const previousBootstrapStatusRef = React.useRef<'pending' | 'ready' | 'failed' | null>(null); const gitReconcileTimeoutRef = React.useRef(null); const gitMutationFlushTimeoutRef = React.useRef(null); const flushQueuedGitMutationsRef = React.useRef<(() => void) | null>(null); @@ -438,11 +442,13 @@ export const GitView: React.FC = ({ isActive }) => { React.useEffect(() => { if (!isActive) return; if (!currentDirectory) { - setWorktreeBootstrapStatus(null); - setIsWaitingForGitRefreshAfterBootstrap(false); + setWorktreeBootstrapSnapshot(null); return; } + const bootstrapDirectory = normalizePath(currentDirectory) ?? currentDirectory; + setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null }); + let cancelled = false; let timeoutId: number | null = null; @@ -452,7 +458,7 @@ export const GitView: React.FC = ({ isActive }) => { if (cancelled) { return; } - setWorktreeBootstrapStatus(next.status); + setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: next.status }); if (next.status === 'pending') { timeoutId = window.setTimeout(() => { void poll(); @@ -460,7 +466,7 @@ export const GitView: React.FC = ({ isActive }) => { } } catch { if (!cancelled) { - setWorktreeBootstrapStatus(null); + setWorktreeBootstrapSnapshot({ directory: bootstrapDirectory, status: null }); } } }; @@ -475,37 +481,84 @@ export const GitView: React.FC = ({ isActive }) => { }; }, [isActive, currentDirectory]); - React.useEffect(() => { - const previous = previousBootstrapStatusRef.current; - previousBootstrapStatusRef.current = worktreeBootstrapStatus; - - if (!currentDirectory || !git) { - return; - } - - if (previous === 'pending' && worktreeBootstrapStatus === 'ready') { - setIsWaitingForGitRefreshAfterBootstrap(true); - void fetchAll(currentDirectory, git).finally(() => { - window.setTimeout(() => { - setIsWaitingForGitRefreshAfterBootstrap(false); - }, 1200); - }); - } - - if (worktreeBootstrapStatus === 'failed') { - setDraftBootstrapPendingDirectory(null); - setIsWaitingForGitRefreshAfterBootstrap(false); - } - }, [currentDirectory, fetchAll, git, setDraftBootstrapPendingDirectory, worktreeBootstrapStatus]); - const normalizedDraftBootstrapPendingDirectory = normalizePath(newSessionDraft?.bootstrapPendingDirectory ?? null); const isDraftBootstrapPendingForCurrentDirectory = Boolean( currentDirectory && normalizedDraftBootstrapPendingDirectory && normalizedDraftBootstrapPendingDirectory === normalizePath(currentDirectory) ); + const sharedWorktreeBootstrapPending = useWorktreeBootstrapPending(currentDirectory ?? null); + const normalizedCurrentBootstrapDirectory = normalizePath(currentDirectory); + const observedWorktreeBootstrapStatus = worktreeBootstrapSnapshot?.directory === normalizedCurrentBootstrapDirectory + ? worktreeBootstrapSnapshot.status + : null; const isPendingWorktreeSetup = Boolean( - currentDirectory && (worktreeBootstrapStatus === 'pending' || isDraftBootstrapPendingForCurrentDirectory) + currentDirectory + && ( + sharedWorktreeBootstrapPending + || observedWorktreeBootstrapStatus === 'pending' + || (isDraftBootstrapPendingForCurrentDirectory && newSessionDraft?.pendingWorktreeRequestId) + ) ); - const shouldHideNotGitState = isPendingWorktreeSetup || isWaitingForGitRefreshAfterBootstrap; + const isPostBootstrapRefreshForCurrentDirectory = Boolean( + normalizedCurrentBootstrapDirectory + && postBootstrapRefresh?.directory === normalizedCurrentBootstrapDirectory + ); + + React.useEffect(() => { + if (!normalizedCurrentBootstrapDirectory) return; + + if (observedWorktreeBootstrapStatus === 'failed') { + setDraftBootstrapPendingDirectory(null); + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory ? null : current + )); + return; + } + + if (isPendingWorktreeSetup) { + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory && current.status === 'refreshing' + ? current + : { directory: normalizedCurrentBootstrapDirectory, status: 'refreshing' } + )); + return; + } + + if ( + postBootstrapRefresh?.directory !== normalizedCurrentBootstrapDirectory + || postBootstrapRefresh.status !== 'refreshing' + || !gitDirectory + || !git + ) { + return; + } + + let cancelled = false; + void fetchStatus(gitDirectory, git, { + force: true, + silent: true, + throwOnError: true, + }).then(() => { + if (cancelled) return; + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory ? null : current + )); + }).catch(() => { + if (cancelled) return; + setPostBootstrapRefresh((current) => ( + current?.directory === normalizedCurrentBootstrapDirectory + ? { ...current, status: 'failed' } + : current + )); + }); + + return () => { + cancelled = true; + }; + }, [fetchStatus, git, gitDirectory, isPendingWorktreeSetup, normalizedCurrentBootstrapDirectory, observedWorktreeBootstrapStatus, postBootstrapRefresh, setDraftBootstrapPendingDirectory]); + + const shouldHideGitState = isPendingWorktreeSetup || isPostBootstrapRefreshForCurrentDirectory; + const postBootstrapRefreshFailed = isPostBootstrapRefreshForCurrentDirectory + && postBootstrapRefresh?.status === 'failed'; const initialSnapshot = React.useMemo(() => { if (!gitDirectory) return null; @@ -2336,6 +2389,42 @@ export const GitView: React.FC = ({ isActive }) => { ); } + if (shouldHideGitState) { + return ( +
+ {!postBootstrapRefreshFailed ? ( + + ) : null} +

+ {postBootstrapRefreshFailed + ? t('gitView.toast.refreshRepositoryFailed') + : t('gitView.empty.worktreeSetupInProgress')} +

+ {!postBootstrapRefreshFailed ? ( +

+ {t('gitView.empty.worktreeSetupDescription')} +

+ ) : ( + + )} +
+ ); + } + if (isGitRepo === null || (isGitRepo === true && !status)) { return (
@@ -2348,20 +2437,6 @@ export const GitView: React.FC = ({ isActive }) => { } if (isGitRepo === false) { - if (shouldHideNotGitState) { - return ( -
- -

- {t('gitView.empty.worktreeSetupInProgress')} -

-

- {t('gitView.empty.worktreeSetupDescription')} -

-
- ); - } - // Nested repository discovery states (discovering, failed, unsupported, // none found, or settling on the auto-selected repository). return ( diff --git a/packages/ui/src/components/views/SettingsView.tsx b/packages/ui/src/components/views/SettingsView.tsx index f5995a77..40872210 100644 --- a/packages/ui/src/components/views/SettingsView.tsx +++ b/packages/ui/src/components/views/SettingsView.tsx @@ -310,24 +310,6 @@ export const SettingsView: React.FC = ({ onClose, forceMobile setMobileStage(def.kind === 'split' ? 'page-sidebar' : 'page-content'); }, [isMobile, setSettingsPage]); - const openThirdPartyProviderSetup = React.useCallback(async (providerId: string): Promise => { - const configStore = useConfigStore.getState(); - await configStore.loadProviders({ source: 'settings:third-party-provider-setup' }); - const providerAvailable = useConfigStore.getState().providers.some( - (provider) => provider.id === providerId, - ); - if (!providerAvailable) { - return false; - } - - configStore.setSelectedProvider(providerId); - openPage('providers'); - if (isMobile) { - setMobileStage('page-content'); - } - return true; - }, [isMobile, openPage]); - const activePageMeta = React.useMemo(() => { return getSettingsPageMeta(settingsSlug); }, [settingsSlug]); @@ -679,12 +661,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile case 'git': return ; case 'integrations': - return ( - openPage('plugins')} - /> - ); + return ; case 'general': case 'appearance': case 'chat': @@ -700,7 +677,7 @@ export const SettingsView: React.FC = ({ onClose, forceMobile default: return null; } - }, [openChamberSectionBySlug, openPage, openThirdPartyProviderSetup, renderUnavailable, runtimeCtx, t]); + }, [openChamberSectionBySlug, renderUnavailable, runtimeCtx, t]); // Mobile: if opened via deep-link / palette to a non-home page, jump into it once. React.useEffect(() => { diff --git a/packages/ui/src/hooks/useMessageQueueHoldSync.ts b/packages/ui/src/hooks/useMessageQueueHoldSync.ts new file mode 100644 index 00000000..fa6834be --- /dev/null +++ b/packages/ui/src/hooks/useMessageQueueHoldSync.ts @@ -0,0 +1,64 @@ +import React from 'react'; +import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; +import { isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore'; +import { getRuntimeKey } from '@/lib/runtime-switch'; + +// Server holds expire on their own (the UI that asserted them may be gone); +// re-assert well inside that window while a run is still going. +const REASSERT_INTERVAL_MS = 2 * 60 * 1000; + +/** + * Auto-review is driven from the UI: it forwards the implementer's answer to + * the reviewer and back while the original session bounces through idle. The + * queue owner must not deliver into those gaps, so while a run is going the + * server is told to hold that session's queue, and released when it ends. + */ +export function useMessageQueueHoldSync(): void { + const runs = useAutoReviewStore((state) => state.runsByOriginalSessionID); + const heldRef = React.useRef>(new Set()); + + const running = React.useMemo(() => { + if (!isServerOwnedMessageQueue()) return new Set(); + const runtimeKey = getRuntimeKey(); + return new Set( + Object.values(runs) + .filter((run) => run.status === 'running' && run.runtimeKey === runtimeKey) + .map((run) => run.originalSessionID), + ); + }, [runs]); + + React.useEffect(() => { + const setHold = (sessionId: string, held: boolean) => { + useMessageQueueStore.getState().setServerHold(sessionId, held).catch((error) => { + console.warn(`[queue] failed to ${held ? 'hold' : 'release'} the queue for ${sessionId}:`, error); + }); + }; + + for (const sessionId of heldRef.current) { + if (!running.has(sessionId)) { + heldRef.current.delete(sessionId); + setHold(sessionId, false); + } + } + for (const sessionId of running) { + if (!heldRef.current.has(sessionId)) { + heldRef.current.add(sessionId); + setHold(sessionId, true); + } + } + + if (running.size === 0) return; + const interval = setInterval(() => { + for (const sessionId of running) setHold(sessionId, true); + }, REASSERT_INTERVAL_MS); + return () => clearInterval(interval); + }, [running]); + + React.useEffect(() => () => { + const held = heldRef.current; + for (const sessionId of held) { + void useMessageQueueStore.getState().setServerHold(sessionId, false).catch(() => undefined); + } + held.clear(); + }, []); +} diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts index c6438e36..20e9a961 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.test.ts @@ -186,11 +186,13 @@ describe('buildQueuedAutoSendPayload', () => { { id: 'queued-1', content: 'first queued message', + text: 'first queued message', createdAt: 1, }, { id: 'queued-2', content: 'second queued message', + text: 'second queued message', createdAt: 2, }, ]; @@ -203,20 +205,18 @@ describe('buildQueuedAutoSendPayload', () => { expect(payload?.primaryAttachments).toEqual([]); }); - test('uses the configured visible agents when parsing queued mentions', () => { - visibleAgents = [ - { - name: 'Builder', - mode: 'subagent', - permission: [], - options: {}, - } as Agent, - ]; - + test('delivers the captured mention and context instead of re-parsing the content', () => { + const metadata = { openchamberContext: { kind: 'github-issue' as const, number: 3, title: 'Bug', url: 'https://x/issues/3' } }; const queue: QueuedMessage[] = [ { id: 'queued-mention', content: '@Builder please take this', + text: 'please take this', + agentMention: 'Builder', + context: [ + { kind: 'context', text: 'issue body', metadata }, + { kind: 'instruction', text: 'use the skill' }, + ], createdAt: 1, }, ]; @@ -225,7 +225,11 @@ describe('buildQueuedAutoSendPayload', () => { expect(payload).not.toBeNull(); expect(payload?.agentMentionName).toBe('Builder'); - expect(payload?.primaryText).toBe('@Builder please take this'); + expect(payload?.primaryText).toBe('please take this'); + expect(payload?.additionalParts).toEqual([ + { text: 'issue body', synthetic: true, metadata }, + { text: 'use the skill', synthetic: true }, + ]); }); test('preserves attachment-only queued messages as sendable payloads', () => { @@ -233,6 +237,7 @@ describe('buildQueuedAutoSendPayload', () => { { id: 'queued-attachments', content: '', + text: '', createdAt: 1, attachments: [ { @@ -249,6 +254,7 @@ describe('buildQueuedAutoSendPayload', () => { { id: 'queued-2', content: 'later queued message', + text: 'later queued message', createdAt: 2, }, ]; @@ -267,6 +273,7 @@ describe('buildQueuedAutoSendPayload', () => { { id: 'queued-1', content: 'queued message', + text: 'queued message', createdAt: 1, }, ]); diff --git a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts index 87bee797..281178f9 100644 --- a/packages/ui/src/hooks/useQueuedMessageAutoSend.ts +++ b/packages/ui/src/hooks/useQueuedMessageAutoSend.ts @@ -5,7 +5,7 @@ import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useContextStore } from '@/stores/contextStore'; import { useAutoReviewStore } from '@/stores/useAutoReviewStore'; -import { parseAgentMentions } from '@/lib/messages/agentMentions'; +import { queuedContextToParts } from '@/components/chat/composer/submit/buildOutgoingMessage'; import { getDirectoryState } from '@/sync/sync-refs'; import { useDirectorySync } from '@/sync/sync-context'; import { getRuntimeKey } from '@/lib/runtime-switch'; @@ -82,14 +82,14 @@ export const buildQueuedAutoSendPayload = (queue: QueuedMessage[]) => { return null; } - const agents = useConfigStore.getState().getVisibleAgents(); - const { sanitizedText, mention } = parseAgentMentions(queued.content, agents); - + // A queued message is delivered as captured: mention already stripped, + // file mentions resolved, and the context it was queued with following it. return { queuedMessageId: queued.id, - primaryText: sanitizedText, + primaryText: queued.text, primaryAttachments: queued.attachments ?? [], - agentMentionName: mention?.name, + agentMentionName: queued.agentMention, + additionalParts: queuedContextToParts(queued.context ?? []), sendConfig: queued.sendConfig, }; }; @@ -114,7 +114,7 @@ export const sendQueuedAutoSendPayload = ( resolved.agent, payload.primaryAttachments, payload.agentMentionName, - undefined, + payload.additionalParts.length > 0 ? payload.additionalParts : undefined, resolved.variant, 'normal', { target }, @@ -148,11 +148,18 @@ const resolveSessionSendConfig = (sessionId: string) => { ?? config.currentModelId ?? selection.lastUsedProvider?.modelID; - const variant = + // A recorded `null` is an explicit "Default": it stops the lookup and sends + // no effort, instead of falling through to the persisted copy. + const savedVariant = selectedAgent && providerID && modelID - ? (selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID) - ?? context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID)) + ? (() => { + const live = selection.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID); + return live !== undefined + ? live + : context.getAgentModelVariantForSession(sessionId, selectedAgent, providerID, modelID); + })() : undefined; + const variant = savedVariant ?? undefined; return { providerID, diff --git a/packages/ui/src/hooks/useWorktreeBootstrapPending.ts b/packages/ui/src/hooks/useWorktreeBootstrapPending.ts new file mode 100644 index 00000000..1108d84d --- /dev/null +++ b/packages/ui/src/hooks/useWorktreeBootstrapPending.ts @@ -0,0 +1,18 @@ +import React from 'react'; + +import { getWorktreeBootstrapState, subscribeWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap'; + +/** + * Whether `directory` is a worktree whose creation has not finished yet: the + * directory exists, but setup commands and the initial git reset are still + * running. Until that completes the working tree transiently looks dirty, so + * surfaces that report uncommitted changes consult this and show nothing + * instead of presenting bootstrap noise as real changes on the branch. + */ +export const useWorktreeBootstrapPending = (directory: string | null): boolean => { + const getSnapshot = React.useCallback( + () => (directory ? getWorktreeBootstrapState(directory)?.status === 'pending' : false), + [directory], + ); + return React.useSyncExternalStore(subscribeWorktreeBootstrapState, getSnapshot, getSnapshot); +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 4cceb29f..5578727d 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -495,7 +495,7 @@ interface GitWorktreeAPI { export interface GitAPI { checkIsGitRepository(directory: string): Promise; - getGitStatus(directory: string, options?: { mode?: 'light' }): Promise; + getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise; getGitDiff(directory: string, options: GetGitDiffOptions): Promise; getGitFileDiff(directory: string, options: GetGitFileDiffOptions): Promise; getGitRangeDiff?(directory: string, options: GetGitRangeDiffOptions): Promise; diff --git a/packages/ui/src/lib/gitApi.ts b/packages/ui/src/lib/gitApi.ts index 1d0e325a..1522154f 100644 --- a/packages/ui/src/lib/gitApi.ts +++ b/packages/ui/src/lib/gitApi.ts @@ -84,7 +84,7 @@ export async function checkIsGitRepository(directory: string): Promise return gitHttp.checkIsGitRepository(directory); } -export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { +export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise { const runtime = getRuntimeGit(); if (runtime) return runtime.getGitStatus(directory, options); return gitHttp.getGitStatus(directory, options); diff --git a/packages/ui/src/lib/gitApiHttp.test.ts b/packages/ui/src/lib/gitApiHttp.test.ts index bc2e2c49..1067481d 100644 --- a/packages/ui/src/lib/gitApiHttp.test.ts +++ b/packages/ui/src/lib/gitApiHttp.test.ts @@ -29,6 +29,7 @@ import { unstageGitFiles, } from './gitApiHttp'; import type { GitStatus } from './api/types'; +import { sessionEvents } from './sessionEvents'; type FetchCall = { input: RequestInfo | URL; @@ -141,6 +142,28 @@ describe('gitApiHttp index mutations', () => { }); describe('gitApiHttp status cache', () => { + test('a Git refresh hint invalidates the cached status before listeners fetch', async () => { + installWindowMock(); + let statusRequestCount = 0; + globalThis.fetch = async () => { + statusRequestCount += 1; + return jsonResponse(statusPayload({ behind: statusRequestCount })); + }; + + try { + const directory = '/repo-cache-tool-mutation'; + const first = await getGitStatus(directory); + sessionEvents.requestGitRefresh({ directory }); + const afterMutation = await getGitStatus(directory); + + expect(first.behind).toBe(1); + expect(afterMutation.behind).toBe(2); + expect(statusRequestCount).toBe(2); + } finally { + restoreMocks(); + } + }); + test('invalidates cached status after fetch', async () => { installWindowMock(); const calls: FetchCall[] = []; @@ -188,6 +211,58 @@ describe('gitApiHttp status cache', () => { restoreMocks(); } }); + + test('fresh status bypasses an unexpired cached snapshot', async () => { + installWindowMock(); + let statusRequestCount = 0; + globalThis.fetch = (async () => { + statusRequestCount += 1; + return jsonResponse(statusPayload({ behind: statusRequestCount })); + }) as typeof fetch; + + try { + const directory = '/repo-cache-fresh'; + const first = await getGitStatus(directory); + const cached = await getGitStatus(directory); + const fresh = await getGitStatus(directory, { fresh: true }); + + expect(first.behind).toBe(1); + expect(cached.behind).toBe(1); + expect(fresh.behind).toBe(2); + expect(statusRequestCount).toBe(2); + } finally { + restoreMocks(); + } + }); + + test('fresh status cannot be replaced in cache by an older in-flight response', async () => { + installWindowMock(); + const statusResolvers: Array<(response: Response) => void> = []; + // SAFETY: the mock accepts the same arguments as fetch and always returns + // a pending Response promise controlled by this test. + globalThis.fetch = (async () => new Promise((resolve) => { + statusResolvers.push(resolve); + })) as typeof fetch; + + try { + const directory = '/repo-cache-fresh-race'; + const older = getGitStatus(directory); + await new Promise((resolve) => setTimeout(resolve, 0)); + const fresh = getGitStatus(directory, { fresh: true }); + await new Promise((resolve) => setTimeout(resolve, 0)); + + expect(statusResolvers).toHaveLength(2); + statusResolvers[1](jsonResponse(statusPayload({ current: 'fresh' }))); + statusResolvers[0](jsonResponse(statusPayload({ current: 'stale' }))); + + expect((await fresh).current).toBe('fresh'); + expect((await older).current).toBe('stale'); + expect((await getGitStatus(directory)).current).toBe('fresh'); + expect(statusResolvers).toHaveLength(2); + } finally { + restoreMocks(); + } + }); }); const statusPayload = (overrides: Partial = {}): GitStatus => ({ diff --git a/packages/ui/src/lib/gitApiHttp.ts b/packages/ui/src/lib/gitApiHttp.ts index 197eb7cf..7eda4da4 100644 --- a/packages/ui/src/lib/gitApiHttp.ts +++ b/packages/ui/src/lib/gitApiHttp.ts @@ -40,7 +40,7 @@ import { normalizePath } from './pathNormalization'; import { runtimeFetch } from './runtime-fetch'; import { getRuntimeUrlResolver } from './runtime-url'; import { getRuntimeKey } from './runtime-switch'; -import { notifyGitStatusInvalidated } from './gitStatusInvalidation'; +import { notifyGitStatusInvalidated, subscribeGitStatusInvalidations } from './gitStatusInvalidation'; const API_BASE = '/api/git'; const GIT_STATUS_CACHE_TTL_MS = 1200; @@ -60,8 +60,7 @@ const getStatusCacheKey = (runtimeKey: string, directory: string, mode?: 'light' const getStatusCacheVersion = (runtimeKey: string, directory: string): number => gitStatusCacheVersions.get(getDirectoryCacheKey(runtimeKey, directory)) ?? 0; -const invalidateGitStatusCache = (directory: string): void => { - const runtimeKey = getRuntimeKey(); +const clearGitStatusCache = (runtimeKey: string, directory: string): void => { const key = getDirectoryCacheKey(runtimeKey, directory); gitStatusCacheVersions.set(key, getStatusCacheVersion(runtimeKey, directory) + 1); for (const mode of [undefined, 'light'] as const) { @@ -69,6 +68,13 @@ const invalidateGitStatusCache = (directory: string): void => { gitStatusCache.delete(statusKey); gitStatusInFlight.delete(statusKey); } +}; + +subscribeGitStatusInvalidations((directory) => { + clearGitStatusCache(getRuntimeKey(), directory); +}); + +const invalidateGitStatusCache = (directory: string): void => { notifyGitStatusInvalidated(directory); }; @@ -162,9 +168,14 @@ export async function listGitDirectories(root: string): Promise { .filter((path): path is string => path !== null); } -export async function getGitStatus(directory: string, options?: { mode?: 'light' }): Promise { +export async function getGitStatus(directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise { const mode = options?.mode; const runtimeKey = getRuntimeKey(); + if (options?.fresh) { + // A forced read must cross the transport cache boundary too. Advancing the + // version also prevents an older in-flight response from repopulating it. + clearGitStatusCache(runtimeKey, directory); + } const key = getStatusCacheKey(runtimeKey, directory, mode); const now = Date.now(); const cached = gitStatusCache.get(key); diff --git a/packages/ui/src/lib/gitStatusInvalidation.ts b/packages/ui/src/lib/gitStatusInvalidation.ts index 9e337365..effc8254 100644 --- a/packages/ui/src/lib/gitStatusInvalidation.ts +++ b/packages/ui/src/lib/gitStatusInvalidation.ts @@ -1,18 +1,18 @@ /** * Minimal notification channel for git status invalidation. * - * Every successful status-affecting git mutation must call + * Every confirmed status-affecting mutation must call * `notifyGitStatusInvalidated`. `useGitStore` subscribes and bumps its * per-directory status mutation revision so an immediate refresh cannot join an * in-flight status request admitted before the mutation, and a stale response - * cannot commit over newer authoritative state. + * cannot commit over newer authoritative state. The HTTP adapter also subscribes + * and clears its short-lived status cache. * * Runtime parity: this is about the store's in-flight status request, not about - * adapter caching, so it applies to every runtime. The HTTP adapter in - * `gitApiHttp.ts` emits it where it clears its own cache; runtime adapters (the - * VS Code bridge) have no cache of their own, so the dispatch layer in - * `gitApi.ts` emits it for them after a successful runtime mutation. Either - * path announces a mutation exactly once. + * adapter caching, so it applies to every runtime. HTTP mutations emit from + * `gitApiHttp.ts`; runtime adapters such as the VS Code bridge emit from the + * dispatch layer in `gitApi.ts`. Tool and editor mutations emit through the + * shared Git refresh hint. Each path announces a mutation exactly once. */ type GitStatusInvalidationListener = (directory: string) => void; diff --git a/packages/ui/src/lib/i18n/messages/de.settings.ts b/packages/ui/src/lib/i18n/messages/de.settings.ts index 8e7ad3c5..7f28f8a2 100644 --- a/packages/ui/src/lib/i18n/messages/de.settings.ts +++ b/packages/ui/src/lib/i18n/messages/de.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go Nutzungsverfolgung', 'settings.providers.page.openCodeGo.description': 'Verbinden Sie das OpenCode Go Dashboard, um rollierenden, wöchentlichen und monatlichen Verbrauch anzuzeigen.', @@ -2225,5 +2224,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.themeMode.dark.description': 'Immer dunkles Erscheinungsbild verwenden', 'chat.message.userText.collapseAria': 'Benutzernachricht einklappen', ...linearIntegrationI18n.de, - ...thirdPartyIntegrationI18n.de, + 'settings.page.integrations.title': 'Integrationen', + 'settings.page.integrations.description': 'Verbinde GitHub und Linear, damit OpenChamber mit deinen Issues und Pull Requests arbeiten kann.', }; diff --git a/packages/ui/src/lib/i18n/messages/de.ts b/packages/ui/src/lib/i18n/messages/de.ts index 3036942f..779333c2 100644 --- a/packages/ui/src/lib/i18n/messages/de.ts +++ b/packages/ui/src/lib/i18n/messages/de.ts @@ -1444,7 +1444,7 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Überprüfungssitzung', 'chat.autoReview.actions.open': 'Öffnen', 'chat.autoReview.actions.stop': 'Stoppen', - 'chat.draftDirtyNotice.tooltip': 'Dieser Branch hat nicht committete Dateien.\nDie neue Session sieht sie. Ein Commit oder ein Worktree hält sie getrennt.', + 'chat.draftDirtyNotice.tooltip': 'Du hast nicht committete Änderungen in diesem Branch.\nEine neue Session könnte mit ihnen in Konflikt geraten. Vielleicht möchtest du sie zuerst committen oder einen anderen Worktree verwenden.', 'chat.draftDirtyNotice.indicatorAria': 'Nicht committete Änderungen in diesem Verzeichnis', 'diffView.hunk.label': 'Stücke', 'diffView.hunk.stage': 'Zu Staging hinzufügen', @@ -2003,6 +2003,8 @@ export const dict = { 'chat.queuedMessage.send': 'senden', 'chat.queuedMessage.removeAria': 'Aus der Warteschlange entfernen', 'chat.queuedMessage.reorderAria': 'Ziehen, um neu anzuordnen', + 'chat.queuedMessage.toast.queueFailed': 'Die Nachricht konnte nicht in die Warteschlange gestellt werden. Sie ist wieder im Eingabefeld.', + 'chat.queuedMessage.toast.takeFailed': 'Die Nachricht aus der Warteschlange konnte nicht geladen werden. Bitte erneut versuchen.', 'chat.container.returnToParent.aria': 'Zurück zur übergeordneten Sitzung', 'chat.container.returnToParent.titleNamed': 'Zurück zu: {title}', 'chat.container.returnToParent.title': 'Zurück zur übergeordneten Sitzung', diff --git a/packages/ui/src/lib/i18n/messages/en.settings.ts b/packages/ui/src/lib/i18n/messages/en.settings.ts index 4b44ad31..0826a92d 100644 --- a/packages/ui/src/lib/i18n/messages/en.settings.ts +++ b/packages/ui/src/lib/i18n/messages/en.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go usage tracking', 'settings.providers.page.openCodeGo.description': 'Connect the OpenCode Go dashboard to show rolling, weekly, and monthly quota.', @@ -2224,5 +2223,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', ...linearIntegrationI18n.en, - ...thirdPartyIntegrationI18n.en, + 'settings.page.integrations.title': 'Integrations', + 'settings.page.integrations.description': 'Connect GitHub and Linear so OpenChamber can work with your issues and pull requests.', } as const; diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index af1d6167..e4d7358b 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1641,7 +1641,7 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Review session', 'chat.autoReview.actions.open': 'Open', 'chat.autoReview.actions.stop': 'Stop', - 'chat.draftDirtyNotice.tooltip': 'This branch has uncommitted files.\nThe new session will see them. A commit or a worktree keeps them separate.', + 'chat.draftDirtyNotice.tooltip': 'You have uncommitted changes on this branch.\nA new session could conflict with them. You may want to commit them first or use another worktree.', 'chat.draftDirtyNotice.indicatorAria': 'Uncommitted changes in this directory', 'diffView.hunk.label': 'Hunks', 'diffView.hunk.stage': 'Stage', @@ -2208,6 +2208,8 @@ export const dict = { 'chat.queuedMessage.send': 'send', 'chat.queuedMessage.removeAria': 'Remove from queue', 'chat.queuedMessage.reorderAria': 'Drag to reorder', + 'chat.queuedMessage.toast.queueFailed': 'Couldn\'t queue the message. It\'s back in the composer.', + 'chat.queuedMessage.toast.takeFailed': 'Couldn\'t load the queued message. Please try again.', 'chat.container.returnToParent.aria': 'Return to parent session', 'chat.container.returnToParent.titleNamed': 'Return to: {title}', 'chat.container.returnToParent.title': 'Return to parent session', diff --git a/packages/ui/src/lib/i18n/messages/es.settings.ts b/packages/ui/src/lib/i18n/messages/es.settings.ts index 2b656990..53119648 100644 --- a/packages/ui/src/lib/i18n/messages/es.settings.ts +++ b/packages/ui/src/lib/i18n/messages/es.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Seguimiento de uso de OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Conecta el panel de OpenCode Go para ver las cuotas móvil, semanal y mensual.', @@ -2234,5 +2233,6 @@ export const settingsDict = { "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", ...linearIntegrationI18n.es, - ...thirdPartyIntegrationI18n.es, + 'settings.page.integrations.title': 'Integraciones', + 'settings.page.integrations.description': 'Conecta GitHub y Linear para que OpenChamber pueda trabajar con tus issues y pull requests.', } as const; diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index c1ca296f..7ad25b17 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1619,7 +1619,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sesión de revisión', 'chat.autoReview.actions.open': 'Abrir', 'chat.autoReview.actions.stop': 'Detener', - 'chat.draftDirtyNotice.tooltip': 'Esta rama tiene archivos sin confirmar.\nLa nueva sesión los verá. Un commit o un worktree los mantiene separados.', + 'chat.draftDirtyNotice.tooltip': 'Tienes cambios sin confirmar en esta rama.\nUna nueva sesión podría entrar en conflicto con ellos. Quizás quieras confirmarlos primero o usar otro worktree.', 'chat.draftDirtyNotice.indicatorAria': 'Cambios sin confirmar en este directorio', "diffView.hunk.label": "Fragmentos", "diffView.hunk.stage": "Preparar", @@ -2186,6 +2186,8 @@ export const dict: Record = { "chat.queuedMessage.send": "send", "chat.queuedMessage.removeAria": "Eliminar de la cola", "chat.queuedMessage.reorderAria": "Arrastra para reordenar", + "chat.queuedMessage.toast.queueFailed": "No se pudo poner el mensaje en cola. Vuelve a estar en el editor.", + "chat.queuedMessage.toast.takeFailed": "No se pudo cargar el mensaje en cola. Inténtalo de nuevo.", "chat.container.returnToParent.aria": "Volver a la sesión principal", "chat.container.returnToParent.titleNamed": "Volver a: {title}", "chat.container.returnToParent.title": "Volver a la sesión principal", diff --git a/packages/ui/src/lib/i18n/messages/fr.settings.ts b/packages/ui/src/lib/i18n/messages/fr.settings.ts index 0fe60bea..92a65608 100644 --- a/packages/ui/src/lib/i18n/messages/fr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/fr.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Suivi de l’utilisation d’OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Connectez le tableau de bord OpenCode Go pour afficher les quotas glissant, hebdomadaire et mensuel.', @@ -2234,5 +2233,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', ...linearIntegrationI18n.fr, - ...thirdPartyIntegrationI18n.fr, + 'settings.page.integrations.title': 'Intégrations', + 'settings.page.integrations.description': 'Connectez GitHub et Linear pour qu’OpenChamber puisse travailler avec vos issues et pull requests.', } as const; diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 2804fdeb..2dd731ec 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1405,7 +1405,7 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'Session de revue', 'chat.autoReview.actions.open': 'Ouvrir', 'chat.autoReview.actions.stop': 'Arrêter', - 'chat.draftDirtyNotice.tooltip': 'Cette branche a des fichiers non commités.\nLa nouvelle session les verra. Un commit ou un worktree les garde séparés.', + 'chat.draftDirtyNotice.tooltip': 'Vous avez des modifications non commitées sur cette branche.\nUne nouvelle session pourrait entrer en conflit avec elles. Vous voudrez peut-être les commiter d’abord ou utiliser un autre worktree.', 'chat.draftDirtyNotice.indicatorAria': 'Modifications non commitées dans ce répertoire', 'diffView.hunk.label': 'Sections', 'diffView.hunk.stage': 'Préparer', @@ -1938,6 +1938,8 @@ export const dict = { 'chat.queuedMessage.send': 'envoyer', 'chat.queuedMessage.removeAria': 'Supprimer de la file d\'attente', 'chat.queuedMessage.reorderAria': 'Glisser pour réorganiser', + 'chat.queuedMessage.toast.queueFailed': 'Impossible de mettre le message en file d\'attente. Il est de retour dans l\'éditeur.', + 'chat.queuedMessage.toast.takeFailed': 'Impossible de charger le message en file d\'attente. Veuillez réessayer.', 'chat.container.returnToParent.aria': 'Retour à la session parents', 'chat.container.returnToParent.titleNamed': 'Retourner à : {title}', 'chat.container.returnToParent.title': 'Retour à la session parents', diff --git a/packages/ui/src/lib/i18n/messages/ja.settings.ts b/packages/ui/src/lib/i18n/messages/ja.settings.ts index 729551a6..3fe754ed 100644 --- a/packages/ui/src/lib/i18n/messages/ja.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ja.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 使用量追跡', 'settings.providers.page.openCodeGo.description': 'OpenCode Go ダッシュボードを接続して、ローリング、週間、月間のクォータを表示します。', @@ -2234,5 +2233,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'ステア', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'キュー', ...linearIntegrationI18n.ja, - ...thirdPartyIntegrationI18n.ja, + 'settings.page.integrations.title': '連携', + 'settings.page.integrations.description': 'GitHub と Linear を接続すると、OpenChamber が Issue やプルリクエストを扱えるようになります。', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index bbf1c0a9..787e1e69 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1646,7 +1646,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'レビューセッション', 'chat.autoReview.actions.open': '開く', 'chat.autoReview.actions.stop': '停止', - 'chat.draftDirtyNotice.tooltip': 'このブランチには未コミットのファイルがあります。\n新しいセッションからも見えます。コミットまたはワークツリーで分けられます。', + 'chat.draftDirtyNotice.tooltip': 'このブランチにはコミットされていない変更があります。\n新しいセッションの変更と競合する可能性があります。先にコミットするか、別のワークツリーを使うとよいかもしれません。', 'chat.draftDirtyNotice.indicatorAria': 'このディレクトリに未コミットの変更があります', 'rightSidebar.contextNotesTodo.plan.defaultTitle': '計画', 'rightSidebar.contextNotesTodo.empty.selectProject': 'プロジェクトを選択してメモとTODOを追加します。', @@ -2204,6 +2204,8 @@ export const dict: Record = { 'chat.queuedMessage.send': '送信', 'chat.queuedMessage.removeAria': 'キューから削除', 'chat.queuedMessage.reorderAria': 'ドラッグして並び替え', + 'chat.queuedMessage.toast.queueFailed': 'メッセージをキューに追加できませんでした。入力欄に戻しました。', + 'chat.queuedMessage.toast.takeFailed': 'キューのメッセージを読み込めませんでした。もう一度お試しください。', 'chat.container.returnToParent.aria': '親セッションに戻る', 'chat.container.returnToParent.titleNamed': '戻る: {title}', 'chat.container.returnToParent.title': '親セッションに戻る', diff --git a/packages/ui/src/lib/i18n/messages/ko.settings.ts b/packages/ui/src/lib/i18n/messages/ko.settings.ts index 48d6aa8b..15816c07 100644 --- a/packages/ui/src/lib/i18n/messages/ko.settings.ts +++ b/packages/ui/src/lib/i18n/messages/ko.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 사용량 추적', 'settings.providers.page.openCodeGo.description': 'OpenCode Go 대시보드를 연결하여 롤링, 주간 및 월간 할당량을 표시합니다.', @@ -2234,5 +2233,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', ...linearIntegrationI18n.ko, - ...thirdPartyIntegrationI18n.ko, + 'settings.page.integrations.title': '통합', + 'settings.page.integrations.description': 'GitHub와 Linear를 연결하면 OpenChamber가 이슈와 풀 리퀘스트를 다룰 수 있습니다.', } as const; diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 64d3ddad..5b23cef5 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1643,7 +1643,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '리뷰 세션', 'chat.autoReview.actions.open': '열기', 'chat.autoReview.actions.stop': '중지', - 'chat.draftDirtyNotice.tooltip': '이 브랜치에는 커밋되지 않은 파일이 있습니다.\n새 세션에서도 보입니다. 커밋 또는 워크트리로 분리할 수 있습니다.', + 'chat.draftDirtyNotice.tooltip': '이 브랜치에 커밋되지 않은 변경 사항이 있습니다.\n새 세션의 변경 사항과 충돌할 수 있습니다. 먼저 커밋하거나 다른 워크트리를 사용하는 것이 좋습니다.', 'chat.draftDirtyNotice.indicatorAria': '이 디렉터리에 커밋되지 않은 변경 사항이 있습니다', 'diffView.hunk.label': '허크', 'diffView.hunk.stage': '스테이지', @@ -2210,6 +2210,8 @@ export const dict: Record = { 'chat.queuedMessage.send': 'send', 'chat.queuedMessage.removeAria': '큐에서 제거', 'chat.queuedMessage.reorderAria': '드래그하여 순서 변경', + 'chat.queuedMessage.toast.queueFailed': '메시지를 대기열에 추가하지 못했습니다. 입력창으로 되돌렸습니다.', + 'chat.queuedMessage.toast.takeFailed': '대기열의 메시지를 불러오지 못했습니다. 다시 시도해 주세요.', 'chat.container.returnToParent.aria': '상위 세션으로 돌아가기', 'chat.container.returnToParent.titleNamed': '돌아가기: {title}', 'chat.container.returnToParent.title': '상위 세션으로 돌아가기', diff --git a/packages/ui/src/lib/i18n/messages/pl.settings.ts b/packages/ui/src/lib/i18n/messages/pl.settings.ts index 9d89d843..b82cc3f8 100644 --- a/packages/ui/src/lib/i18n/messages/pl.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pl.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Śledzenie użycia OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Połącz panel OpenCode Go, aby wyświetlać limity kroczące, tygodniowe i miesięczne.', @@ -2227,5 +2226,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', ...linearIntegrationI18n.pl, - ...thirdPartyIntegrationI18n.pl, + 'settings.page.integrations.title': 'Integracje', + 'settings.page.integrations.description': 'Połącz GitHub i Linear, aby OpenChamber mógł pracować z Twoimi issue i pull requestami.', }; diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 2b030572..561545f7 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -863,6 +863,8 @@ export const dict: Record = { 'chat.queuedMessage.send': 'send', 'chat.queuedMessage.removeAria': 'Usuń z kolejki', 'chat.queuedMessage.reorderAria': 'Przeciągnij, aby zmienić kolejność', + 'chat.queuedMessage.toast.queueFailed': 'Nie udało się dodać wiadomości do kolejki. Wróciła do pola wpisywania.', + 'chat.queuedMessage.toast.takeFailed': 'Nie udało się wczytać wiadomości z kolejki. Spróbuj ponownie.', 'chat.container.returnToParent.aria': 'Powrót do sesji nadrzędnej', 'chat.container.returnToParent.titleNamed': 'Powrót do: {title}', 'chat.container.returnToParent.title': 'Powrót do sesji nadrzędnej', @@ -1845,7 +1847,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sesja review', 'chat.autoReview.actions.open': 'Otwórz', 'chat.autoReview.actions.stop': 'Zatrzymaj', - 'chat.draftDirtyNotice.tooltip': 'Ta gałąź ma niezacommitowane pliki.\nNowa sesja będzie je widzieć. Commit albo worktree trzyma je osobno.', + 'chat.draftDirtyNotice.tooltip': 'Masz niezacommitowane zmiany w tej gałęzi.\nNowa sesja może z nimi kolidować. Być może warto najpierw je zacommitować albo użyć innego worktree.', 'chat.draftDirtyNotice.indicatorAria': 'Niezacommitowane zmiany w tym katalogu', 'diffView.hunk.label': 'Fragmenty', 'diffView.hunk.stage': 'Przygotuj', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts index 9261f8ca..60d618e8 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Monitoramento de uso do OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Conecte o painel do OpenCode Go para exibir as cotas móvel, semanal e mensal.', @@ -2234,5 +2233,6 @@ export const settingsDict = { "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", ...linearIntegrationI18n['pt-BR'], - ...thirdPartyIntegrationI18n['pt-BR'], + 'settings.page.integrations.title': 'Integrações', + 'settings.page.integrations.description': 'Conecte o GitHub e o Linear para que o OpenChamber possa trabalhar com suas issues e pull requests.', } as const; diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 0636e87e..773a9f7e 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1619,7 +1619,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Sessão de revisão', 'chat.autoReview.actions.open': 'Abrir', 'chat.autoReview.actions.stop': 'Parar', - 'chat.draftDirtyNotice.tooltip': 'Esta branch tem arquivos sem commit.\nA nova sessão os verá. Um commit ou um worktree os mantém separados.', + 'chat.draftDirtyNotice.tooltip': 'Você tem alterações sem commit nesta branch.\nUma nova sessão pode entrar em conflito com elas. Talvez seja melhor fazer commit delas primeiro ou usar outro worktree.', 'chat.draftDirtyNotice.indicatorAria': 'Alterações sem commit neste diretório', "diffView.hunk.label": "Trechos", "diffView.hunk.stage": "Preparar", @@ -2186,6 +2186,8 @@ export const dict: Record = { "chat.queuedMessage.send": "send", "chat.queuedMessage.removeAria": "Excluir da fila", "chat.queuedMessage.reorderAria": "Arraste para reordenar", + "chat.queuedMessage.toast.queueFailed": "Não foi possível enfileirar a mensagem. Ela voltou para o campo de texto.", + "chat.queuedMessage.toast.takeFailed": "Não foi possível carregar a mensagem da fila. Tente novamente.", "chat.container.returnToParent.aria": "Voltar para a sessão principal", "chat.container.returnToParent.titleNamed": "Voltar para: {title}", "chat.container.returnToParent.title": "Voltar para a sessão principal", diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts deleted file mode 100644 index d7890ea3..00000000 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.test.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { describe, expect, test } from 'bun:test'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; - -const locales = ['en', 'de', 'fr', 'es', 'ja', 'pt-BR', 'uk', 'ko', 'pl', 'zh-CN', 'zh-TW', 'tr'] as const; - -const requiredKeys = [ - 'settings.page.integrations.title', - 'settings.page.integrations.description', - 'settings.integrations.experimentalWarning', - 'settings.integrations.thirdParty.title', - 'settings.integrations.thirdParty.actions.install', - 'settings.integrations.thirdParty.actions.update', - 'settings.integrations.thirdParty.actions.setup', - 'settings.integrations.thirdParty.actions.remove', - 'settings.integrations.thirdParty.status.notInstalled', - 'settings.integrations.thirdParty.opencodeClaude.description', - 'settings.integrations.thirdParty.opencodeCursorOauth.description', -] as const; - -describe('third-party integration translations', () => { - test('provides every required key in every supported locale', () => { - for (const locale of locales) { - for (const key of requiredKeys) { - expect(thirdPartyIntegrationI18n[locale][key]).toBeTruthy(); - } - } - }); -}); diff --git a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts b/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts deleted file mode 100644 index 41f381ee..00000000 --- a/packages/ui/src/lib/i18n/messages/third-party-integrations.i18n.ts +++ /dev/null @@ -1,423 +0,0 @@ -/** Third-party integration settings strings — merged into each locale's settings dictionary. */ -export const thirdPartyIntegrationI18n = { - en: { - 'settings.page.integrations.title': 'Integrations', - 'settings.page.integrations.description': 'Add third-party subscriptions to use as OpenChamber providers.', - 'settings.integrations.experimentalWarning': 'Experimental feature. We aim to respect provider policies, but account restrictions and suspensions remain each provider\'s decision. Use integrations at your own risk.', - 'settings.integrations.thirdParty.title': 'Third-party integrations', - 'settings.integrations.thirdParty.info': 'Install a provider plugin, then set up your subscription so OpenChamber can use it.', - 'settings.integrations.thirdParty.actions.install': 'Install', - 'settings.integrations.thirdParty.actions.update': 'Update', - 'settings.integrations.thirdParty.actions.setup': 'Set up', - 'settings.integrations.thirdParty.actions.remove': 'Remove', - 'settings.integrations.thirdParty.actions.docs': 'Docs', - 'settings.integrations.thirdParty.actions.managePlugins': 'Manage plugins', - 'settings.integrations.thirdParty.status.notInstalled': 'Not installed', - 'settings.integrations.thirdParty.status.installed': 'Installed', - 'settings.integrations.thirdParty.status.installedVersion': 'Installed {version}', - 'settings.integrations.thirdParty.status.updateAvailable': 'Update available: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Following the latest release', - 'settings.integrations.thirdParty.status.projectInstalled': 'Also configured for this project', - 'settings.integrations.thirdParty.status.ambiguous': 'Multiple user-wide plugin entries need manual management', - 'settings.integrations.thirdParty.status.restartRequired': 'Restart OpenCode before setting up this provider.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'Could not check npm right now.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'The provider is not available yet. Restart OpenCode and try again.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Remove integration', - 'settings.integrations.thirdParty.dialog.remove.description': 'Remove {name} from your user-wide OpenCode configuration? The provider will no longer load after OpenCode refreshes.', - 'settings.integrations.thirdParty.toast.installed': '{name} installed', - 'settings.integrations.thirdParty.toast.updated': '{name} updated', - 'settings.integrations.thirdParty.toast.removed': '{name} removed', - 'settings.integrations.thirdParty.toast.actionFailed': 'Could not update the integration', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'The provider could not be opened yet', - 'settings.integrations.thirdParty.toast.restartRequired': 'Restart OpenCode for changes to take effect', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Use your Claude Pro/Max plan — no API keys, no Claude apps.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor’s generous in-house model limits, now in OpenChamber.', - }, - de: { - 'settings.page.integrations.title': 'Integrationen', - 'settings.page.integrations.description': 'Füge Drittanbieter-Abonnements hinzu, um sie als OpenChamber-Provider zu nutzen.', - 'settings.integrations.experimentalWarning': 'Experimentelle Funktion. Wir bemühen uns, die Richtlinien der Anbieter zu respektieren, aber Kontobeschränkungen und Sperrungen liegen bei jedem Anbieter. Nutze Integrationen auf eigenes Risiko.', - 'settings.integrations.thirdParty.title': 'Drittanbieter-Integrationen', - 'settings.integrations.thirdParty.info': 'Installiere ein Provider-Plugin und richte dein Abonnement ein, damit OpenChamber es nutzen kann.', - 'settings.integrations.thirdParty.actions.install': 'Installieren', - 'settings.integrations.thirdParty.actions.update': 'Aktualisieren', - 'settings.integrations.thirdParty.actions.setup': 'Einrichten', - 'settings.integrations.thirdParty.actions.remove': 'Entfernen', - 'settings.integrations.thirdParty.actions.docs': 'Dokumentation', - 'settings.integrations.thirdParty.actions.managePlugins': 'Plugins verwalten', - 'settings.integrations.thirdParty.status.notInstalled': 'Nicht installiert', - 'settings.integrations.thirdParty.status.installed': 'Installiert', - 'settings.integrations.thirdParty.status.installedVersion': '{version} installiert', - 'settings.integrations.thirdParty.status.updateAvailable': 'Aktualisierung verfügbar: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Folgt der neuesten Version', - 'settings.integrations.thirdParty.status.projectInstalled': 'Auch für dieses Projekt konfiguriert', - 'settings.integrations.thirdParty.status.ambiguous': 'Mehrere benutzerweite Plugin-Einträge müssen manuell verwaltet werden', - 'settings.integrations.thirdParty.status.restartRequired': 'Starte OpenCode neu, bevor du diesen Provider einrichtest.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'npm konnte gerade nicht geprüft werden.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'Der Provider ist noch nicht verfügbar. Starte OpenCode neu und versuche es erneut.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Integration entfernen', - 'settings.integrations.thirdParty.dialog.remove.description': '{name} aus deiner benutzerweiten OpenCode-Konfiguration entfernen? Der Provider wird nach der Aktualisierung von OpenCode nicht mehr geladen.', - 'settings.integrations.thirdParty.toast.installed': '{name} installiert', - 'settings.integrations.thirdParty.toast.updated': '{name} aktualisiert', - 'settings.integrations.thirdParty.toast.removed': '{name} entfernt', - 'settings.integrations.thirdParty.toast.actionFailed': 'Die Integration konnte nicht aktualisiert werden', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Der Provider konnte noch nicht geöffnet werden', - 'settings.integrations.thirdParty.toast.restartRequired': 'Starte OpenCode neu, damit die Änderungen wirksam werden', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Nutze deinen Claude-Pro/Max-Plan — ohne API-Keys, ohne Claude-Apps.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Die großzügigen Limits der Cursor-eigenen Modelle jetzt in OpenChamber.', - }, - fr: { - 'settings.page.integrations.title': 'Intégrations', - 'settings.page.integrations.description': 'Ajoutez des abonnements tiers à utiliser comme fournisseurs OpenChamber.', - 'settings.integrations.experimentalWarning': 'Fonctionnalité expérimentale. Nous cherchons à respecter les règles des fournisseurs, mais les restrictions et suspensions de compte relèvent de leur décision. Utilisez les intégrations à vos risques.', - 'settings.integrations.thirdParty.title': 'Intégrations tierces', - 'settings.integrations.thirdParty.info': 'Installez un plugin de fournisseur, puis configurez votre abonnement pour qu’OpenChamber puisse l’utiliser.', - 'settings.integrations.thirdParty.actions.install': 'Installer', - 'settings.integrations.thirdParty.actions.update': 'Mettre à jour', - 'settings.integrations.thirdParty.actions.setup': 'Configurer', - 'settings.integrations.thirdParty.actions.remove': 'Supprimer', - 'settings.integrations.thirdParty.actions.docs': 'Documentation', - 'settings.integrations.thirdParty.actions.managePlugins': 'Gérer les plugins', - 'settings.integrations.thirdParty.status.notInstalled': 'Non installé', - 'settings.integrations.thirdParty.status.installed': 'Installé', - 'settings.integrations.thirdParty.status.installedVersion': '{version} installé', - 'settings.integrations.thirdParty.status.updateAvailable': 'Mise à jour disponible : {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Suit la dernière version', - 'settings.integrations.thirdParty.status.projectInstalled': 'Également configuré pour ce projet', - 'settings.integrations.thirdParty.status.ambiguous': 'Plusieurs entrées de plugin globales doivent être gérées manuellement', - 'settings.integrations.thirdParty.status.restartRequired': 'Redémarrez OpenCode avant de configurer ce fournisseur.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'Impossible de vérifier npm pour le moment.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'Le fournisseur n’est pas encore disponible. Redémarrez OpenCode et réessayez.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Supprimer l’intégration', - 'settings.integrations.thirdParty.dialog.remove.description': 'Supprimer {name} de votre configuration OpenCode globale ? Le fournisseur ne sera plus chargé après l’actualisation d’OpenCode.', - 'settings.integrations.thirdParty.toast.installed': '{name} installé', - 'settings.integrations.thirdParty.toast.updated': '{name} mis à jour', - 'settings.integrations.thirdParty.toast.removed': '{name} supprimé', - 'settings.integrations.thirdParty.toast.actionFailed': 'Impossible de mettre à jour l’intégration', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Le fournisseur n’a pas encore pu être ouvert', - 'settings.integrations.thirdParty.toast.restartRequired': 'Redémarrez OpenCode pour que les modifications prennent effet', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Utilisez votre forfait Claude Pro/Max — sans clés API, sans apps Claude.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Les généreuses limites des modèles internes Cursor, désormais dans OpenChamber.', - }, - es: { - 'settings.page.integrations.title': 'Integraciones', - 'settings.page.integrations.description': 'Añade suscripciones de terceros para usarlas como proveedores de OpenChamber.', - 'settings.integrations.experimentalWarning': 'Función experimental. Buscamos respetar las políticas de los proveedores, pero las restricciones y suspensiones de cuentas son decisión de cada proveedor. Usa las integraciones bajo tu propia responsabilidad.', - 'settings.integrations.thirdParty.title': 'Integraciones de terceros', - 'settings.integrations.thirdParty.info': 'Instala un plugin de proveedor y configura tu suscripción para que OpenChamber pueda usarla.', - 'settings.integrations.thirdParty.actions.install': 'Instalar', - 'settings.integrations.thirdParty.actions.update': 'Actualizar', - 'settings.integrations.thirdParty.actions.setup': 'Configurar', - 'settings.integrations.thirdParty.actions.remove': 'Quitar', - 'settings.integrations.thirdParty.actions.docs': 'Documentación', - 'settings.integrations.thirdParty.actions.managePlugins': 'Administrar plugins', - 'settings.integrations.thirdParty.status.notInstalled': 'No instalado', - 'settings.integrations.thirdParty.status.installed': 'Instalado', - 'settings.integrations.thirdParty.status.installedVersion': '{version} instalado', - 'settings.integrations.thirdParty.status.updateAvailable': 'Actualización disponible: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Sigue la versión más reciente', - 'settings.integrations.thirdParty.status.projectInstalled': 'También configurado para este proyecto', - 'settings.integrations.thirdParty.status.ambiguous': 'Hay varias entradas de plugin globales que requieren gestión manual', - 'settings.integrations.thirdParty.status.restartRequired': 'Reinicia OpenCode antes de configurar este proveedor.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'No se pudo comprobar npm ahora mismo.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'El proveedor todavía no está disponible. Reinicia OpenCode e inténtalo de nuevo.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Quitar integración', - 'settings.integrations.thirdParty.dialog.remove.description': '¿Quitar {name} de tu configuración global de OpenCode? El proveedor dejará de cargarse después de actualizar OpenCode.', - 'settings.integrations.thirdParty.toast.installed': '{name} instalado', - 'settings.integrations.thirdParty.toast.updated': '{name} actualizado', - 'settings.integrations.thirdParty.toast.removed': '{name} eliminado', - 'settings.integrations.thirdParty.toast.actionFailed': 'No se pudo actualizar la integración', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Todavía no se pudo abrir el proveedor', - 'settings.integrations.thirdParty.toast.restartRequired': 'Reinicia OpenCode para que los cambios surtan efecto', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Usa tu plan Claude Pro/Max: sin claves API ni apps de Claude.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Los generosos límites de los modelos internos de Cursor, ahora en OpenChamber.', - }, - ja: { - 'settings.page.integrations.title': '連携', - 'settings.page.integrations.description': 'サードパーティのサブスクリプションを追加して、OpenChamber のプロバイダーとして使います。', - 'settings.integrations.experimentalWarning': '実験的な機能です。プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。', - 'settings.integrations.thirdParty.title': 'サードパーティー連携', - 'settings.integrations.thirdParty.info': 'プロバイダープラグインをインストールし、サブスクリプションを設定して OpenChamber で使えるようにします。', - 'settings.integrations.thirdParty.actions.install': 'インストール', - 'settings.integrations.thirdParty.actions.update': '更新', - 'settings.integrations.thirdParty.actions.setup': '設定', - 'settings.integrations.thirdParty.actions.remove': '削除', - 'settings.integrations.thirdParty.actions.docs': 'ドキュメント', - 'settings.integrations.thirdParty.actions.managePlugins': 'プラグインを管理', - 'settings.integrations.thirdParty.status.notInstalled': '未インストール', - 'settings.integrations.thirdParty.status.installed': 'インストール済み', - 'settings.integrations.thirdParty.status.installedVersion': '{version} をインストール済み', - 'settings.integrations.thirdParty.status.updateAvailable': '更新があります: {version}', - 'settings.integrations.thirdParty.status.unpinned': '最新リリースを追跡中', - 'settings.integrations.thirdParty.status.projectInstalled': 'このプロジェクトにも設定済み', - 'settings.integrations.thirdParty.status.ambiguous': '複数のユーザー全体プラグインエントリーは手動で管理する必要があります', - 'settings.integrations.thirdParty.status.restartRequired': 'このプロバイダーを設定する前に OpenCode を再起動してください。', - 'settings.integrations.thirdParty.status.registryUnavailable': '現在 npm を確認できません。', - 'settings.integrations.thirdParty.status.providerUnavailable': 'プロバイダーはまだ利用できません。OpenCode を再起動して再試行してください。', - 'settings.integrations.thirdParty.dialog.remove.title': '連携を削除', - 'settings.integrations.thirdParty.dialog.remove.description': '{name} をユーザー全体の OpenCode 設定から削除しますか?OpenCode の更新後、このプロバイダーは読み込まれなくなります。', - 'settings.integrations.thirdParty.toast.installed': '{name} をインストールしました', - 'settings.integrations.thirdParty.toast.updated': '{name} を更新しました', - 'settings.integrations.thirdParty.toast.removed': '{name} を削除しました', - 'settings.integrations.thirdParty.toast.actionFailed': '連携を更新できませんでした', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'プロバイダーをまだ開けませんでした', - 'settings.integrations.thirdParty.toast.restartRequired': '変更を反映するには OpenCode を再起動してください', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max プランを利用 — API キーも Claude アプリも不要。', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内蔵モデルの余裕ある制限が、OpenChamber で使えます。', - }, - ko: { - 'settings.page.integrations.title': '통합', - 'settings.page.integrations.description': '타사 구독을 추가해 OpenChamber 프로바이더로 사용하세요.', - 'settings.integrations.experimentalWarning': '실험 단계 기능입니다. 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.', - 'settings.integrations.thirdParty.title': '서드파티 통합', - 'settings.integrations.thirdParty.info': '프로바이더 플러그인을 설치한 뒤 구독을 설정하면 OpenChamber에서 사용할 수 있습니다.', - 'settings.integrations.thirdParty.actions.install': '설치', - 'settings.integrations.thirdParty.actions.update': '업데이트', - 'settings.integrations.thirdParty.actions.setup': '설정', - 'settings.integrations.thirdParty.actions.remove': '제거', - 'settings.integrations.thirdParty.actions.docs': '문서', - 'settings.integrations.thirdParty.actions.managePlugins': '플러그인 관리', - 'settings.integrations.thirdParty.status.notInstalled': '설치되지 않음', - 'settings.integrations.thirdParty.status.installed': '설치됨', - 'settings.integrations.thirdParty.status.installedVersion': '{version} 설치됨', - 'settings.integrations.thirdParty.status.updateAvailable': '업데이트 가능: {version}', - 'settings.integrations.thirdParty.status.unpinned': '최신 릴리스 추적 중', - 'settings.integrations.thirdParty.status.projectInstalled': '이 프로젝트에도 구성됨', - 'settings.integrations.thirdParty.status.ambiguous': '여러 사용자 전체 플러그인 항목은 수동으로 관리해야 합니다', - 'settings.integrations.thirdParty.status.restartRequired': '이 프로바이더를 설정하기 전에 OpenCode를 다시 시작하세요.', - 'settings.integrations.thirdParty.status.registryUnavailable': '지금은 npm을 확인할 수 없습니다.', - 'settings.integrations.thirdParty.status.providerUnavailable': '프로바이더를 아직 사용할 수 없습니다. OpenCode를 다시 시작한 후 재시도하세요.', - 'settings.integrations.thirdParty.dialog.remove.title': '통합 제거', - 'settings.integrations.thirdParty.dialog.remove.description': '사용자 전체 OpenCode 구성에서 {name}을(를) 제거할까요? OpenCode가 새로 고쳐진 후 프로바이더가 더 이상 로드되지 않습니다.', - 'settings.integrations.thirdParty.toast.installed': '{name} 설치됨', - 'settings.integrations.thirdParty.toast.updated': '{name} 업데이트됨', - 'settings.integrations.thirdParty.toast.removed': '{name} 제거됨', - 'settings.integrations.thirdParty.toast.actionFailed': '통합을 업데이트하지 못했습니다', - 'settings.integrations.thirdParty.toast.providerUnavailable': '프로바이더를 아직 열 수 없습니다', - 'settings.integrations.thirdParty.toast.restartRequired': '변경 사항을 적용하려면 OpenCode를 다시 시작하세요', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max 요금제를 사용하세요. API 키와 Claude 앱은 필요 없습니다.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 자체 모델의 넉넉한 한도를 이제 OpenChamber에서.', - }, - pl: { - 'settings.page.integrations.title': 'Integracje', - 'settings.page.integrations.description': 'Dodaj subskrypcje zewnętrzne, aby używać ich jako dostawców OpenChamber.', - 'settings.integrations.experimentalWarning': 'Funkcja eksperymentalna. Staramy się przestrzegać zasad dostawców, ale ograniczenia i zawieszenia kont pozostają decyzją każdego dostawcy. Używaj integracji na własne ryzyko.', - 'settings.integrations.thirdParty.title': 'Integracje zewnętrzne', - 'settings.integrations.thirdParty.info': 'Zainstaluj wtyczkę dostawcy, a następnie skonfiguruj subskrypcję, aby OpenChamber mógł z niej korzystać.', - 'settings.integrations.thirdParty.actions.install': 'Zainstaluj', - 'settings.integrations.thirdParty.actions.update': 'Aktualizuj', - 'settings.integrations.thirdParty.actions.setup': 'Skonfiguruj', - 'settings.integrations.thirdParty.actions.remove': 'Usuń', - 'settings.integrations.thirdParty.actions.docs': 'Dokumentacja', - 'settings.integrations.thirdParty.actions.managePlugins': 'Zarządzaj wtyczkami', - 'settings.integrations.thirdParty.status.notInstalled': 'Nie zainstalowano', - 'settings.integrations.thirdParty.status.installed': 'Zainstalowano', - 'settings.integrations.thirdParty.status.installedVersion': 'Zainstalowano {version}', - 'settings.integrations.thirdParty.status.updateAvailable': 'Dostępna aktualizacja: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Śledzi najnowsze wydanie', - 'settings.integrations.thirdParty.status.projectInstalled': 'Skonfigurowano także dla tego projektu', - 'settings.integrations.thirdParty.status.ambiguous': 'Wiele globalnych wpisów wtyczki wymaga ręcznego zarządzania', - 'settings.integrations.thirdParty.status.restartRequired': 'Uruchom ponownie OpenCode przed konfiguracją tego dostawcy.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'Nie można teraz sprawdzić npm.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'Dostawca nie jest jeszcze dostępny. Uruchom ponownie OpenCode i spróbuj ponownie.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Usuń integrację', - 'settings.integrations.thirdParty.dialog.remove.description': 'Usunąć {name} z globalnej konfiguracji OpenCode? Dostawca przestanie być ładowany po odświeżeniu OpenCode.', - 'settings.integrations.thirdParty.toast.installed': 'Zainstalowano {name}', - 'settings.integrations.thirdParty.toast.updated': 'Zaktualizowano {name}', - 'settings.integrations.thirdParty.toast.removed': 'Usunięto {name}', - 'settings.integrations.thirdParty.toast.actionFailed': 'Nie udało się zaktualizować integracji', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Nie można jeszcze otworzyć dostawcy', - 'settings.integrations.thirdParty.toast.restartRequired': 'Uruchom ponownie OpenCode, aby zastosować zmiany', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Korzystaj z planu Claude Pro/Max — bez kluczy API i aplikacji Claude.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Hojne limity wewnętrznych modeli Cursor teraz w OpenChamber.', - }, - 'pt-BR': { - 'settings.page.integrations.title': 'Integrações', - 'settings.page.integrations.description': 'Adicione assinaturas de terceiros para usar como provedores do OpenChamber.', - 'settings.integrations.experimentalWarning': 'Recurso experimental. Buscamos respeitar as políticas dos provedores, mas restrições e suspensões de conta continuam sendo decisão de cada provedor. Use as integrações por sua conta e risco.', - 'settings.integrations.thirdParty.title': 'Integrações de terceiros', - 'settings.integrations.thirdParty.info': 'Instale um plugin de provedor e configure sua assinatura para o OpenChamber poder usá-la.', - 'settings.integrations.thirdParty.actions.install': 'Instalar', - 'settings.integrations.thirdParty.actions.update': 'Atualizar', - 'settings.integrations.thirdParty.actions.setup': 'Configurar', - 'settings.integrations.thirdParty.actions.remove': 'Remover', - 'settings.integrations.thirdParty.actions.docs': 'Documentação', - 'settings.integrations.thirdParty.actions.managePlugins': 'Gerenciar plugins', - 'settings.integrations.thirdParty.status.notInstalled': 'Não instalado', - 'settings.integrations.thirdParty.status.installed': 'Instalado', - 'settings.integrations.thirdParty.status.installedVersion': '{version} instalado', - 'settings.integrations.thirdParty.status.updateAvailable': 'Atualização disponível: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Acompanha a versão mais recente', - 'settings.integrations.thirdParty.status.projectInstalled': 'Também configurado para este projeto', - 'settings.integrations.thirdParty.status.ambiguous': 'Várias entradas de plugin globais precisam de gerenciamento manual', - 'settings.integrations.thirdParty.status.restartRequired': 'Reinicie o OpenCode antes de configurar este provedor.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'Não foi possível verificar o npm agora.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'O provedor ainda não está disponível. Reinicie o OpenCode e tente novamente.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Remover integração', - 'settings.integrations.thirdParty.dialog.remove.description': 'Remover {name} da sua configuração global do OpenCode? O provedor deixará de ser carregado após a atualização do OpenCode.', - 'settings.integrations.thirdParty.toast.installed': '{name} instalado', - 'settings.integrations.thirdParty.toast.updated': '{name} atualizado', - 'settings.integrations.thirdParty.toast.removed': '{name} removido', - 'settings.integrations.thirdParty.toast.actionFailed': 'Não foi possível atualizar a integração', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Ainda não foi possível abrir o provedor', - 'settings.integrations.thirdParty.toast.restartRequired': 'Reinicie o OpenCode para que as alterações entrem em vigor', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Use seu plano Claude Pro/Max — sem chaves de API nem apps da Claude.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Os limites generosos dos modelos internos do Cursor, agora no OpenChamber.', - }, - uk: { - 'settings.page.integrations.title': 'Інтеграції', - 'settings.page.integrations.description': 'Додайте сторонні підписки, щоб використовувати їх як провайдери OpenChamber.', - 'settings.integrations.experimentalWarning': 'Експериментальна функція. Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.', - 'settings.integrations.thirdParty.title': 'Сторонні інтеграції', - 'settings.integrations.thirdParty.info': 'Установіть плагін провайдера, а потім налаштуйте підписку, щоб OpenChamber міг її використовувати.', - 'settings.integrations.thirdParty.actions.install': 'Встановити', - 'settings.integrations.thirdParty.actions.update': 'Оновити', - 'settings.integrations.thirdParty.actions.setup': 'Налаштувати', - 'settings.integrations.thirdParty.actions.remove': 'Видалити', - 'settings.integrations.thirdParty.actions.docs': 'Документація', - 'settings.integrations.thirdParty.actions.managePlugins': 'Керувати плагінами', - 'settings.integrations.thirdParty.status.notInstalled': 'Не встановлено', - 'settings.integrations.thirdParty.status.installed': 'Встановлено', - 'settings.integrations.thirdParty.status.installedVersion': 'Встановлено {version}', - 'settings.integrations.thirdParty.status.updateAvailable': 'Доступне оновлення: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'Відстежує найновіший випуск', - 'settings.integrations.thirdParty.status.projectInstalled': 'Також налаштовано для цього проєкту', - 'settings.integrations.thirdParty.status.ambiguous': 'Кілька глобальних записів плагіна потребують ручного керування', - 'settings.integrations.thirdParty.status.restartRequired': 'Перезапустіть OpenCode перед налаштуванням цього провайдера.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'Зараз не вдалося перевірити npm.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'Провайдер ще недоступний. Перезапустіть OpenCode й спробуйте знову.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Видалити інтеграцію', - 'settings.integrations.thirdParty.dialog.remove.description': 'Видалити {name} з вашої глобальної конфігурації OpenCode? Після оновлення OpenCode провайдер більше не завантажуватиметься.', - 'settings.integrations.thirdParty.toast.installed': '{name} встановлено', - 'settings.integrations.thirdParty.toast.updated': '{name} оновлено', - 'settings.integrations.thirdParty.toast.removed': '{name} видалено', - 'settings.integrations.thirdParty.toast.actionFailed': 'Не вдалося оновити інтеграцію', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Провайдера ще не вдалося відкрити', - 'settings.integrations.thirdParty.toast.restartRequired': 'Перезапустіть OpenCode, щоб застосувати зміни', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max за підпискою — без API-ключів і без додатків Claude.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Щедрі ліміти внутрішніх моделей Cursor — тепер в OpenChamber.', - }, - 'zh-CN': { - 'settings.page.integrations.title': '集成', - 'settings.page.integrations.description': '添加第三方订阅,将其用作 OpenChamber 提供商。', - 'settings.integrations.experimentalWarning': '实验性功能。我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。', - 'settings.integrations.thirdParty.title': '第三方集成', - 'settings.integrations.thirdParty.info': '安装提供商插件并设置订阅,以便 OpenChamber 可以使用它。', - 'settings.integrations.thirdParty.actions.install': '安装', - 'settings.integrations.thirdParty.actions.update': '更新', - 'settings.integrations.thirdParty.actions.setup': '设置', - 'settings.integrations.thirdParty.actions.remove': '移除', - 'settings.integrations.thirdParty.actions.docs': '文档', - 'settings.integrations.thirdParty.actions.managePlugins': '管理插件', - 'settings.integrations.thirdParty.status.notInstalled': '未安装', - 'settings.integrations.thirdParty.status.installed': '已安装', - 'settings.integrations.thirdParty.status.installedVersion': '已安装 {version}', - 'settings.integrations.thirdParty.status.updateAvailable': '有可用更新:{version}', - 'settings.integrations.thirdParty.status.unpinned': '跟踪最新版本', - 'settings.integrations.thirdParty.status.projectInstalled': '也已为此项目配置', - 'settings.integrations.thirdParty.status.ambiguous': '多个用户级插件条目需要手动管理', - 'settings.integrations.thirdParty.status.restartRequired': '请先重启 OpenCode,再设置此提供商。', - 'settings.integrations.thirdParty.status.registryUnavailable': '当前无法检查 npm。', - 'settings.integrations.thirdParty.status.providerUnavailable': '该提供商尚不可用。请重启 OpenCode 后重试。', - 'settings.integrations.thirdParty.dialog.remove.title': '移除集成', - 'settings.integrations.thirdParty.dialog.remove.description': '要从用户级 OpenCode 配置中移除 {name} 吗?OpenCode 刷新后将不再加载该提供商。', - 'settings.integrations.thirdParty.toast.installed': '已安装 {name}', - 'settings.integrations.thirdParty.toast.updated': '已更新 {name}', - 'settings.integrations.thirdParty.toast.removed': '已移除 {name}', - 'settings.integrations.thirdParty.toast.actionFailed': '无法更新集成', - 'settings.integrations.thirdParty.toast.providerUnavailable': '暂时无法打开该提供商', - 'settings.integrations.thirdParty.toast.restartRequired': '请重启 OpenCode 以使更改生效', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 套餐——无需 API 密钥,也无需 Claude 应用。', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 内部模型的充足额度,现已可用于 OpenChamber。', - }, - 'zh-TW': { - 'settings.page.integrations.title': '整合', - 'settings.page.integrations.description': '新增第三方訂閱,將其用作 OpenChamber 供應商。', - 'settings.integrations.experimentalWarning': '實驗性功能。我們致力遵守供應商的政策,但帳戶限制和停用仍由各供應商決定。請自行承擔使用整合的風險。', - 'settings.integrations.thirdParty.title': '第三方整合', - 'settings.integrations.thirdParty.info': '安裝供應商外掛並設定訂閱,以便 OpenChamber 可以使用它。', - 'settings.integrations.thirdParty.actions.install': '安裝', - 'settings.integrations.thirdParty.actions.update': '更新', - 'settings.integrations.thirdParty.actions.setup': '設定', - 'settings.integrations.thirdParty.actions.remove': '移除', - 'settings.integrations.thirdParty.actions.docs': '文件', - 'settings.integrations.thirdParty.actions.managePlugins': '管理外掛', - 'settings.integrations.thirdParty.status.notInstalled': '未安裝', - 'settings.integrations.thirdParty.status.installed': '已安裝', - 'settings.integrations.thirdParty.status.installedVersion': '已安裝 {version}', - 'settings.integrations.thirdParty.status.updateAvailable': '有可用更新:{version}', - 'settings.integrations.thirdParty.status.unpinned': '追蹤最新版本', - 'settings.integrations.thirdParty.status.projectInstalled': '也已為此專案設定', - 'settings.integrations.thirdParty.status.ambiguous': '多個使用者層級外掛項目需要手動管理', - 'settings.integrations.thirdParty.status.restartRequired': '請先重新啟動 OpenCode,再設定此供應商。', - 'settings.integrations.thirdParty.status.registryUnavailable': '目前無法檢查 npm。', - 'settings.integrations.thirdParty.status.providerUnavailable': '供應商尚不可用。請重新啟動 OpenCode 後再試一次。', - 'settings.integrations.thirdParty.dialog.remove.title': '移除整合', - 'settings.integrations.thirdParty.dialog.remove.description': '要從使用者層級 OpenCode 設定中移除 {name} 嗎?OpenCode 重新整理後將不再載入此供應商。', - 'settings.integrations.thirdParty.toast.installed': '已安裝 {name}', - 'settings.integrations.thirdParty.toast.updated': '已更新 {name}', - 'settings.integrations.thirdParty.toast.removed': '已移除 {name}', - 'settings.integrations.thirdParty.toast.actionFailed': '無法更新整合', - 'settings.integrations.thirdParty.toast.providerUnavailable': '暫時無法開啟供應商', - 'settings.integrations.thirdParty.toast.restartRequired': '請重新啟動 OpenCode 以使變更生效', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': '使用 Claude Pro/Max 方案——無需 API 金鑰,也無需 Claude 應用程式。', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor 內部模型的充足額度,現已可用於 OpenChamber。', - }, - tr: { - 'settings.integrations.experimentalWarning': 'Deneysel özellik. Provider politikalarına saygı göstermeye çalışıyoruz, ancak hesap kısıtlamaları ve askıya almalar her provider\'ın kararıdır. Entegrasyonları kendi riskinize kullanın.', - 'settings.page.integrations.title': 'Entegrasyonlar', - 'settings.page.integrations.description': 'OpenChamber provider olarak kullanmak için üçüncü taraf abonelikler ekleyin.', - 'settings.integrations.thirdParty.title': 'Üçüncü taraf entegrasyonlar', - 'settings.integrations.thirdParty.info': 'Bir provider eklentisi kurun, ardından aboneliğinizi ayarlayın ki OpenChamber onu kullanabilsin.', - 'settings.integrations.thirdParty.actions.install': 'Kur', - 'settings.integrations.thirdParty.actions.update': 'Güncelle', - 'settings.integrations.thirdParty.actions.setup': 'Ayarla', - 'settings.integrations.thirdParty.actions.remove': 'Kaldır', - 'settings.integrations.thirdParty.actions.docs': 'Dokümanlar', - 'settings.integrations.thirdParty.actions.managePlugins': 'Eklentileri yönet', - 'settings.integrations.thirdParty.status.notInstalled': 'Kurulu değil', - 'settings.integrations.thirdParty.status.installed': 'Kurulu', - 'settings.integrations.thirdParty.status.installedVersion': 'Kurulu: {version}', - 'settings.integrations.thirdParty.status.updateAvailable': 'Güncelleme var: {version}', - 'settings.integrations.thirdParty.status.unpinned': 'En son release takip ediliyor', - 'settings.integrations.thirdParty.status.projectInstalled': 'Ayrıca bu proje için yapılandırıldı', - 'settings.integrations.thirdParty.status.ambiguous': 'Birden çok kullanıcı genelinde eklenti girdisi elle yönetilmeli', - 'settings.integrations.thirdParty.status.restartRequired': 'Bu provider\'ı ayarlamadan önce OpenCode\'u yeniden başlatın.', - 'settings.integrations.thirdParty.status.registryUnavailable': 'npm şu anda denetlenemedi.', - 'settings.integrations.thirdParty.status.providerUnavailable': 'Provider henüz kullanılabilir değil. OpenCode\'u yeniden başlatıp tekrar deneyin.', - 'settings.integrations.thirdParty.dialog.remove.title': 'Entegrasyonu kaldır', - 'settings.integrations.thirdParty.dialog.remove.description': '{name}, kullanıcı genelindeki OpenCode yapılandırmanızdan kaldırılsın mı? OpenCode yenilendikten sonra provider artık yüklenmeyecek.', - 'settings.integrations.thirdParty.toast.installed': '{name} kuruldu', - 'settings.integrations.thirdParty.toast.updated': '{name} güncellendi', - 'settings.integrations.thirdParty.toast.removed': '{name} kaldırıldı', - 'settings.integrations.thirdParty.toast.actionFailed': 'Entegrasyon güncellenemedi', - 'settings.integrations.thirdParty.toast.providerUnavailable': 'Provider henüz açılamadı', - 'settings.integrations.thirdParty.toast.restartRequired': 'Değişikliklerin geçerli olması için OpenCode\'u yeniden başlatın', - 'settings.integrations.thirdParty.opencodeClaude.name': 'Claude Code', - 'settings.integrations.thirdParty.opencodeClaude.description': 'Claude Pro/Max planınızı kullanın — API anahtarı gerekmez, Claude uygulamaları gerekmez.', - 'settings.integrations.thirdParty.opencodeCursorOauth.name': 'Cursor', - 'settings.integrations.thirdParty.opencodeCursorOauth.description': 'Cursor\'ın cömert kendi model limitleri artık OpenChamber\'da.', - }, -} as const; diff --git a/packages/ui/src/lib/i18n/messages/tr.settings.ts b/packages/ui/src/lib/i18n/messages/tr.settings.ts index 02651073..7d03119c 100644 --- a/packages/ui/src/lib/i18n/messages/tr.settings.ts +++ b/packages/ui/src/lib/i18n/messages/tr.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go kullanım takibi', 'settings.providers.page.openCodeGo.description': 'Kayan, haftalık ve aylık kotayı göstermek için OpenCode Go kontrol panelini bağlayın.', @@ -2223,6 +2222,7 @@ export const settingsDict = { 'settings.openchamber.visual.field.sessionTabs': 'Session\'ları başlıkta sekme olarak göster', 'settings.openchamber.visual.field.sessionTabsAria': 'Başlıktaki session sekmelerini aç/kapat', 'settings.openchamber.visual.field.sessionTabsInfo': 'Açtığınız session\'lar başlıkta sekmeler olarak dizilir. Kapatırsanız düz session başlığına döner.', - ...thirdPartyIntegrationI18n.tr, + 'settings.page.integrations.title': 'Entegrasyonlar', + 'settings.page.integrations.description': 'OpenChamber’ın issue’lar ve pull request’lerle çalışabilmesi için GitHub ve Linear’ı bağlayın.', ...linearIntegrationI18n.tr, }; diff --git a/packages/ui/src/lib/i18n/messages/tr.ts b/packages/ui/src/lib/i18n/messages/tr.ts index 775fe6b6..6bfdf572 100644 --- a/packages/ui/src/lib/i18n/messages/tr.ts +++ b/packages/ui/src/lib/i18n/messages/tr.ts @@ -1603,7 +1603,7 @@ export const dict = { 'chat.autoReview.reviewSessionLabel': 'İnceleme session\'ı', 'chat.autoReview.actions.open': 'Aç', 'chat.autoReview.actions.stop': 'Durdur', - 'chat.draftDirtyNotice.tooltip': 'Bu dalda commit edilmemiş dosyalar var.\nYeni oturum onları görecek. Bir commit veya worktree onları ayrı tutar.', + 'chat.draftDirtyNotice.tooltip': 'Bu dalda commit edilmemiş değişiklikleriniz var.\nYeni bir oturum bunlarla çakışabilir. Önce commit etmeyi veya başka bir worktree kullanmayı düşünebilirsiniz.', 'chat.draftDirtyNotice.indicatorAria': 'Bu dizinde commit edilmemiş değişiklikler var', 'diffView.hunk.label': 'Hunk\'lar', 'diffView.hunk.stage': 'Stage', @@ -2167,6 +2167,8 @@ export const dict = { 'chat.queuedMessage.send': 'gönder', 'chat.queuedMessage.removeAria': 'Kuyruktan kaldır', 'chat.queuedMessage.reorderAria': 'Yeniden sıralamak için sürükle', + 'chat.queuedMessage.toast.queueFailed': 'Mesaj kuyruğa eklenemedi. Yazma alanına geri kondu.', + 'chat.queuedMessage.toast.takeFailed': 'Kuyruktaki mesaj yüklenemedi. Lütfen tekrar deneyin.', 'chat.container.returnToParent.aria': 'Üst session\'a dön', 'chat.container.returnToParent.titleNamed': 'Şuraya dön: {title}', 'chat.container.returnToParent.title': 'Üst session\'a dön', diff --git a/packages/ui/src/lib/i18n/messages/uk.settings.ts b/packages/ui/src/lib/i18n/messages/uk.settings.ts index 4116ef42..e60e8f84 100644 --- a/packages/ui/src/lib/i18n/messages/uk.settings.ts +++ b/packages/ui/src/lib/i18n/messages/uk.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'Відстеження використання OpenCode Go', 'settings.providers.page.openCodeGo.description': 'Підключіть панель OpenCode Go, щоб бачити ковзну, тижневу та місячну квоту.', @@ -2234,5 +2233,6 @@ export const settingsDict = { "settings.openchamber.visual.option.followUpBehavior.steer.label": "Steer", "settings.openchamber.visual.option.followUpBehavior.queue.label": "Queue", ...linearIntegrationI18n.uk, - ...thirdPartyIntegrationI18n.uk, + 'settings.page.integrations.title': 'Інтеграції', + 'settings.page.integrations.description': 'Підключіть GitHub і Linear, щоб OpenChamber міг працювати з вашими задачами та pull request-ами.', } as const; diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 276dc0c2..7236179d 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1619,7 +1619,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': 'Сесія ревʼю', 'chat.autoReview.actions.open': 'Відкрити', 'chat.autoReview.actions.stop': 'Зупинити', - 'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені файли.\nНова сесія бачитиме їх. Коміт або worktree тримають їх окремо.', + 'chat.draftDirtyNotice.tooltip': 'У цій гілці є незакомічені зміни.\nНова сесія може конфліктувати з ними. Можливо, варто спершу закомітити їх або скористатися іншим worktree.', 'chat.draftDirtyNotice.indicatorAria': 'Незакомічені зміни в цьому каталозі', "diffView.hunk.label": "Шматки", "diffView.hunk.stage": "Додати", @@ -2186,6 +2186,8 @@ export const dict: Record = { "chat.queuedMessage.send": "send", "chat.queuedMessage.removeAria": "Видалити з черги", "chat.queuedMessage.reorderAria": "Перетягніть, щоб змінити порядок", + "chat.queuedMessage.toast.queueFailed": "Не вдалося додати повідомлення в чергу. Воно повернулося в поле вводу.", + "chat.queuedMessage.toast.takeFailed": "Не вдалося завантажити повідомлення з черги. Спробуйте ще раз.", "chat.container.returnToParent.aria": "Повернутися до батьківської сесії", "chat.container.returnToParent.titleNamed": "Повернутися до: {title}", "chat.container.returnToParent.title": "Повернутися до батьківської сесії", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts index 01024ad1..6c78774c 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量跟踪', 'settings.providers.page.openCodeGo.description': '连接 OpenCode Go 控制面板以显示滚动、每周和每月配额。', @@ -2234,5 +2233,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', ...linearIntegrationI18n['zh-CN'], - ...thirdPartyIntegrationI18n['zh-CN'], + 'settings.page.integrations.title': '集成', + 'settings.page.integrations.description': '连接 GitHub 和 Linear,让 OpenChamber 可以处理你的 issue 和拉取请求。', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 3fcb1c2a..c901b389 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1607,7 +1607,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '审查会话', 'chat.autoReview.actions.open': '打开', 'chat.autoReview.actions.stop': '停止', - 'chat.draftDirtyNotice.tooltip': '此分支有未提交的文件。\n新会话会看到它们。提交或工作树可将它们分开。', + 'chat.draftDirtyNotice.tooltip': '此分支有未提交的更改。\n新会话可能会与这些更改冲突。你可以考虑先提交这些更改,或使用其他工作树。', 'chat.draftDirtyNotice.indicatorAria': '此目录有未提交的更改', 'diffView.hunk.label': '代码块', 'diffView.hunk.stage': '暂存', @@ -2174,6 +2174,8 @@ export const dict: Record = { 'chat.queuedMessage.send': 'send', 'chat.queuedMessage.removeAria': '从队列移除', 'chat.queuedMessage.reorderAria': '拖动以重新排序', + 'chat.queuedMessage.toast.queueFailed': '无法将消息加入队列,已放回输入框。', + 'chat.queuedMessage.toast.takeFailed': '无法加载队列中的消息,请重试。', 'chat.container.returnToParent.aria': '返回父会话', 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父会话', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts index 7e5d3f25..4492a485 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.settings.ts @@ -1,5 +1,4 @@ import { linearIntegrationI18n } from './linear-integration.i18n'; -import { thirdPartyIntegrationI18n } from './third-party-integrations.i18n'; export const settingsDict = { 'settings.providers.page.openCodeGo.title': 'OpenCode Go 用量追蹤', 'settings.providers.page.openCodeGo.description': '連接 OpenCode Go 控制面板以顯示滾動、每週和每月配額。', @@ -2234,5 +2233,6 @@ export const settingsDict = { 'settings.openchamber.visual.option.followUpBehavior.steer.label': 'Steer', 'settings.openchamber.visual.option.followUpBehavior.queue.label': 'Queue', ...linearIntegrationI18n['zh-TW'], - ...thirdPartyIntegrationI18n['zh-TW'], + 'settings.page.integrations.title': '整合', + 'settings.page.integrations.description': '連接 GitHub 和 Linear,讓 OpenChamber 可以處理你的 issue 和 pull request。', } as const; diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 5f4263b7..e5ecfb22 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1617,7 +1617,7 @@ export const dict: Record = { 'chat.autoReview.reviewSessionLabel': '審查工作階段', 'chat.autoReview.actions.open': '開啟', 'chat.autoReview.actions.stop': '停止', - 'chat.draftDirtyNotice.tooltip': '此分支有未提交的檔案。\n新的工作階段會看到它們。提交或工作樹可將它們分開。', + 'chat.draftDirtyNotice.tooltip': '此分支有未提交的變更。\n新的工作階段可能會與這些變更衝突。你可以考慮先提交變更,或使用其他工作樹。', 'chat.draftDirtyNotice.indicatorAria': '此目錄有未提交的變更', 'diffView.hunk.label': '程式碼區塊', 'diffView.hunk.stage': '暫存', @@ -2178,6 +2178,8 @@ export const dict: Record = { 'chat.queuedMessage.send': 'send', 'chat.queuedMessage.removeAria': '從佇列移除', 'chat.queuedMessage.reorderAria': '拖曳以重新排序', + 'chat.queuedMessage.toast.queueFailed': '無法將訊息加入佇列,已放回輸入框。', + 'chat.queuedMessage.toast.takeFailed': '無法載入佇列中的訊息,請再試一次。', 'chat.container.returnToParent.aria': '返回父會話', 'chat.container.returnToParent.titleNamed': '返回到:{title}', 'chat.container.returnToParent.title': '返回父會話', diff --git a/packages/ui/src/lib/messages/contextParts.ts b/packages/ui/src/lib/messages/contextParts.ts index 4b6b78a9..1570da37 100644 --- a/packages/ui/src/lib/messages/contextParts.ts +++ b/packages/ui/src/lib/messages/contextParts.ts @@ -315,6 +315,12 @@ const contextPayloadSchema = z.discriminatedUnion('kind', [ }), ]); +/** + * Part metadata carrying a context payload, for parsing at a trust boundary + * (a queued message coming back from the server, for instance). + */ +export const contextPartMetadataSchema = z.object({ [CONTEXT_METADATA_KEY]: contextPayloadSchema }); + /** The subset of a message part that context read-back inspects. */ export type ContextCarrierPart = { type: string } & Pick; diff --git a/packages/ui/src/lib/sessionEvents.ts b/packages/ui/src/lib/sessionEvents.ts index 331383b6..07eba0f1 100644 --- a/packages/ui/src/lib/sessionEvents.ts +++ b/packages/ui/src/lib/sessionEvents.ts @@ -1,4 +1,6 @@ import type { Session } from '@opencode-ai/sdk/v2'; +import type { Part } from '@opencode-ai/sdk/v2/client'; +import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation'; import type { WorktreeMetadata } from '@/types/worktree'; export type SessionDeleteRequest = { @@ -24,6 +26,12 @@ const deleteListeners = new Set(); const createListeners = new Set(); const directoryListeners = new Set(); const gitRefreshListeners = new Set(); +const gitMutatingTools = new Set(['bash', 'edit', 'write', 'apply_patch', 'patch']); + +const normalizeToolName = (tool: string): string => { + const parts = tool.trim().toLowerCase().split('.').filter(Boolean); + return parts[parts.length - 1] ?? ''; +}; export const sessionEvents = { onDeleteRequest(listener: DeleteListener) { @@ -67,6 +75,19 @@ export const sessionEvents = { if (!hint.directory.trim()) { return; } + notifyGitStatusInvalidated(hint.directory); gitRefreshListeners.forEach((listener) => listener(hint)); }, + requestGitRefreshForToolTransition(directory: string, previousPart: Part | undefined, nextPart: Part) { + if (nextPart.type !== 'tool' || nextPart.state.status !== 'completed') { + return; + } + if (previousPart?.type === 'tool' && previousPart.state.status === 'completed') { + return; + } + if (!gitMutatingTools.has(normalizeToolName(nextPart.tool))) { + return; + } + sessionEvents.requestGitRefresh({ directory }); + }, }; diff --git a/packages/ui/src/lib/settings/metadata.ts b/packages/ui/src/lib/settings/metadata.ts index 8603434d..60958965 100644 --- a/packages/ui/src/lib/settings/metadata.ts +++ b/packages/ui/src/lib/settings/metadata.ts @@ -202,7 +202,7 @@ export const SETTINGS_PAGE_METADATA: readonly SettingsPageMeta[] = [ { slug: 'voice', title: 'Voice', group: 'general', kind: 'single', keywords: ['tts', 'speech', 'voice'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'tunnel', title: 'External Tunnel', group: 'projects', kind: 'single', keywords: ['tunnel', 'external', 'cloudflare', 'qr', 'remote', 'mobile', 'share'], isAvailable: (ctx) => !ctx.isVSCode }, { slug: 'about', title: 'About', group: 'general', kind: 'single', keywords: ['about', 'version', 'updates', 'release', 'changelog'], isAvailable: (ctx) => ctx.isMobile && !ctx.isVSCode }, - { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'plugin', 'provider', 'oauth', 'claude', 'cursor', 'command code', 'connect', 'discord', 'telegram', 'messenger', 'github', 'linear'] }, + { slug: 'integrations', title: 'Integrations', group: 'general', kind: 'single', keywords: ['integration', 'connect', 'oauth', 'github', 'linear'], isAvailable: (ctx) => !ctx.isVSCode }, ] as const; const LEGACY_SIDEBAR_SECTION_TO_SETTINGS_SLUG: Record = { diff --git a/packages/ui/src/lib/settings/search.test.ts b/packages/ui/src/lib/settings/search.test.ts index e3cf0fa8..ad12372d 100644 --- a/packages/ui/src/lib/settings/search.test.ts +++ b/packages/ui/src/lib/settings/search.test.ts @@ -17,28 +17,6 @@ const runtimeCtx = { }; describe('settings search', () => { - test('finds the Claude Code third-party integration', () => { - const results = buildSettingsSearchResults({ - query: 'claude', - runtimeCtx, - t, - getPageTitle: (page) => page, - }); - - expect(results.some((result) => result.id === 'integrations.third-party.opencode-claude')).toBe(true); - }); - - test('finds third-party integrations by OpenChamber npm package names', () => { - const results = buildSettingsSearchResults({ - query: '@openchamber/opencode-cursor', - runtimeCtx, - t, - getPageTitle: (page) => page, - }); - - expect(results.some((result) => result.id === 'integrations.third-party.opencode-cursor-oauth')).toBe(true); - }); - test('finds Linear connect on the integrations page', () => { const results = buildSettingsSearchResults({ query: 'linear', diff --git a/packages/ui/src/lib/settings/search.ts b/packages/ui/src/lib/settings/search.ts index 14d0caf2..eaf11603 100644 --- a/packages/ui/src/lib/settings/search.ts +++ b/packages/ui/src/lib/settings/search.ts @@ -1018,26 +1018,6 @@ const SETTINGS_SEARCH_ITEMS: readonly SettingsSearchItem[] = [ keywords: ['linear', 'project', 'team', 'map', 'workspace', 'directory'], isAvailable: (ctx) => !ctx.isVSCode, }, - { - id: 'integrations.third-party', - page: 'integrations', - titleKey: 'settings.integrations.thirdParty.title', - keywords: ['plugin', 'provider', 'oauth', 'install', 'update', 'remove'], - }, - { - id: 'integrations.third-party.opencode-claude', - page: 'integrations', - titleKey: 'settings.integrations.thirdParty.opencodeClaude.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description', - keywords: ['claude', 'anthropic', 'claude code', 'pro', 'max', 'agent sdk', '@openchamber/opencode-claude'], - }, - { - id: 'integrations.third-party.opencode-cursor-oauth', - page: 'integrations', - titleKey: 'settings.integrations.thirdParty.opencodeCursorOauth.name', - descriptionKey: 'settings.integrations.thirdParty.opencodeCursorOauth.description', - keywords: ['cursor', 'oauth', 'subscription', 'openai compatible', '@openchamber/opencode-cursor'], - }, ] as const; interface BuildSettingsSearchResultsOptions { diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts index 89204a80..d94eb417 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.test.ts @@ -51,6 +51,7 @@ const { markWorktreeBootstrapPending, setWorktreeBootstrapState, startWorktreeBootstrapWatcher, + subscribeWorktreeBootstrapState, waitForWorktreeBootstrap, waitForWorktreeGitReady, } = await import('./worktreeBootstrap'); @@ -246,3 +247,39 @@ describe('worktreeBootstrap.waitForWorktreeBootstrap', () => { clearWorktreeBootstrapState('/repo-wt'); }); }); + +describe('worktreeBootstrap subscription', () => { + beforeEach(() => { + clearWorktreeBootstrapState('/repo-wt'); + }); + + test('notifies subscribers as a directory enters and leaves bootstrap', () => { + const pendingSnapshots: boolean[] = []; + const unsubscribe = subscribeWorktreeBootstrapState(() => { + pendingSnapshots.push(getWorktreeBootstrapState('/repo-wt')?.status === 'pending'); + }); + + try { + markWorktreeBootstrapPending('/repo-wt'); + setWorktreeBootstrapState('/repo-wt', { status: 'ready', error: null, updatedAt: 2 }); + clearWorktreeBootstrapState('/repo-wt'); + } finally { + unsubscribe(); + } + + expect(pendingSnapshots).toEqual([true, false, false]); + }); + + test('stops notifying after unsubscribe', () => { + let notifications = 0; + const unsubscribe = subscribeWorktreeBootstrapState(() => { + notifications += 1; + }); + + markWorktreeBootstrapPending('/repo-wt'); + unsubscribe(); + clearWorktreeBootstrapState('/repo-wt'); + + expect(notifications).toBe(1); + }); +}); diff --git a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts index 9b047a70..52b649ef 100644 --- a/packages/ui/src/lib/worktrees/worktreeBootstrap.ts +++ b/packages/ui/src/lib/worktrees/worktreeBootstrap.ts @@ -24,6 +24,23 @@ const watchers = new Map normalizePath(directory); const getWaiterKey = (key: string, target: WorktreeBootstrapTarget): string => `${key}\n${target}`; +// UI surfaces subscribe to know when a directory enters or leaves bootstrap, +// so a half-created worktree's transient files are never shown as changes. +const bootstrapListeners = new Set<() => void>(); + +const notifyBootstrapListeners = (): void => { + for (const listener of bootstrapListeners) { + listener(); + } +}; + +export const subscribeWorktreeBootstrapState = (listener: () => void): (() => void) => { + bootstrapListeners.add(listener); + return () => { + bootstrapListeners.delete(listener); + }; +}; + const startLifecycle = (key: string): void => { const watcher = watchers.get(key); if (watcher) { @@ -72,6 +89,7 @@ const storePolledState = ( } state.set(key, next); + notifyBootstrapListeners(); return next; }; @@ -98,6 +116,7 @@ export const markWorktreeBootstrapPending = (directory: string): void => { error: null, updatedAt: Date.now(), }); + notifyBootstrapListeners(); }; export const clearWorktreeBootstrapState = (directory: string): void => { @@ -108,6 +127,7 @@ export const clearWorktreeBootstrapState = (directory: string): void => { startLifecycle(key); state.delete(key); lifecycleVersions.delete(key); + notifyBootstrapListeners(); }; export const setWorktreeBootstrapState = (directory: string, next: WorktreeBootstrapState): void => { @@ -117,6 +137,7 @@ export const setWorktreeBootstrapState = (directory: string, next: WorktreeBoots } startLifecycle(key); state.set(key, next); + notifyBootstrapListeners(); }; export const getWorktreeBootstrapState = (directory: string): WorktreeBootstrapState | null => { diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.test.ts b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts index 642e10db..ba792b48 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.test.ts @@ -15,9 +15,11 @@ let gitStatus: { behind: number; } | null = null; let branchTracking: MockBranchTracking = {}; +let isGitRepository = true; const createdPayloads: CreateWorktreeArgs[] = []; mock.module('@/lib/gitApi', () => ({ + checkIsGitRepository: () => Promise.resolve(isGitRepository), getGitStatus: () => (gitStatus ? Promise.resolve(gitStatus) : Promise.reject(new Error('no status'))), getGitBranches: () => Promise.resolve({ all: [], @@ -52,7 +54,11 @@ mock.module('@/lib/worktrees/worktreeManager', () => ({ }, })); -const { createWorktreeWithDefaults, withWorktreeRemoteStartRef } = await import('./worktreeCreate'); +const { + createWorktreeWithDefaults, + withWorktreeRemoteStartRef, + WorktreeRequiresGitRepositoryError, +} = await import('./worktreeCreate'); const baseArgs = (overrides: CreateWorktreeArgs = {}): CreateWorktreeArgs => ({ preferredName: 'openchamber/feature', @@ -168,9 +174,17 @@ describe('createWorktreeWithDefaults remote source integration', () => { projectRoot = '/repo'; gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 0 }; branchTracking = { main: 'origin/main' }; + isGitRepository = true; createdPayloads.length = 0; }); + test('rejects a non-Git project before asking the runtime to create a worktree', async () => { + isGitRepository = false; + + await expect(createWorktreeWithDefaults(project, baseArgs())).rejects.toThrow(WorktreeRequiresGitRepositoryError); + expect(createdPayloads).toHaveLength(0); + }); + test('sets the new branch\'s own upstream when using the tracked remote source', async () => { gitStatus = { current: 'main', tracking: 'origin/main', ahead: 0, behind: 15 }; diff --git a/packages/ui/src/lib/worktrees/worktreeCreate.ts b/packages/ui/src/lib/worktrees/worktreeCreate.ts index 52890f8f..da3c017d 100644 --- a/packages/ui/src/lib/worktrees/worktreeCreate.ts +++ b/packages/ui/src/lib/worktrees/worktreeCreate.ts @@ -1,8 +1,15 @@ -import { getGitBranches, getGitStatus } from '@/lib/gitApi'; +import { checkIsGitRepository, getGitBranches, getGitStatus } from '@/lib/gitApi'; import type { CreateWorktreeArgs, ProjectRef } from '@/lib/worktrees/worktreeManager'; import { createWorktree } from '@/lib/worktrees/worktreeManager'; import { getRootBranch, resolveProjectRoot } from '@/lib/worktrees/worktreeStatus'; +export class WorktreeRequiresGitRepositoryError extends Error { + constructor() { + super('Worktree creation requires a Git repository'); + this.name = 'WorktreeRequiresGitRepositoryError'; + } +} + const parseTrackingRef = (tracking: string | null | undefined): { remote: string; branch: string } | null => { const value = String(tracking || '').trim().replace(/^remotes\//, ''); if (!value) { @@ -162,6 +169,10 @@ export const createWorktreeWithDefaults = async ( args: CreateWorktreeArgs, options?: { resolvedRootTrackingRemote?: string | null } ) => { + const isGitRepository = await checkIsGitRepository(project.path); + if (!isGitRepository) { + throw new WorktreeRequiresGitRepositoryError(); + } const remoteArgs = await withWorktreeRemoteStartRef(project, args); const resolvedArgs = await withWorktreeUpstreamDefaults(project.path, remoteArgs, options); return createWorktree(project, resolvedArgs); diff --git a/packages/ui/src/lib/worktrees/worktreeManager.test.ts b/packages/ui/src/lib/worktrees/worktreeManager.test.ts index 109fac8b..dc7bec50 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.test.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.test.ts @@ -118,6 +118,7 @@ const { getLatestWorktreeMetadata, listProjectWorktrees, partitionWorktreesByRegisteredProject, + removeProjectWorktree, validateWorktreeCreate, worktreeMapsEqual, } = await import('./worktreeManager'); @@ -389,6 +390,44 @@ describe('worktreeManager list invalidation', () => { expect(metadata.worktreeStatus).toBe('pending'); expect(getLatestWorktreeMetadata(metadata).worktreeStatus).toBe('ready'); }); + + test('removes a worktree from sidebar topology owned by another registered checkout', async () => { + const removed: WorktreeMetadata = { + path: '/worktrees/removed', + projectDirectory: '/repo', + branch: 'removed', + label: 'removed', + }; + const sibling: WorktreeMetadata = { + path: '/worktrees/sibling', + projectDirectory: '/repo', + branch: 'sibling', + label: 'sibling', + }; + const unrelatedEntries: WorktreeMetadata[] = [{ + path: '/other/worktree', + projectDirectory: '/other', + branch: 'other', + label: 'other', + }]; + sessionState.availableWorktreesByProject = new Map([ + ['/worktrees/configured', [removed, sibling]], + ['/other', unrelatedEntries], + ]); + sessionState.availableWorktrees = [removed, sibling, ...unrelatedEntries]; + sessionState.worktreeMetadata = new Map([ + ['removed-session', removed], + ['sibling-session', sibling], + ]); + + await removeProjectWorktree({ id: 'path:/repo', path: '/repo' }, removed); + + expect(sessionState.availableWorktreesByProject.get('/worktrees/configured')).toEqual([sibling]); + expect(sessionState.availableWorktreesByProject.get('/other')).toBe(unrelatedEntries); + expect(sessionState.availableWorktrees).toEqual([sibling, ...unrelatedEntries]); + expect(sessionState.worktreeMetadata.has('removed-session')).toBe(false); + expect(sessionState.worktreeMetadata.get('sibling-session')).toBe(sibling); + }); }); describe('worktreeMapsEqual', () => { diff --git a/packages/ui/src/lib/worktrees/worktreeManager.ts b/packages/ui/src/lib/worktrees/worktreeManager.ts index b14de347..0fd1953b 100644 --- a/packages/ui/src/lib/worktrees/worktreeManager.ts +++ b/packages/ui/src/lib/worktrees/worktreeManager.ts @@ -606,14 +606,16 @@ export async function removeProjectWorktree(project: ProjectRef, worktree: Workt // Update sidebar store so removed worktree disappears immediately const normalizedWorktreePath = normalizePath(worktree.path); - const sidebarProjectKey = projectDirectory; const currentByProject = useSessionUIStore.getState().availableWorktreesByProject; const updatedByProject = new Map(currentByProject); - const projectWorktrees = updatedByProject.get(sidebarProjectKey) ?? []; - updatedByProject.set( - sidebarProjectKey, - projectWorktrees.filter((w) => normalizePath(w.path) !== normalizedWorktreePath), - ); + for (const [projectKey, projectWorktrees] of currentByProject) { + const remainingWorktrees = projectWorktrees.filter( + (candidate) => normalizePath(candidate.path) !== normalizedWorktreePath, + ); + if (remainingWorktrees.length !== projectWorktrees.length) { + updatedByProject.set(projectKey, remainingWorktrees); + } + } // Clean up worktreeMetadata for sessions in the removed worktree const currentMetadata = useSessionUIStore.getState().worktreeMetadata; diff --git a/packages/ui/src/stores/DOCUMENTATION.md b/packages/ui/src/stores/DOCUMENTATION.md index cf3964b0..f6bdb396 100644 --- a/packages/ui/src/stores/DOCUMENTATION.md +++ b/packages/ui/src/stores/DOCUMENTATION.md @@ -64,7 +64,11 @@ These stores coordinate persistent project/session metadata across multiple view `useProjectContextStore.ts` caches server-owned project notes, todos, and plan links, keyed by the path-derived project id. It replaced a pair of `window` CustomEvents that made every mounted notes panel re-read the whole project config. Writes are optimistic and roll back on failure; they are serialized per project, because the server's own store does a read-modify-write and two concurrent saves would otherwise race it. A load that resolves while a write is in flight keeps the local value for that field group only, so a slow snapshot cannot undo newer typing while still delivering the plan list it fetched. A failed load sets `error` and preserves the cached snapshot — an unreachable server must never render as "this project has no notes". Note and plan creation are deliberately not optimistic, since ids and timestamps are assigned by the server. Notes, todos, and plans are written through separate routes and tracked by separate in-flight flags, so a todo toggle cannot clobber a note edit in the same window. Pinned notes and plans are assembled into a synthetic context part by `lib/projectContextPinning.ts` at send time; that module tracks per-session what it already sent so an unchanged pinned set is not re-sent every turn. -`messageQueueStore.ts` keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same. +`messageQueueStore.ts` has two owners, decided by `isServerOwnedMessageQueue()`. On web, desktop, and mobile the OpenChamber server owns the queue (`packages/web/server/lib/message-queue/`): it delivers queued messages when the session goes idle whether or not any UI is open, and the store is a projection of it — `hydrate()` loads the server snapshot for the active runtime, `openchamber:message-queue.updated` broadcasts keep it current, and every mutation is optimistic locally then settled on the server's copy of that session (a failed round-trip re-reads the server instead of guessing). A per-key server revision rejects stale snapshots. Projection items carry attachment metadata only and no captured context; `popToInput()`/`takeForSend()` remove the message on the server and get the full payload back, which is why both are async. + +A queued message is captured whole, so whoever delivers it sends exactly what the composer would have: `text` (the content with its agent mention stripped and `@file` mentions already resolved into `attachments`), `agentMention`, and `context` — every chip the composer had attached (inline comments, terminal selections, browser annotations, PR comments/checks, quotes, linked issue/PR/Linear references, pending synthetic parts) plus the skill instruction derived from the text. `QueuedContextPart` distinguishes attached items (restored to the chips when the message is edited) from derived instructions (re-derived on send, never restored) and from synthetic parts other surfaces handed the composer (restored as pending). Context is captured by `buildComposerContext` and delivered by `queuedContextToParts` (`components/chat/composer/submit/buildOutgoingMessage.ts`), the same functions the composer uses for its own send. Nothing is re-resolved at delivery: the server has no agent list, no confirmed mentions, and no draft store. Messages a previous build left in this browser are uploaded once on the first hydration of a runtime and then dropped from persistence for that runtime (`partialize` skips server-owned runtime keys). VS Code has no server and keeps the local queue with the foreground auto-send hook (`useQueuedMessageAutoSend`, enabled only there); `useMessageQueueHoldSync` tells the server to hold a session's queue while a UI-driven auto-review run is going. + +In the local (VS Code) mode the store keeps a queued message until its own send resolves, so between dispatch and resolution the entry is still visible to every reader. Dispatchers must therefore mark the send (`markSending`/`clearSending`) and read `getSendableQueue()` — or filter `sendingIds` themselves — instead of dispatching straight from `queuedMessages`; otherwise a composer submit merges a message the auto-send hook is already delivering and it is sent twice (the window is seconds over a relay). `clearQueue()` retains in-flight entries for the same reason. `sendingIds` is deliberately not persisted: a restart has no in-flight sends, and a stale flag would strand a queued message; in the server-owned mode it mirrors the server's in-flight item. Desktop queues use the configured host id as runtime identity, not the current API URL, because an SSH reconnect allocates a new local forwarding port while the remote host remains the same. `useGlobalSessionsStore.ts` owns cold/global active and archived session coverage. Its entity map and active root, parent/child, and directory indexes are maintained in the same transaction as the compatibility arrays and `sessionsByDirectory`. Full authoritative snapshots may rebuild those indexes once; direct create, update, move, archive, and delete mutations update only affected hierarchy and directory buckets. Metadata-only updates preserve the structure reference. It is complementary to directory child stores: it is not the source of live busy/retry status or session messages. @@ -154,7 +158,8 @@ Important properties: - status, branches, log, identity, repository probes, and prefetch diffs commit through runtime and per-channel generations - status mutations advance a revision so older refreshes cannot undo optimistic or confirmed index changes - a successful status-affecting git mutation also advances that revision: the HTTP adapter's cache invalidation notifies the store through `lib/gitStatusInvalidation.ts` (the VS Code bridge adapter has no client-side status cache, so it emits nothing today) -- `fetchAll({ force: true })` forces the status fetch as well as the log refresh +- `fetchStatus({ force: true })` and `fetchAll({ force: true })` cross both the store and runtime transport caches; a forced reconciliation must reach the active runtime rather than reuse an unexpired browser status snapshot +- status requests do not start while a managed worktree bootstrap is pending, and a response admitted before bootstrap began is discarded if it completes after the directory enters `pending`; the `--no-checkout` population window is not user working-tree state - branch persistence is versioned, bounded, runtime-scoped, and claims the ambiguous legacy cache once - diff data has per-directory and aggregate count/UTF-8-byte limits; oversized single entries are rejected @@ -218,11 +223,29 @@ Each of them therefore keeps two things: tracks the **active** project only. Thinking variants keep the effective value in `currentVariant` so existing send -paths capture a stable configuration. The transient `currentVariantSelection` -distinguishes automatic initialization from a picker or shortcut choosing an -explicit override or `Default`; returning to `Default` restores its inherited -effective value. Only explicit overrides are stored in the per-session -selection store. +paths capture a stable configuration. `currentVariantSelection` says where that +value came from: a string is an effort chosen in the picker or by the shortcut, +`null` is an explicit `Default`, and `undefined` is automatic initialization, +which lets the inherited default apply. + +`Default` sends no effort at all. It cannot resolve back to the inherited +default: the settings default would take effect again, and the next assistant +reply echoes that effort back as an explicit choice, so the picker jumps off +`Default` one message after the user chose it. For the same reason the +per-session selection store records an explicit `Default` (as `null`) instead of +clearing the entry — a cleared entry is indistinguishable from never having +chosen, and the settings default wins again on the next agent or session switch. + +Only a place where the user chose may write `null`. Restore paths — message +history, a preserved manual override — pass their own "found nothing" through +as `undefined`, because a session whose history carries no effort is not a +session where `Default` was picked. A restore that manufactures `null` latches +the session onto `Default`: `null` outranks the agent and settings defaults by +design, so the concrete effort it displaced can never come back. + +Every write of `currentVariant` writes `currentVariantSelection` with it. They +are one selection; updating only the effective value leaves the picker showing +one effort while sends carry another. Every loader and mutation takes an explicit directory; omitting it means the active project, which is what non-Settings callers pass. A load for another @@ -315,9 +338,11 @@ Do not raise limits casually. Expected model: - `GitView` / `DiffView` ensure current-directory Git state when visible +- the Git view gates its status-derived content and actions while a managed worktree bootstrap is pending, then keeps the gate closed until one forced fresh status read succeeds; refresh failure exposes retry without revealing the cached bootstrap snapshot - explicit Git actions refresh status/branches/log as needed - every status-affecting git mutation invalidates the HTTP adapter's status cache on its success path (failed mutations invalidate nothing), so the follow-up refresh is authoritative instead of the pre-mutation cache entry -- a mounted file-mutating tool issues a one-shot Git refresh hint when it transitions from active to successfully finalized; remounting historical completed tools does not replay the hint +- the sync event handler issues one Git refresh hint when a live file-mutating tool first reaches `completed`; this does not depend on `ToolPart` mounting, and duplicate terminal events do not replay the hint +- every Git refresh hint invalidates the store request generation and the HTTP status cache before visible consumers request status, so they share one post-mutation read instead of accepting a cached or pre-mutation response - a successful dirty save from the in-app file editor issues a path-scoped Git refresh hint; clean autosave checks remain no-ops - refresh hints with authoritative file paths invalidate only those cached and currently rendered diffs before status refresh; pathless tools request status reconciliation without broadly remounting DiffView - targeted diff remounts preserve the user's current file-section anchor and intra-file offset before paint instead of resetting the stacked view to the top diff --git a/packages/ui/src/stores/contextStore.ts b/packages/ui/src/stores/contextStore.ts index ad824f15..7ee4d7c0 100644 --- a/packages/ui/src/stores/contextStore.ts +++ b/packages/ui/src/stores/contextStore.ts @@ -24,8 +24,10 @@ interface ContextState { sessionAgentModelSelections: Map>; - // sessionId → agentName → "providerId/modelId" → variant - sessionAgentModelVariantSelections: Map>>; + // sessionId → agentName → "providerId/modelId" → variant, where `null` is + // an explicit "Default" (send no effort) and a missing entry means the + // inherited default applies. + sessionAgentModelVariantSelections: Map>>; currentAgentContext: Map; @@ -45,8 +47,8 @@ interface ContextActions { saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void; getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null; - saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void; - getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined; + saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => void; + getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | null | undefined; getContextUsage: (sessionId: string, contextLimit: number, outputLimit: number, messages: Map) => ContextUsage | null; @@ -145,7 +147,7 @@ export const useContextStore = create()( return agentMap.get(agentName) || null; }, - saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => { + saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => { set((state) => { const newSelections = new Map(state.sessionAgentModelVariantSelections); diff --git a/packages/ui/src/stores/messageQueueStore.server.test.ts b/packages/ui/src/stores/messageQueueStore.server.test.ts new file mode 100644 index 00000000..892de098 --- /dev/null +++ b/packages/ui/src/stores/messageQueueStore.server.test.ts @@ -0,0 +1,259 @@ +import { beforeEach, describe, expect, mock, test } from "bun:test" +import type { AttachedFile } from "./types/sessionTypes" +import type { MessageQueueUpdatedEvent } from "./messageQueueStore" + +type FetchCall = { path: string; method: string; body: ReturnType } +let calls: FetchCall[] = [] +let respond: (call: FetchCall) => Response = () => new Response("{}", { status: 200 }) + +mock.module("@/lib/runtime-fetch", () => ({ + runtimeFetch: async (path: string, init?: RequestInit) => { + const call = { + path, + method: init?.method ?? "GET", + body: init?.body === undefined ? undefined : JSON.parse(String(init.body)), + } + calls.push(call) + return respond(call) + }, +})) +const desktop = await import("@/lib/desktop") +mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => false })) +mock.module("@/lib/runtime-switch", () => ({ getRuntimeKey: () => "runtime-a" })) +mock.module("@/lib/persistence", () => ({ updateDesktopSettings: async () => undefined })) + +const { + applyMessageQueueUpdatedEvent, + createMessageQueueTarget, + getMessageQueueKey, + useMessageQueueStore, +} = await import("./messageQueueStore") + +type ServerItem = MessageQueueUpdatedEvent["properties"]["session"]["items"][number] +type ServerSession = MessageQueueUpdatedEvent["properties"]["session"] + +type ServerReply = { + revision: number + session?: ServerSession + sessions?: ServerSession[] + item?: ServerItem + items?: ServerItem[] +} + +const json = (value: ServerReply, status = 200) => new Response(JSON.stringify(value), { status }) + +const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! +const key = getMessageQueueKey(target) + +const serverItem = (id: string, content: string, extra: Partial = {}): ServerItem => ({ + id, + createdAt: 1, + content, + text: content, + attachments: [], + sendConfig: { providerID: "p", modelID: "m" }, + ...extra, +}) + +const issueMetadata = { openchamberContext: { kind: "github-issue" as const, number: 3, title: "Bug", url: "https://x/issues/3" } } + +const session = (items: ServerItem[], sendingId: string | null = null): ServerSession => ({ + sessionId: "session-1", + directory: "/repo", + items, + sendingId, +}) + +const updated = (revision: number, updatedSession: ServerSession): MessageQueueUpdatedEvent => ({ + type: "openchamber:message-queue.updated", + properties: { revision, session: updatedSession }, +}) + +const attachment: AttachedFile = { + id: "att-1", + file: new File(["hi"], "note.txt", { type: "text/plain" }), + dataUrl: "data:text/plain;base64,aGk=", + mimeType: "text/plain", + filename: "note.txt", + size: 2, + source: "local", +} + +beforeEach(() => { + calls = [] + respond = () => json({ revision: 1, session: session([]) }) + // Forgetting also drops the revision guard, so each test starts unordered. + useMessageQueueStore.getState().forgetQueue(target) + useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} }) +}) + +describe("server-owned message queue", () => { + // First: the one-time upload of a legacy local queue happens before this + // runtime is known to be server-owned, which the later hydrations establish. + test("hydrate uploads messages queued by an older build before reading the server", async () => { + useMessageQueueStore.setState({ + queuedMessages: { + [key]: [{ id: "local-1", content: "from before", text: "from before", createdAt: 1, sendConfig: { providerID: "p", modelID: "m" } }], + }, + }) + respond = (call) => (call.method === "POST" + ? json({ revision: 2, session: session([serverItem("q1", "from before")]) }) + : json({ revision: 2, sessions: [session([serverItem("q1", "from before")])] })) + await useMessageQueueStore.getState().hydrate() + + expect(calls[0]).toEqual({ + method: "POST", + path: "/api/message-queue/sessions/session-1/items", + body: { directory: "/repo", item: { content: "from before", text: "from before", attachments: [], context: [], sendConfig: { providerID: "p", modelID: "m" } } }, + }) + expect(calls[1]?.path).toBe("/api/message-queue") + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"]) + }) + + test("hydrate replaces the runtime's projection with the server queue", async () => { + respond = () => json({ revision: 3, sessions: [session([serverItem("q1", "hello")], "q1")] }) + await useMessageQueueStore.getState().hydrate() + + expect(calls.map((call) => `${call.method} ${call.path}`)).toEqual(["GET /api/message-queue"]) + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["hello"]) + expect(useMessageQueueStore.getState().sendingIds[key]).toEqual(["q1"]) + }) + + test("addToQueue shows the message at once and settles on the server's copy", async () => { + respond = () => json({ revision: 5, session: session([serverItem("srv-1", "hi @reviewer", { agentMention: "reviewer" })]) }) + const pending = useMessageQueueStore.getState().addToQueue(target, { + content: "hi @reviewer", + text: "hi", + agentMention: "reviewer", + attachments: [attachment], + sendConfig: { providerID: "p", modelID: "m", agent: "build" }, + }) + expect(useMessageQueueStore.getState().queuedMessages[key]).toHaveLength(1) + await pending + + expect(calls[0]).toEqual({ + method: "POST", + path: "/api/message-queue/sessions/session-1/items", + body: { + directory: "/repo", + item: { + content: "hi @reviewer", + text: "hi", + agentMention: "reviewer", + attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: attachment.dataUrl }], + context: [], + sendConfig: { providerID: "p", modelID: "m", agent: "build" }, + }, + }, + }) + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["srv-1"]) + }) + + test("addToQueue hands the captured context to the server, and a take brings it back", async () => { + const context = [ + { kind: "context" as const, text: "issue body", metadata: issueMetadata }, + { kind: "synthetic" as const, text: "conflict payload" }, + ] + respond = () => json({ revision: 6, session: session([serverItem("srv-1", "with context")]) }) + await useMessageQueueStore.getState().addToQueue(target, { + content: "with context", + context, + sendConfig: { providerID: "p", modelID: "m" }, + }) + expect(calls[0]?.body.item.context).toEqual(context) + // The projection carries no context; the server strips payloads from snapshots. + expect(useMessageQueueStore.getState().queuedMessages[key]?.[0]?.context).toBe(undefined) + + respond = () => json({ revision: 7, session: session([]), item: serverItem("srv-1", "with context", { context }) }) + const [taken] = await useMessageQueueStore.getState().takeForSend(target, "srv-1") + expect(taken?.context).toEqual(context) + expect(taken?.text).toBe("with context") + }) + + test("a server item with malformed context is rejected at the boundary", async () => { + respond = () => new Response(JSON.stringify({ + revision: 8, + session: session([]), + item: { ...serverItem("srv-1", "x"), context: [{ kind: "context", text: "x", metadata: { openchamberContext: { kind: "nope" } } }] }, + }), { status: 200 }) + await expect(useMessageQueueStore.getState().takeForSend(target, "srv-1")).rejects.toThrow() + }) + + test("addToQueue rolls the optimistic entry back when the server refuses", async () => { + respond = () => new Response("nope", { status: 500 }) + await expect(useMessageQueueStore.getState().addToQueue(target, { + content: "x", + sendConfig: { providerID: "p", modelID: "m" }, + })).rejects.toThrow() + expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) + }) + + test("addToQueue refuses a message with no captured model", async () => { + await expect(useMessageQueueStore.getState().addToQueue(target, { content: "x" })).rejects.toThrow() + expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) + expect(calls).toHaveLength(0) + }) + + test("takeForSend brings the full message back, attachments included", async () => { + respond = () => json({ + revision: 7, + session: session([]), + item: serverItem("q1", "with file", { + attachments: [{ id: "att-1", filename: "note.txt", mimeType: "text/plain", size: 2, source: "local", dataUrl: "data:text/plain;base64,aGk=" }], + }), + }) + const [taken] = await useMessageQueueStore.getState().takeForSend(target, "q1") + + expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/items/q1/take") + expect(calls[0]?.method).toBe("POST") + expect(taken?.content).toBe("with file") + expect(taken?.attachments?.[0]?.dataUrl).toBe("data:text/plain;base64,aGk=") + expect(taken?.attachments?.[0]?.file.size).toBe(2) + expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) + }) + + test("takeForSend without an id takes everything the server is not already sending", async () => { + respond = () => json({ revision: 8, session: session([serverItem("q1", "in flight")], "q1"), items: [serverItem("q2", "second")] }) + const taken = await useMessageQueueStore.getState().takeForSend(target) + + expect(calls[0]?.path).toBe("/api/message-queue/sessions/session-1/take") + expect(calls[0]?.method).toBe("POST") + expect(taken.map((m) => m.content)).toEqual(["second"]) + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q1"]) + }) + + test("broadcasts update the projection but never move it backwards", () => { + applyMessageQueueUpdatedEvent(updated(4, session([serverItem("q1", "newer")])), "runtime-a") + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"]) + + applyMessageQueueUpdatedEvent(updated(2, session([serverItem("q0", "older")])), "runtime-a") + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"]) + + applyMessageQueueUpdatedEvent(updated(9, session([serverItem("q1", "newer")])), "runtime-b") + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.content)).toEqual(["newer"]) + }) + + test("removeFromQueue and clearQueue update locally and tell the server", async () => { + useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "b", createdAt: 2 }] } }) + respond = () => json({ revision: 10, session: session([serverItem("q2", "b")]) }) + useMessageQueueStore.getState().removeFromQueue(target, "q1") + expect(useMessageQueueStore.getState().queuedMessages[key]?.map((m) => m.id)).toEqual(["q2"]) + await Promise.resolve() + await Promise.resolve() + expect(calls[0]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1/items/q1", body: undefined }) + + respond = () => json({ revision: 11, session: session([]) }) + useMessageQueueStore.getState().clearQueue(target) + expect(useMessageQueueStore.getState().queuedMessages[key]).toBe(undefined) + await Promise.resolve() + expect(calls[1]).toEqual({ method: "DELETE", path: "/api/message-queue/sessions/session-1", body: undefined }) + }) + + test("reorderQueue sends the complete new order", async () => { + useMessageQueueStore.setState({ queuedMessages: { [key]: [{ id: "q1", content: "a", text: "a", createdAt: 1 }, { id: "q2", content: "b", text: "b", createdAt: 2 }] } }) + respond = () => json({ revision: 12, session: session([serverItem("q2", "b"), serverItem("q1", "a")]) }) + useMessageQueueStore.getState().reorderQueue(target, "q2", "q1") + await Promise.resolve() + expect(calls[0]).toEqual({ method: "PUT", path: "/api/message-queue/sessions/session-1/order", body: { itemIds: ["q2", "q1"] } }) + }) +}) diff --git a/packages/ui/src/stores/messageQueueStore.test.ts b/packages/ui/src/stores/messageQueueStore.test.ts index 8d4b237d..0737b9a0 100644 --- a/packages/ui/src/stores/messageQueueStore.test.ts +++ b/packages/ui/src/stores/messageQueueStore.test.ts @@ -1,11 +1,17 @@ -import { beforeEach, describe, expect, test } from "bun:test" -import { +import { beforeEach, describe, expect, mock, test } from "bun:test" + +// The local queue is the VS Code behavior; every other runtime hands the +// queue to the server (see messageQueueStore.server.test.ts). +const desktop = await import("@/lib/desktop") +mock.module("@/lib/desktop", () => ({ ...desktop, isVSCodeRuntime: () => true })) + +const { createMessageQueueTarget, getMessageQueueKey, migrateMessageQueueState, parseMessageQueueKey, useMessageQueueStore, -} from "./messageQueueStore" +} = await import("./messageQueueStore") beforeEach(() => { useMessageQueueStore.setState({ queuedMessages: {}, quarantinedLegacyMessages: {}, sendingIds: {} }) @@ -38,6 +44,37 @@ describe("message queue runtime ownership", () => { expect(migrated.quarantinedLegacyMessages?.["session-1"]?.[0]?.content).toBe("legacy") }) + test("keeps what was captured at queue time on the local message", () => { + const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! + const context = [{ kind: "synthetic" as const, text: "conflict payload" }] + useMessageQueueStore.getState().addToQueue(target, { + content: "@Builder do it", + text: "do it", + agentMention: "Builder", + context, + }) + useMessageQueueStore.getState().addToQueue(target, { content: "plain" }) + + const [captured, plain] = useMessageQueueStore.getState().getQueueForTarget(target) + expect(captured?.content).toBe("@Builder do it") + expect(captured?.text).toBe("do it") + expect(captured?.agentMention).toBe("Builder") + expect(captured?.context).toEqual(context) + expect(plain?.text).toBe("plain") + expect(plain?.agentMention).toBe(undefined) + expect(plain?.context).toBe(undefined) + }) + + test("messages persisted before delivery text existed deliver their content", () => { + const migrated = migrateMessageQueueState({ + queuedMessages: { + "runtime-a\n/repo\nsession-1": [{ id: "queued-1", content: "@Builder old", createdAt: 1 }], + }, + }, 2) + + expect(migrated.queuedMessages?.["runtime-a\n/repo\nsession-1"]?.[0]?.text).toBe("@Builder old") + }) + test("bounds each queue to the newest 20 messages", () => { const target = createMessageQueueTarget("session-1", "/repo", "runtime-a")! for (let index = 0; index < 25; index += 1) { diff --git a/packages/ui/src/stores/messageQueueStore.ts b/packages/ui/src/stores/messageQueueStore.ts index 8ee096ec..3341779e 100644 --- a/packages/ui/src/stores/messageQueueStore.ts +++ b/packages/ui/src/stores/messageQueueStore.ts @@ -1,9 +1,14 @@ import { create } from 'zustand'; import { devtools, persist } from 'zustand/middleware'; +import { z } from 'zod'; +import type { Event } from '@opencode-ai/sdk/v2'; import { createDeferredSafeJSONStorage } from './utils/safeStorage'; import type { AttachedFile } from './types/sessionTypes'; +import { contextPartMetadataSchema, type ContextPartMetadata } from '@/lib/messages/contextParts'; import { updateDesktopSettings } from '@/lib/persistence'; import { getRuntimeKey } from '@/lib/runtime-switch'; +import { isVSCodeRuntime } from '@/lib/desktop'; +import { runtimeFetch } from '@/lib/runtime-fetch'; import { normalizePath } from '@/lib/pathNormalization'; export type FollowUpBehavior = 'steer' | 'queue'; @@ -40,18 +45,71 @@ export const normalizeFollowUpBehavior = ( return DEFAULT_FOLLOW_UP_BEHAVIOR; }; +/** + * Who delivers the queue. Web, desktop, and mobile talk to an OpenChamber + * server that owns the queue and sends it whether or not any UI is open. VS + * Code has no server of its own, so the extension UI keeps the local queue + * and the foreground auto-send hook. + */ +export const isServerOwnedMessageQueue = (): boolean => !isVSCodeRuntime(); + +export interface QueuedMessageSendConfig { + providerID: string; + modelID: string; + agent?: string; + variant?: string; +} + +/** + * Context captured with a queued message: whatever the composer had attached + * when the message was queued. It leaves the composer with the message, so + * delivery (by the server, or by the auto-send hook in VS Code) carries it and + * editing the message brings it back. + */ +export type QueuedContextPart = + | { + /** An attached context item: a draft chip or a linked issue/PR. Restored on edit. */ + kind: 'context'; + text: string; + metadata: ContextPartMetadata; + /** Delivered as its own synthetic part right before this one (a linked PR's reading instructions). */ + instructions?: string; + } + | { + /** Derived from the message text (the skill instruction); re-derived when the text is sent again, so never restored. */ + kind: 'instruction'; + text: string; + } + | { + /** Handed to the composer by another surface (conflict resolution); restored as pending on edit. */ + kind: 'synthetic'; + text: string; + }; + export interface QueuedMessage { id: string; + /** What the user typed, for display and editing. */ content: string; + /** What is delivered: `content` without its leading agent mention, file mentions already resolved. */ + text: string; + /** Agent mentioned at the start of `content`, delivered as an agent part. */ + agentMention?: string; attachments?: AttachedFile[]; + /** Absent on a server projection item; a take brings it back. */ + context?: QueuedContextPart[]; createdAt: number; /** Send config captured at queue time — used as-is when auto-sending */ - sendConfig?: { - providerID: string; - modelID: string; - agent?: string; - variant?: string; - }; + sendConfig?: QueuedMessageSendConfig; +} + +interface QueuedMessageInput { + content: string; + /** Defaults to `content`. */ + text?: string; + agentMention?: string; + attachments?: AttachedFile[]; + context?: QueuedContextPart[]; + sendConfig?: QueuedMessageSendConfig; } export type MessageQueueTarget = { @@ -81,6 +139,199 @@ export const parseMessageQueueKey = (key: string): MessageQueueTarget | null => return createMessageQueueTarget(sessionParts.join('\n'), directory, runtimeKey); }; +// --------------------------------------------------------------------------- +// Server contract (packages/web/server/lib/message-queue) +// --------------------------------------------------------------------------- + +const serverSendConfigSchema = z.object({ + providerID: z.string().min(1), + modelID: z.string().min(1), + agent: z.string().optional(), + variant: z.string().optional(), +}); + +const serverAttachmentSchema = z.object({ + id: z.string(), + filename: z.string(), + mimeType: z.string(), + size: z.number(), + source: z.enum(['local', 'server', 'vscode']), + serverPath: z.string().optional(), + /** Present only on a taken item; broadcasts and snapshots omit payloads. */ + dataUrl: z.string().optional(), +}); + +const serverContextPartSchema = z.discriminatedUnion('kind', [ + z.object({ + kind: z.literal('context'), + text: z.string(), + metadata: contextPartMetadataSchema, + instructions: z.string().optional(), + }), + z.object({ kind: z.literal('instruction'), text: z.string() }), + z.object({ kind: z.literal('synthetic'), text: z.string() }), +]); + +const serverItemSchema = z.object({ + id: z.string().min(1), + createdAt: z.number(), + content: z.string(), + text: z.string(), + agentMention: z.string().optional(), + attachments: z.array(serverAttachmentSchema), + /** Present only on a taken item; broadcasts and snapshots omit it like attachment payloads. */ + context: z.array(serverContextPartSchema).optional(), + sendConfig: serverSendConfigSchema, +}); + +const serverSessionSchema = z.object({ + sessionId: z.string().min(1), + directory: z.string(), + items: z.array(serverItemSchema), + sendingId: z.string().nullable(), +}); + +const serverSnapshotSchema = z.object({ + revision: z.number(), + sessions: z.array(serverSessionSchema), +}); + +const serverSessionResponseSchema = z.object({ + revision: z.number(), + session: serverSessionSchema, +}); + +const serverTakeResponseSchema = serverSessionResponseSchema.extend({ item: serverItemSchema }); +const serverTakeAllResponseSchema = serverSessionResponseSchema.extend({ items: z.array(serverItemSchema) }); + +type ServerQueueSession = z.infer; +type ServerQueueItem = z.infer; +type ServerQueueAttachment = z.infer; + +const decodeDataUrl = (dataUrl: string): ArrayBuffer | null => { + const commaIndex = dataUrl.indexOf(','); + if (!dataUrl.startsWith('data:') || commaIndex === -1) return null; + const meta = dataUrl.slice(5, commaIndex); + const payload = dataUrl.slice(commaIndex + 1); + try { + if (meta.endsWith(';base64')) { + const binary = atob(payload); + const buffer = new ArrayBuffer(binary.length); + const bytes = new Uint8Array(buffer); + for (let index = 0; index < binary.length; index += 1) bytes[index] = binary.charCodeAt(index); + return buffer; + } + const encoded = new TextEncoder().encode(decodeURIComponent(payload)); + const buffer = new ArrayBuffer(encoded.byteLength); + new Uint8Array(buffer).set(encoded); + return buffer; + } catch { + return null; + } +}; + +/** A taken item carries its payload; a projection item has an empty file. */ +const toAttachedFile = (attachment: ServerQueueAttachment): AttachedFile => { + const dataUrl = attachment.dataUrl ?? ''; + const bytes = dataUrl ? decodeDataUrl(dataUrl) : null; + const file: AttachedFile = { + id: attachment.id, + file: new File(bytes ? [bytes] : [], attachment.filename, { type: attachment.mimeType }), + dataUrl, + mimeType: attachment.mimeType, + filename: attachment.filename, + size: attachment.size, + source: attachment.source, + }; + if (attachment.serverPath) file.serverPath = attachment.serverPath; + return file; +}; + +const toQueuedMessage = (item: ServerQueueItem): QueuedMessage => { + const message: QueuedMessage = { + id: item.id, + content: item.content, + text: item.text, + createdAt: item.createdAt, + sendConfig: { ...item.sendConfig }, + }; + if (item.agentMention) message.agentMention = item.agentMention; + if (item.attachments.length > 0) message.attachments = item.attachments.map(toAttachedFile); + if (item.context) message.context = item.context; + return message; +}; + +type ServerQueueAttachmentInput = Omit & { dataUrl: string }; + +type ServerQueueItemInput = { + content: string; + text: string; + agentMention?: string; + attachments: ServerQueueAttachmentInput[]; + context: QueuedContextPart[]; + sendConfig: QueuedMessageSendConfig; +}; + +type ServerQueueRequestBody = + | { directory: string; item: ServerQueueItemInput } + | { itemIds: string[] } + | { held: boolean }; + +const toServerAttachment = (attachment: AttachedFile): ServerQueueAttachmentInput => { + const input: ServerQueueAttachmentInput = { + id: attachment.id, + filename: attachment.filename, + mimeType: attachment.mimeType, + size: attachment.size, + source: attachment.source, + dataUrl: attachment.dataUrl, + }; + if (attachment.serverPath) input.serverPath = attachment.serverPath; + return input; +}; + +const toServerItemInput = (message: QueuedMessageInput, sendConfig: QueuedMessageSendConfig): ServerQueueItemInput => { + const item: ServerQueueItemInput = { + content: message.content, + text: message.text ?? message.content, + attachments: (message.attachments ?? []).filter((file) => Boolean(file.dataUrl)).map(toServerAttachment), + context: message.context ?? [], + sendConfig, + }; + if (message.agentMention) item.agentMention = message.agentMention; + return item; +}; + +const requestJson = async (schema: z.ZodType, path: string, init?: RequestInit): Promise => { + const response = await runtimeFetch(path, init); + if (!response.ok) { + const error: Error & { status?: number } = new Error(`Message queue request failed (${response.status})`); + error.status = response.status; + throw error; + } + const parsed = schema.safeParse(await response.json()); + if (!parsed.success) throw new Error('Invalid message queue response'); + return parsed.data; +}; + +const jsonInit = (method: string, body?: ServerQueueRequestBody): RequestInit => { + if (body === undefined) return { method }; + return { method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }; +}; + +const sessionPath = (sessionId: string) => `/api/message-queue/sessions/${encodeURIComponent(sessionId)}`; + +/** + * Runtime keys whose queue the server owns, established by a successful + * hydration. Their entries are a projection and must not be persisted: a + * stale local copy would resurrect messages the server already delivered. + */ +const serverOwnedRuntimeKeys = new Set(); + +/** Server revision last applied per queue key; older snapshots are ignored. */ +const appliedRevisions = new Map(); +let hydrationGeneration = 0; + interface MessageQueueState { queuedMessages: Record; // runtime + directory + session → queue quarantinedLegacyMessages: Record; @@ -95,231 +346,432 @@ interface MessageQueueState { * twice. Dispatchers must skip entries listed here. * * Never persisted: a restart has no in-flight sends, and a stale flag would - * strand a queued message permanently. + * strand a queued message permanently. With a server-owned queue this + * mirrors the server's in-flight item. */ sendingIds: Record; } interface MessageQueueActions { - addToQueue: (target: MessageQueueTarget, message: Omit) => void; + addToQueue: (target: MessageQueueTarget, message: QueuedMessageInput) => Promise; removeFromQueue: (target: MessageQueueTarget, messageId: string) => void; reorderQueue: (target: MessageQueueTarget, fromId: string, toId: string) => void; - popToInput: (target: MessageQueueTarget, messageId: string) => QueuedMessage | null; + /** Removes the message and returns it in full, attachments included. */ + popToInput: (target: MessageQueueTarget, messageId: string) => Promise; + /** + * Removes what the composer is about to send itself — one message or every + * message not already being delivered — and returns it in full. + */ + takeForSend: (target: MessageQueueTarget, messageId?: string) => Promise; clearQueue: (target: MessageQueueTarget) => void; + /** Drops the local projection only (the session is gone); never a server call. */ + forgetQueue: (target: MessageQueueTarget) => void; clearAllQueues: () => void; markSending: (target: MessageQueueTarget, messageId: string) => void; clearSending: (target: MessageQueueTarget, messageId: string) => void; getSendableQueue: (target: MessageQueueTarget) => QueuedMessage[]; setFollowUpBehavior: (behavior: FollowUpBehavior) => void; getQueueForTarget: (target: MessageQueueTarget) => QueuedMessage[]; + /** Server-owned queue: load the authoritative queue for the active runtime. */ + hydrate: () => Promise; + /** Server-owned queue: apply one session's authoritative state (broadcast or response). */ + applyServerSession: (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => void; + /** Server-owned queue: tell the server to hold or release a session's delivery. */ + setServerHold: (sessionId: string, held: boolean) => Promise; + resetForRuntimeSwitch: (previousRuntimeKey: string | null | undefined) => void; } type MessageQueueStore = MessageQueueState & MessageQueueActions; +/** Messages persisted before version 3 carried only `content`. */ +type PersistedQueuedMessage = Omit & { text?: string }; + type PersistedMessageQueueState = { - queuedMessages?: Record; - quarantinedLegacyMessages?: Record; + queuedMessages?: Record; + quarantinedLegacyMessages?: Record; followUpBehavior?: FollowUpBehavior; queueModeEnabled?: boolean; }; +const withDeliveryText = (queues: Record): Record => ( + Object.fromEntries(Object.entries(queues).map(([key, queue]) => [ + key, + queue.map((message) => ({ ...message, text: message.text ?? message.content })), + ])) +); + export const migrateMessageQueueState = (persistedState: unknown, version: number): Partial => { const state = (persistedState ?? {}) as PersistedMessageQueueState; const legacyQueues = version < 2 ? (state.queuedMessages ?? {}) : {}; return { - queuedMessages: version < 2 ? {} : (state.queuedMessages ?? {}), - quarantinedLegacyMessages: { + queuedMessages: version < 2 ? {} : withDeliveryText(state.queuedMessages ?? {}), + quarantinedLegacyMessages: withDeliveryText({ ...(state.quarantinedLegacyMessages ?? {}), ...legacyQueues, - }, + }), followUpBehavior: normalizeFollowUpBehavior(state.followUpBehavior, state.queueModeEnabled ?? null), }; }; +const withoutKey = (record: Record, key: string): Record => { + const { [key]: _removed, ...rest } = record; + void _removed; + return rest; +}; + +const removeMessageLocally = ( + state: Pick, + key: string, + messageId: string, +): Pick => { + const newQueue = (state.queuedMessages[key] ?? []).filter((m) => m.id !== messageId); + if (newQueue.length === 0) return { queuedMessages: withoutKey(state.queuedMessages, key) }; + return { queuedMessages: { ...state.queuedMessages, [key]: newQueue } }; +}; + export const useMessageQueueStore = create()( devtools( persist( - (set, get) => ({ - queuedMessages: {}, - quarantinedLegacyMessages: {}, - followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, - sendingIds: {}, - - addToQueue: (target, message) => { + (set, get) => { + const applyServerSession = (session: ServerQueueSession, revision: number, expectedRuntimeKey: string) => { + if (expectedRuntimeKey !== getRuntimeKey()) return; + const target = createMessageQueueTarget(session.sessionId, session.directory, expectedRuntimeKey); + if (!target) return; const key = getMessageQueueKey(target); - const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; - const queuedMessage: QueuedMessage = { - id, - content: message.content, - attachments: message.attachments, - createdAt: Date.now(), - sendConfig: message.sendConfig, - }; - + if ((appliedRevisions.get(key) ?? -1) > revision) return; + appliedRevisions.set(key, revision); set((state) => { - const currentQueue = state.queuedMessages[key] ?? []; - const queuedMessages = { - ...state.queuedMessages, - [key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE), - }; - const keys = Object.keys(queuedMessages); - if (keys.length > MAX_QUEUE_TARGETS) { - keys.sort((left, right) => ( - (queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0) - )); - for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey]; - } - return { - queuedMessages, - }; + const queue = session.items.map(toQueuedMessage); + const queuedMessages = queue.length > 0 + ? { ...state.queuedMessages, [key]: queue } + : withoutKey(state.queuedMessages, key); + const sendingIds = session.sendingId + ? { ...state.sendingIds, [key]: [session.sendingId] } + : withoutKey(state.sendingIds, key); + return { queuedMessages, sendingIds }; }); - }, + }; - removeFromQueue: (target, messageId) => { - const key = getMessageQueueKey(target); - set((state) => { - const currentQueue = state.queuedMessages[key] ?? []; - const newQueue = currentQueue.filter((m) => m.id !== messageId); - - if (newQueue.length === 0) { - const { [key]: _removed, ...rest } = state.queuedMessages; - void _removed; - return { queuedMessages: rest }; - } - - return { - queuedMessages: { + /** Server state wins; a failed round-trip re-reads it instead of guessing. */ + const refreshSession = async (target: MessageQueueTarget) => { + try { + const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue'); + const session = snapshot.sessions.find((entry) => entry.sessionId === target.sessionId) + ?? { sessionId: target.sessionId, directory: target.directory, items: [], sendingId: null }; + applyServerSession(session, snapshot.revision, target.runtimeKey); + } catch { + // Offline: keep the optimistic projection; the next broadcast or hydration corrects it. + } + }; + + const serverMutation = async ( + target: MessageQueueTarget, + path: string, + init: RequestInit, + ) => { + try { + const result = await requestJson(serverSessionResponseSchema, path, init); + applyServerSession(result.session, result.revision, target.runtimeKey); + } catch (error) { + console.warn('[queue] server update failed:', error); + await refreshSession(target); + } + }; + + return { + queuedMessages: {}, + quarantinedLegacyMessages: {}, + followUpBehavior: DEFAULT_FOLLOW_UP_BEHAVIOR, + sendingIds: {}, + + addToQueue: async (target, message) => { + const key = getMessageQueueKey(target); + const id = `queued-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`; + const queuedMessage: QueuedMessage = { + id, + content: message.content, + text: message.text ?? message.content, + createdAt: Date.now(), + sendConfig: message.sendConfig, + }; + if (message.agentMention) queuedMessage.agentMention = message.agentMention; + if (message.attachments && message.attachments.length > 0) queuedMessage.attachments = message.attachments; + if (message.context && message.context.length > 0) queuedMessage.context = message.context; + + set((state) => { + const currentQueue = state.queuedMessages[key] ?? []; + const queuedMessages = { ...state.queuedMessages, - [key]: newQueue, - }, - }; - }); - }, + [key]: [...currentQueue, queuedMessage].slice(-MAX_MESSAGES_PER_QUEUE), + }; + const keys = Object.keys(queuedMessages); + if (keys.length > MAX_QUEUE_TARGETS) { + keys.sort((left, right) => ( + (queuedMessages[left]?.[0]?.createdAt ?? 0) - (queuedMessages[right]?.[0]?.createdAt ?? 0) + )); + for (const staleKey of keys.slice(0, keys.length - MAX_QUEUE_TARGETS)) delete queuedMessages[staleKey]; + } + return { + queuedMessages, + }; + }); - reorderQueue: (target, fromId, toId) => { - if (fromId === toId) return; - const key = getMessageQueueKey(target); - set((state) => { - const currentQueue = state.queuedMessages[key]; - if (!currentQueue) return state; + if (!isServerOwnedMessageQueue()) return; + if (!message.sendConfig) { + set((state) => removeMessageLocally(state, key, id)); + throw new Error('A queued message needs a provider and model to be delivered later.'); + } + try { + const result = await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { + directory: target.directory, + item: toServerItemInput(message, message.sendConfig), + })); + // The optimistic entry is replaced by the server's copy of the queue. + set((state) => removeMessageLocally(state, key, id)); + applyServerSession(result.session, result.revision, target.runtimeKey); + } catch (error) { + set((state) => removeMessageLocally(state, key, id)); + throw error; + } + }, + + removeFromQueue: (target, messageId) => { + const key = getMessageQueueKey(target); + set((state) => removeMessageLocally(state, key, messageId)); + if (isServerOwnedMessageQueue()) { + void serverMutation(target, `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}`, jsonInit('DELETE')); + } + }, + + reorderQueue: (target, fromId, toId) => { + if (fromId === toId) return; + const key = getMessageQueueKey(target); + const currentQueue = get().queuedMessages[key]; + if (!currentQueue) return; const fromIndex = currentQueue.findIndex((m) => m.id === fromId); const toIndex = currentQueue.findIndex((m) => m.id === toId); - if (fromIndex === -1 || toIndex === -1) return state; + if (fromIndex === -1 || toIndex === -1) return; const newQueue = currentQueue.slice(); const [moved] = newQueue.splice(fromIndex, 1); newQueue.splice(toIndex, 0, moved); - return { + set((state) => ({ queuedMessages: { ...state.queuedMessages, [key]: newQueue, }, - }; - }); - }, - - popToInput: (target, messageId) => { - const key = getMessageQueueKey(target); - const state = get(); - const currentQueue = state.queuedMessages[key] ?? []; - const message = currentQueue.find((m) => m.id === messageId); - - if (!message) { - return null; - } - - // Remove from queue - set((prevState) => { - const queue = prevState.queuedMessages[key] ?? []; - const newQueue = queue.filter((m) => m.id !== messageId); - - if (newQueue.length === 0) { - const { [key]: _removed, ...rest } = prevState.queuedMessages; - void _removed; - return { queuedMessages: rest }; + })); + if (isServerOwnedMessageQueue()) { + const itemIds = newQueue.map((message) => message.id); + void serverMutation(target, `${sessionPath(target.sessionId)}/order`, jsonInit('PUT', { itemIds })); } - - return { - queuedMessages: { - ...prevState.queuedMessages, - [key]: newQueue, - }, - }; - }); + }, - return message; - }, + popToInput: async (target, messageId) => { + const [message] = await get().takeForSend(target, messageId); + return message ?? null; + }, - clearQueue: (target) => { - const key = getMessageQueueKey(target); - set((state) => { - // Clearing drops what is still queued, never a message - // already handed to the server: that send will resolve - // and must find its entry to remove or restore. + takeForSend: async (target, messageId) => { + const key = getMessageQueueKey(target); + if (isServerOwnedMessageQueue()) { + if (messageId) { + const result = await requestJson( + serverTakeResponseSchema, + `${sessionPath(target.sessionId)}/items/${encodeURIComponent(messageId)}/take`, + jsonInit('POST'), + ); + applyServerSession(result.session, result.revision, target.runtimeKey); + return [toQueuedMessage(result.item)]; + } + const result = await requestJson(serverTakeAllResponseSchema, `${sessionPath(target.sessionId)}/take`, jsonInit('POST')); + applyServerSession(result.session, result.revision, target.runtimeKey); + return result.items.map(toQueuedMessage); + } + + const state = get(); const sending = state.sendingIds[key] ?? []; - const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id)); - if (retained.length > 0) { - return { queuedMessages: { ...state.queuedMessages, [key]: retained } }; + const taken = (state.queuedMessages[key] ?? []).filter((message) => ( + (messageId ? message.id === messageId : true) && !sending.includes(message.id) + )); + if (taken.length === 0) return []; + const takenIds = new Set(taken.map((message) => message.id)); + set((prevState) => { + const remaining = (prevState.queuedMessages[key] ?? []).filter((message) => !takenIds.has(message.id)); + if (remaining.length === 0) return { queuedMessages: withoutKey(prevState.queuedMessages, key) }; + return { queuedMessages: { ...prevState.queuedMessages, [key]: remaining } }; + }); + return taken; + }, + + clearQueue: (target) => { + const key = getMessageQueueKey(target); + set((state) => { + // Clearing drops what is still queued, never a message + // already handed to the server: that send will resolve + // and must find its entry to remove or restore. + const sending = state.sendingIds[key] ?? []; + const retained = (state.queuedMessages[key] ?? []).filter((m) => sending.includes(m.id)); + if (retained.length > 0) { + return { queuedMessages: { ...state.queuedMessages, [key]: retained } }; + } + return { queuedMessages: withoutKey(state.queuedMessages, key) }; + }); + if (isServerOwnedMessageQueue()) { + void serverMutation(target, sessionPath(target.sessionId), jsonInit('DELETE')); } - const { [key]: _removed, ...rest } = state.queuedMessages; - void _removed; - return { queuedMessages: rest }; - }); - }, + }, - clearAllQueues: () => { - set({ queuedMessages: {}, sendingIds: {} }); - }, + forgetQueue: (target) => { + const key = getMessageQueueKey(target); + appliedRevisions.delete(key); + set((state) => ({ + queuedMessages: withoutKey(state.queuedMessages, key), + sendingIds: withoutKey(state.sendingIds, key), + })); + }, - markSending: (target, messageId) => { - const key = getMessageQueueKey(target); - set((state) => { - const current = state.sendingIds[key] ?? []; - if (current.includes(messageId)) return state; - return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } }; - }); - }, + clearAllQueues: () => { + set({ queuedMessages: {}, sendingIds: {} }); + }, - clearSending: (target, messageId) => { - const key = getMessageQueueKey(target); - set((state) => { - const current = state.sendingIds[key]; - if (!current || !current.includes(messageId)) return state; - const next = current.filter((id) => id !== messageId); - if (next.length === 0) { - const { [key]: _removed, ...rest } = state.sendingIds; - void _removed; - return { sendingIds: rest }; + markSending: (target, messageId) => { + const key = getMessageQueueKey(target); + set((state) => { + const current = state.sendingIds[key] ?? []; + if (current.includes(messageId)) return state; + return { sendingIds: { ...state.sendingIds, [key]: [...current, messageId] } }; + }); + }, + + clearSending: (target, messageId) => { + const key = getMessageQueueKey(target); + set((state) => { + const current = state.sendingIds[key]; + if (!current || !current.includes(messageId)) return state; + const next = current.filter((id) => id !== messageId); + if (next.length === 0) return { sendingIds: withoutKey(state.sendingIds, key) }; + return { sendingIds: { ...state.sendingIds, [key]: next } }; + }); + }, + + getSendableQueue: (target) => { + const key = getMessageQueueKey(target); + const state = get(); + const queue = state.queuedMessages[key] ?? []; + const sending = state.sendingIds[key]; + if (!sending || sending.length === 0) return queue; + return queue.filter((message) => !sending.includes(message.id)); + }, + + setFollowUpBehavior: (behavior) => { + set({ followUpBehavior: behavior }); + void updateDesktopSettings({ followUpBehavior: behavior }); + }, + + getQueueForTarget: (target) => { + return get().queuedMessages[getMessageQueueKey(target)] ?? []; + }, + + hydrate: async () => { + if (!isServerOwnedMessageQueue()) return; + const runtimeKey = getRuntimeKey(); + const generation = ++hydrationGeneration; + const isCurrent = () => generation === hydrationGeneration && runtimeKey === getRuntimeKey(); + + // Messages queued by an older build live in this browser only. + // Hand them to the server once so they are still delivered; + // whatever cannot be uploaded is superseded by the server's queue. + const legacyEntries = Object.entries(get().queuedMessages) + .map(([key, queue]) => ({ target: parseMessageQueueKey(key), queue })) + .filter((entry): entry is { target: MessageQueueTarget; queue: QueuedMessage[] } => ( + entry.target !== null && entry.target.runtimeKey === runtimeKey && !serverOwnedRuntimeKeys.has(runtimeKey) + )); + for (const { target, queue } of legacyEntries) { + for (const message of queue) { + if (!message.sendConfig) continue; + try { + await requestJson(serverSessionResponseSchema, `${sessionPath(target.sessionId)}/items`, jsonInit('POST', { + directory: target.directory, + item: toServerItemInput(message, message.sendConfig), + })); + } catch (error) { + console.warn('[queue] failed to migrate a locally queued message to the server:', error); + } + if (!isCurrent()) return; + } } - return { sendingIds: { ...state.sendingIds, [key]: next } }; - }); - }, - getSendableQueue: (target) => { - const key = getMessageQueueKey(target); - const state = get(); - const queue = state.queuedMessages[key] ?? []; - const sending = state.sendingIds[key]; - if (!sending || sending.length === 0) return queue; - return queue.filter((message) => !sending.includes(message.id)); - }, + const snapshot = await requestJson(serverSnapshotSchema, '/api/message-queue'); + if (!isCurrent()) return; + serverOwnedRuntimeKeys.add(runtimeKey); + set((state) => { + const queuedMessages: Record = {}; + const sendingIds: Record = {}; + for (const [key, queue] of Object.entries(state.queuedMessages)) { + if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) queuedMessages[key] = queue; + } + for (const [key, ids] of Object.entries(state.sendingIds)) { + if (parseMessageQueueKey(key)?.runtimeKey !== runtimeKey) sendingIds[key] = ids; + } + for (const session of snapshot.sessions) { + const target = createMessageQueueTarget(session.sessionId, session.directory, runtimeKey); + if (!target) continue; + const key = getMessageQueueKey(target); + if ((appliedRevisions.get(key) ?? -1) > snapshot.revision) { + // A broadcast newer than this snapshot already landed; keep it. + if (state.queuedMessages[key]) queuedMessages[key] = state.queuedMessages[key]; + if (state.sendingIds[key]) sendingIds[key] = state.sendingIds[key]; + continue; + } + appliedRevisions.set(key, snapshot.revision); + if (session.items.length > 0) queuedMessages[key] = session.items.map(toQueuedMessage); + if (session.sendingId) sendingIds[key] = [session.sendingId]; + } + return { queuedMessages, sendingIds }; + }); + }, - setFollowUpBehavior: (behavior) => { - set({ followUpBehavior: behavior }); - void updateDesktopSettings({ followUpBehavior: behavior }); - }, + applyServerSession, - getQueueForTarget: (target) => { - return get().queuedMessages[getMessageQueueKey(target)] ?? []; - }, - }), + setServerHold: async (sessionId, held) => { + if (!isServerOwnedMessageQueue()) return; + const response = await runtimeFetch(`${sessionPath(sessionId)}/hold`, jsonInit('PUT', { held })); + if (!response.ok) throw new Error(`Message queue hold request failed (${response.status})`); + }, + + resetForRuntimeSwitch: (previousRuntimeKey) => { + hydrationGeneration += 1; + if (!previousRuntimeKey || !serverOwnedRuntimeKeys.has(previousRuntimeKey)) return; + // The previous runtime's projection belongs to its server; + // switching back re-hydrates it from there. + set((state) => { + const queuedMessages: Record = {}; + const sendingIds: Record = {}; + for (const [key, queue] of Object.entries(state.queuedMessages)) { + if (parseMessageQueueKey(key)?.runtimeKey === previousRuntimeKey) appliedRevisions.delete(key); + else queuedMessages[key] = queue; + } + for (const [key, ids] of Object.entries(state.sendingIds)) { + if (parseMessageQueueKey(key)?.runtimeKey !== previousRuntimeKey) sendingIds[key] = ids; + } + return { queuedMessages, sendingIds }; + }); + }, + }; + }, { name: 'message-queue-store', - version: 2, + version: 3, storage: createDeferredSafeJSONStorage(), partialize: (state) => ({ - queuedMessages: state.queuedMessages, + queuedMessages: Object.fromEntries( + Object.entries(state.queuedMessages).filter(([key]) => { + const runtimeKey = parseMessageQueueKey(key)?.runtimeKey; + return !runtimeKey || !serverOwnedRuntimeKeys.has(runtimeKey); + }), + ), quarantinedLegacyMessages: state.quarantinedLegacyMessages, followUpBehavior: state.followUpBehavior, }), @@ -331,3 +783,21 @@ export const useMessageQueueStore = create()( } ) ); + +const serverUpdatedEventSchema = z.object({ + properties: z.object({ revision: z.number(), session: serverSessionSchema }), +}); + +export type MessageQueueUpdatedEvent = { + type: 'openchamber:message-queue.updated'; + properties: z.infer['properties']; +}; + +/** `openchamber:message-queue.updated` broadcast → projection. */ +export const applyMessageQueueUpdatedEvent = (payload: Event | MessageQueueUpdatedEvent, expectedRuntimeKey: string): void => { + if (!isServerOwnedMessageQueue()) return; + const parsed = serverUpdatedEventSchema.safeParse(payload); + if (!parsed.success) return; + const { session, revision } = parsed.data.properties; + useMessageQueueStore.getState().applyServerSession(session, revision, expectedRuntimeKey); +}; diff --git a/packages/ui/src/stores/useConfigStore.test.ts b/packages/ui/src/stores/useConfigStore.test.ts index 5ac465ff..2039ae52 100644 --- a/packages/ui/src/stores/useConfigStore.test.ts +++ b/packages/ui/src/stores/useConfigStore.test.ts @@ -544,7 +544,8 @@ describe('useConfigStore provider persistence', () => { useConfigStore.getState().setCurrentVariantOverride('max', 'high'); expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined); - expect(useConfigStore.getState().currentVariant).toBe('high'); + // Default is a choice to send no effort, not a way back to the inherited one. + expect(useConfigStore.getState().currentVariant).toBe(undefined); expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'high' }); }); @@ -562,7 +563,7 @@ describe('useConfigStore provider persistence', () => { expect(useConfigStore.getState().currentVariantSelection.override).toBe('high'); expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined); expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); - expect(useConfigStore.getState().currentVariant).toBe('high'); + expect(useConfigStore.getState().currentVariant).toBe(undefined); }); test('an unavailable explicit variant cycles back to Default', () => { @@ -576,7 +577,7 @@ describe('useConfigStore provider persistence', () => { }); expect(useConfigStore.getState().cycleCurrentVariant()).toBe(undefined); - expect(useConfigStore.getState().currentVariant).toBe('low'); + expect(useConfigStore.getState().currentVariant).toBe(undefined); expect(useConfigStore.getState().currentVariantSelection.override).toBeNull(); }); @@ -608,6 +609,73 @@ describe('useConfigStore provider persistence', () => { expect(useConfigStore.getState().currentVariant).toBe('medium'); }); + test('an explicit Default effort sends no variant instead of the settings default', () => { + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })], + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentVariant: 'low', + currentVariantSelection: { override: 'low', inherited: 'low' }, + settingsDefaultVariant: 'low', + directoryScoped: {}, + }); + + useConfigStore.getState().setCurrentVariantOverride(null, 'low'); + + expect(useConfigStore.getState().currentVariant).toBe(undefined); + expect(useConfigStore.getState().currentVariantSelection).toEqual({ override: null, inherited: 'low' }); + }); + + test('setAgent keeps a session Default effort instead of restoring the settings default', () => { + const sessionId = 'ses_agent_default_effort'; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useSelectionStore.getState().saveAgentModelForSession(sessionId, 'plan', 'openai', 'gpt-5.5'); + useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', null); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })], + agents: [testAgent('plan')], + settingsDefaultVariant: 'low', + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentVariant: 'low', + currentVariantSelection: { override: undefined, inherited: 'low' }, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('plan'); + + const state = useConfigStore.getState(); + expect(state.currentVariant).toBe(undefined); + expect(state.currentVariantSelection).toEqual({ override: null, inherited: 'low' }); + expect(state.directoryScoped[DIRECTORY]?.currentVariant).toBe(undefined); + }); + + test('setAgent reports the same effort through currentVariant and the picker selection', () => { + const sessionId = 'ses_agent_effort_in_sync'; + useSessionUIStore.setState({ currentSessionId: sessionId }); + useSelectionStore.getState().saveAgentModelForSession(sessionId, 'plan', 'openai', 'gpt-5.5'); + useSelectionStore.getState().saveAgentModelVariantForSession(sessionId, 'plan', 'openai', 'gpt-5.5', 'high'); + useConfigStore.setState({ + activeDirectoryKey: DIRECTORY, + providers: [provider('openai', 'gpt-5.5', { low: {}, high: {} })], + agents: [testAgent('plan')], + settingsDefaultVariant: 'low', + currentProviderId: 'openai', + currentModelId: 'gpt-5.5', + currentVariant: 'low', + currentVariantSelection: { override: 'low', inherited: 'low' }, + directoryScoped: {}, + }); + + useConfigStore.getState().setAgent('plan'); + + const state = useConfigStore.getState(); + expect(state.currentVariant).toBe('high'); + expect(state.currentVariantSelection).toEqual({ override: 'high', inherited: 'low' }); + }); + test('setAgent applies settings default variant for a saved session agent model', () => { const sessionId = 'ses_existing_agent_model_default_variant'; useSessionUIStore.setState({ currentSessionId: sessionId }); diff --git a/packages/ui/src/stores/useConfigStore.ts b/packages/ui/src/stores/useConfigStore.ts index 66f1487e..aef723b1 100644 --- a/packages/ui/src/stores/useConfigStore.ts +++ b/packages/ui/src/stores/useConfigStore.ts @@ -904,11 +904,28 @@ interface DirectoryScopedConfig { selectionSource?: "auto" | "manual"; } +/** + * The thinking-effort selection, split into what the user picked and what + * applies when they picked nothing: + * + * - `override: string` an effort chosen in the picker + * - `override: null` "Default" chosen in the picker — send no effort + * - `override: undefined` nothing chosen — the inherited default applies + * + * `null` and `undefined` are not interchangeable: collapsing them makes the + * "Default" entry unpickable, because the settings default silently takes + * effect again and the next assistant reply echoes it back as an explicit + * choice. + */ type CurrentVariantSelection = { override: string | null | undefined; inherited: string | undefined; }; +const resolveVariantFromSelection = (selection: CurrentVariantSelection): string | undefined => ( + selection.override === null ? undefined : selection.override ?? selection.inherited +); + /** * Lift the active directory's cached provider/agent snapshot into the top-level * fields the pickers read (`providers`, `agents`, selections), so a cold start @@ -1902,7 +1919,7 @@ export const useConfigStore = create()( setCurrentVariantOverride: (override, inherited) => { set((state) => { - const currentVariant = override ?? inherited; + const currentVariant = resolveVariantFromSelection({ override, inherited }); if ( state.currentVariant === currentVariant && state.currentVariantSelection.override === override @@ -2527,8 +2544,27 @@ export const useConfigStore = create()( if (agentName) { const { currentSessionId } = useSessionUIStore.getState(); - const applyResolvedModelSelection = (providerId: string, modelId: string, variant?: string) => { + // Writes the effort alongside the model, because the two are one + // selection: leaving `currentVariantSelection` behind would let the + // picker show one effort while sends carry another. + const applyResolvedModelSelection = ( + providerId: string, + modelId: string, + variantSelection: CurrentVariantSelection, + ) => { set((state) => { + const variant = resolveVariantFromSelection(variantSelection); + if ( + state.currentProviderId === providerId + && state.currentModelId === modelId + && state.currentVariant === variant + && state.currentVariantSelection.override === variantSelection.override + && state.currentVariantSelection.inherited === variantSelection.inherited + && state.selectionSource === "manual" + ) { + return state; + } + const directoryKey = state.activeDirectoryKey; const baseSnapshot: DirectoryScopedConfig = state.directoryScoped[directoryKey] ?? { providers: state.providers, @@ -2554,6 +2590,7 @@ export const useConfigStore = create()( currentProviderId: providerId, currentModelId: modelId, currentVariant: variant, + currentVariantSelection: variantSelection, selectionSource: "manual", directoryScoped: { ...state.directoryScoped, @@ -2563,16 +2600,24 @@ export const useConfigStore = create()( }); }; - const resolveVariantForModel = ( + const resolveVariantSelectionForModel = ( providerId: string, modelId: string, agentVariant?: string, - ): string | undefined => { + ): CurrentVariantSelection => { const model = providers .find((provider) => provider.id === providerId) ?.models.find((candidate) => candidate.id === modelId) as { variants?: Record } | undefined; const variants = model?.variants; - if (!variants) return undefined; + if (!variants) return { override: undefined, inherited: undefined }; + + const isAvailable = (candidate: string | null | undefined): candidate is string => ( + candidate !== null + && candidate !== undefined + && Object.prototype.hasOwnProperty.call(variants, candidate) + ); + + const inherited = [agentVariant, settingsDefaultVariant].find(isAvailable); const savedVariant = currentSessionId ? useSelectionStore.getState().getAgentModelVariantForSession( @@ -2582,14 +2627,23 @@ export const useConfigStore = create()( modelId, ) : undefined; - - for (const candidate of [savedVariant, agentVariant, settingsDefaultVariant]) { - if (candidate && Object.prototype.hasOwnProperty.call(variants, candidate)) { - return candidate; - } + // `null` is this session's explicit "Default"; it outranks + // the agent and settings defaults just like a named effort. + if (savedVariant === null || isAvailable(savedVariant)) { + return { override: savedVariant, inherited }; } - return undefined; + // While drafting there is no session record to read the choice + // back from, and switching agent is not a change of effort: + // keep the picker's choice for this same model, "Default" + // (an explicit `null`) included. + const liveSelection = get().currentVariantSelection; + const sameModel = get().currentProviderId === providerId && get().currentModelId === modelId; + if (!currentSessionId && sameModel && (liveSelection.override === null || isAvailable(liveSelection.override))) { + return { override: liveSelection.override, inherited }; + } + + return { override: undefined, inherited }; }; const agent = agents.find((candidate) => candidate.name === agentName); @@ -2601,14 +2655,11 @@ export const useConfigStore = create()( if (currentSessionId) { const existingAgentModel = useSelectionStore.getState().getAgentModelForSession(currentSessionId, agentName); if (existingAgentModel && hasProviderModel(providers, existingAgentModel.providerId, existingAgentModel.modelId)) { - const resolvedVariant = resolveVariantForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant); - if ( - currentProviderId !== existingAgentModel.providerId - || currentModelId !== existingAgentModel.modelId - || get().currentVariant !== resolvedVariant - ) { - applyResolvedModelSelection(existingAgentModel.providerId, existingAgentModel.modelId, resolvedVariant); - } + applyResolvedModelSelection( + existingAgentModel.providerId, + existingAgentModel.modelId, + resolveVariantSelectionForModel(existingAgentModel.providerId, existingAgentModel.modelId, agent?.variant), + ); return; } } @@ -2621,7 +2672,7 @@ export const useConfigStore = create()( const agentModel = agentProvider?.models.find((model) => model.id === modelID); if (agentModel) { - applyResolvedModelSelection(providerID, modelID, resolveVariantForModel(providerID, modelID, agent?.variant)); + applyResolvedModelSelection(providerID, modelID, resolveVariantSelectionForModel(providerID, modelID, agent?.variant)); return; } } @@ -2653,7 +2704,7 @@ export const useConfigStore = create()( if (parsed) { const settingsProvider = providers.find((p) => p.id === parsed.providerId); if (settingsProvider?.models.some((m) => m.id === parsed.modelId)) { - applyResolvedModelSelection(parsed.providerId, parsed.modelId, resolveVariantForModel(parsed.providerId, parsed.modelId, agent?.variant)); + applyResolvedModelSelection(parsed.providerId, parsed.modelId, resolveVariantSelectionForModel(parsed.providerId, parsed.modelId, agent?.variant)); return; } } diff --git a/packages/ui/src/stores/useGitStore.test.ts b/packages/ui/src/stores/useGitStore.test.ts index e96445b9..9ba64150 100644 --- a/packages/ui/src/stores/useGitStore.test.ts +++ b/packages/ui/src/stores/useGitStore.test.ts @@ -3,6 +3,7 @@ import type { GitStatus } from '@/lib/api/types'; import { useGitStore } from './useGitStore'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { notifyGitStatusInvalidated } from '@/lib/gitStatusInvalidation'; +import { clearWorktreeBootstrapState, markWorktreeBootstrapPending } from '@/lib/worktrees/worktreeBootstrap'; // The real transport has no server in tests and fails as a generic error. // Tests that exercise other failure modes swap this implementation; the @@ -86,6 +87,7 @@ const createGitApi = (getGitStatus: GitAPI['getGitStatus']): GitAPI => ({ describe('useGitStore', () => { beforeEach(() => { + clearWorktreeBootstrapState('/repo'); useGitStore.getState().resetForRuntimeSwitch(getRuntimeKey()); }); @@ -198,8 +200,10 @@ describe('useGitStore', () => { setDirectoryStatus(createStatus()); const requests: Deferred[] = []; let statusCalls = 0; - const git = createGitApi(() => { + const statusOptions: Array<{ mode?: 'light'; fresh?: boolean } | undefined> = []; + const git = createGitApi((_directory, options) => { statusCalls += 1; + statusOptions.push(options); const request = createDeferred(); requests.push(request); return request.promise; @@ -212,6 +216,7 @@ describe('useGitStore', () => { const all = useGitStore.getState().fetchAll('/repo', git, { force: true }); await Promise.resolve(); expect(statusCalls).toBe(2); + expect(statusOptions).toEqual([undefined, { fresh: true }]); requests[1].resolve({ ...createStatus(), current: 'feature' }); requests[0].resolve(createStatus()); @@ -220,6 +225,56 @@ describe('useGitStore', () => { expect(useGitStore.getState().getDirectoryState('/repo')?.status?.current).toBe('feature'); }); + test('does not request status while worktree bootstrap is pending', async () => { + setDirectoryStatus(createStatus()); + let statusCalls = 0; + const git = createGitApi(async () => { + statusCalls += 1; + return createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }]); + }); + + markWorktreeBootstrapPending('/repo'); + const changed = await useGitStore.getState().fetchStatus('/repo', git, { force: true }); + + expect(changed).toBe(false); + expect(statusCalls).toBe(0); + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]); + }); + + test('does not publish a status response after bootstrap becomes pending', async () => { + setDirectoryStatus(createStatus()); + const request = createDeferred(); + const git = createGitApi(() => request.promise); + + const loading = useGitStore.getState().fetchStatus('/repo', git, { silent: true }); + await Promise.resolve(); + markWorktreeBootstrapPending('/repo'); + request.resolve(createStatus(undefined, [{ path: 'bootstrap.ts', index: 'D', working_dir: ' ' }])); + + expect(await loading).toBe(false); + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]); + }); + + test('can propagate a forced status failure to a reconciliation owner', async () => { + setDirectoryStatus(createStatus()); + const git = createGitApi(async () => { + throw new Error('offline'); + }); + const originalConsoleError = console.error; + console.error = () => undefined; + + try { + await expect(useGitStore.getState().fetchStatus('/repo', git, { + force: true, + silent: true, + throwOnError: true, + })).rejects.toThrow('offline'); + expect(useGitStore.getState().getDirectoryState('/repo')?.status?.files).toEqual([]); + } finally { + console.error = originalConsoleError; + } + }); + test('does not let an older status fetch undo an optimistic mutation', async () => { const initial = createStatus(undefined, [{ path: 'src/index.ts', index: ' ', working_dir: 'M' }]); setDirectoryStatus(initial); diff --git a/packages/ui/src/stores/useGitStore.ts b/packages/ui/src/stores/useGitStore.ts index 341a77a6..a91795e3 100644 --- a/packages/ui/src/stores/useGitStore.ts +++ b/packages/ui/src/stores/useGitStore.ts @@ -11,6 +11,7 @@ import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { GitDirectoriesUnsupportedError, listGitDirectories } from '@/lib/gitApiHttp'; import { subscribeGitStatusInvalidations } from '@/lib/gitStatusInvalidation'; +import { getWorktreeBootstrapState } from '@/lib/worktrees/worktreeBootstrap'; const LOG_STALE_THRESHOLD = 10000; const REPO_CHECK_STALE_THRESHOLD = 60_000; @@ -28,6 +29,7 @@ const DIFF_CACHE_MAX_ENTRIES = 30; const DIFF_CACHE_MAX_TOTAL_SIZE_BYTES = 20 * 1024 * 1024; // 20MB const DIFF_CACHE_MAX_GLOBAL_ENTRIES = 200; type GitStatusFetchMode = 'full' | 'light'; +type GitStatusRequestOptions = { mode?: 'light'; fresh?: boolean }; // Discovery outcome for a root that is not itself a git repository. The three // states are mutually exclusive: a repository list (possibly empty), a failed @@ -64,7 +66,7 @@ interface GitStore { setActiveDirectory: (directory: string | null) => void; getDirectoryState: (directory: string) => DirectoryGitState | null; - fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean }) => Promise; + fetchStatus: (directory: string, git: GitAPI, options?: { silent?: boolean; mode?: 'light'; force?: boolean; throwOnError?: boolean }) => Promise; fetchBranches: (directory: string, git: GitAPI) => Promise; fetchLog: (directory: string, git: GitAPI, maxCount?: number) => Promise; fetchIdentity: (directory: string, git: GitAPI) => Promise; @@ -115,7 +117,7 @@ interface GitFileDiffResponse { interface GitAPI { checkIsGitRepository: (directory: string) => Promise; - getGitStatus: (directory: string, options?: { mode?: 'light' }) => Promise; + getGitStatus: (directory: string, options?: GitStatusRequestOptions) => Promise; getGitBranches: (directory: string) => Promise; getGitLog: (directory: string, options?: { maxCount?: number }) => Promise; getCurrentGitIdentity: (directory: string) => Promise; @@ -692,6 +694,9 @@ export const useGitStore = create()( }, fetchStatus: async (directory, git, options = {}) => { + if (getWorktreeBootstrapState(directory)?.status === 'pending') { + return false; + } const statusFetchMode: GitStatusFetchMode = options.mode ?? 'full'; const runtimeKey = getRuntimeKey(); const statusFetchKey = getStatusFetchKey(runtimeKey, directory, statusFetchMode); @@ -757,8 +762,17 @@ export const useGitStore = create()( return false; } - const newStatus = await git.getGitStatus(directory, options.mode ? { mode: options.mode } : undefined); + let statusOptions: GitStatusRequestOptions | undefined; + if (options.mode || options.force) { + statusOptions = {}; + if (options.mode) statusOptions.mode = options.mode; + if (options.force) statusOptions.fresh = true; + } + const newStatus = await git.getGitStatus(directory, statusOptions); if (!isRequestCurrent(token, directory)) return false; + // A request admitted before worktree creation must not publish a + // transient --no-checkout/reset snapshot after bootstrap begins. + if (getWorktreeBootstrapState(directory)?.status === 'pending') return false; const latestState = get().directories.get(directory) ?? createEmptyDirectoryState(); if (hasStatusChanged(latestState.status, newStatus)) { @@ -830,6 +844,9 @@ export const useGitStore = create()( } } catch (error) { console.error('Failed to fetch git status:', error); + if (options.throwOnError) { + throw error; + } } finally { if (!silent && isRequestCurrent(token, directory)) { const newDirectories = new Map(get().directories); diff --git a/packages/ui/src/stores/usePluginsStore.ts b/packages/ui/src/stores/usePluginsStore.ts index 2b9a2ec8..97fdc205 100644 --- a/packages/ui/src/stores/usePluginsStore.ts +++ b/packages/ui/src/stores/usePluginsStore.ts @@ -36,7 +36,7 @@ export interface PluginDraft { content: string; } -export type PluginMutationResult = { +type PluginMutationResult = { ok: boolean; reloadFailed?: boolean; message?: string; diff --git a/packages/ui/src/sync/DOCUMENTATION.md b/packages/ui/src/sync/DOCUMENTATION.md index 753ae66e..0f9c24b7 100644 --- a/packages/ui/src/sync/DOCUMENTATION.md +++ b/packages/ui/src/sync/DOCUMENTATION.md @@ -284,12 +284,13 @@ Rules: 2. If an action targets a session by ID, resolve the **session's own directory**. Do not assume the current directory is correct. 3. `session-ui-store.ts` should delegate to `session-actions.ts` for these mutations instead of duplicating SDK calls. 4. Sending after a revert commits the new branch optimistically: remove the reverted tail and marker before inserting the new message, and restore both if the send is rejected. -5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. +5. Composer and queued sends carry their captured runtime, directory, and session through asynchronous preparation. A runtime change cancels the send instead of re-resolving it against the new runtime. Outside VS Code the queue itself is server-owned (`packages/web/server/lib/message-queue/`): the UI hands the server the captured send configuration, resolved text, attachments, and attached context at queue time and the server delivers on idle; the composer only sends a queued message itself after taking it back from the server (`takeForSend`). See the `messageQueueStore.ts` section in `stores/DOCUMENTATION.md`. 6. After session creation, the directory returned by the server is authoritative over the requested draft directory. The server may canonicalize a worktree path, and the first prompt must use the same directory identity as the created session. 7. Regular new-chat drafts that inherit the persisted current/last directory must not create a session against a confirmed-missing path. Fall back to the active project only when OpenCode reports the directory missing; keep explicit worktree targets, in-flight worktree creation, and unknown/offline probes unchanged, and do not persist the fallback until session creation succeeds. A concurrent draft rewrite to that same active-project fallback must not abort session creation. 8. A prompt send that fails **after** the request left the client is ambiguous, never a definite failure: the server may already be answering it. Transports tag those errors (`markAmbiguousTransportFailure` in `@/lib/relay/transport-error`; the relay tunnel tags every stream that dies with a request in flight), and `isAmbiguousSendFailure` reads the tag before falling back to status/text heuristics. An ambiguous failure waits for the connection to return, refetches recent messages, and confirms the optimistic message in place instead of rolling it back — rolling it back lets the message queue re-send a prompt the engine is already running, producing two independent AI responses for one user message. 9. `SessionLiveActivity` has three answers and `unknown` is never `idle`. `getSessionLiveActivity` reports `active` when any child store or the global session-status index holds a non-idle status, `idle` only when a child store actually covers the session's directory, and `unknown` otherwise — child stores are evicted for background directories, and the global index keeps only non-idle entries, so absence of a status is not proof of idleness. Callers that gate a destructive action (worktree moves) must refuse on `unknown`. 10. Revert and unrevert cascade through known descendant sessions before mutating the parent. Revert uses the first descendant user message at or after the parent's target timestamp, including equal timestamps because message IDs do not define chronology. A descendant failure is logged and does not block its siblings or the parent. The parent runs last so its shared-directory file snapshot remains authoritative. A busy descendant is aborted before it is reverted, like the parent, so nothing keeps writing past the revert boundary. Redo clears the revert marker on every descendant, including markers the user set on a subagent independently of the parent undo. +11. Starting a session from an assistant answer carries the source session ID, rendered directory, and answer text into the action. It must not rediscover that context from the globally active child store or the OpenCode client's fallback directory: the visible session may belong to an existing worktree while the active provider directory points elsewhere. New isolated worktrees resolve their registered parent project from that captured directory, preferring recorded worktree metadata when available. The dialog offers creation only after the project root is confirmed as a Git repository, and the creation boundary repeats that check so stale or bypassed UI state cannot run Git commands against a non-repository directory; failures leave the dialog open and visible. Examples of global-store updates performed in `session-actions.ts`: @@ -371,6 +372,14 @@ The global sessions store persists and hydrates one bounded, runtime-scoped star VS Code intentionally has no managed Chats mode. It neither reads nor writes the managed Chats startup cache, regular drafts continue to target the open workspace, and the global session store rejects managed chat sessions from both snapshots and live upserts before any VS Code surface can consume them. Sidebar and switcher filters repeat that exclusion defensively. +### Remembering the last draft target + +`session-ui-store.ts` persists the side of the composer's target selector the user last worked on under `oc.chatInput.lastDraftTarget`, so a plain new session reopens there instead of always landing on Chat. The record holds a project id, a directory, and `target`, which is `"chat"`, `"project"`, or `null`. + +`null` is what a record written before `target` existed reads as, and it leaves the Chat default in place rather than guessing a side from the directory. A recorded project that no longer exists falls back to Chat the same way. Only a picker choice writes `"chat"` or `"project"`. + +A session's own directory is not a target choice. "New session in the current directory" forwards the current session's directory even when that session is a managed chat, and a chat scratch directory names no project, so those overrides resolve to a chat draft. Treating one as an explicit project target is how a plus pressed inside a chat opened a project draft. + When creating a draft in `handleDirectoryEvent`, **only clone the state fields the event will mutate**. Never spread all fields eagerly. ```typescript diff --git a/packages/ui/src/sync/__tests__/issue-2039.test.ts b/packages/ui/src/sync/__tests__/issue-2039.test.ts index b6fe2b09..39c658ac 100644 --- a/packages/ui/src/sync/__tests__/issue-2039.test.ts +++ b/packages/ui/src/sync/__tests__/issue-2039.test.ts @@ -6,6 +6,8 @@ const createSessionCalls: Array<{ title?: string; directory: string | null; pare const permissionAutoAcceptCalls: Array<[string, boolean]> = [] const savedVariantCalls: Array = [] let configVariantOverride: string | null | undefined +let projects: Array<{ id: string; path: string; label: string }> = [] +const createdWorktreeProjects: Array<{ id: string; path: string }> = [] // Sync's session→directory index. `createSession` writes it, and directory // resolution reads it as the authoritative source, so the mock has to keep one. const sessionDirectoryRegistry = new Map() @@ -111,7 +113,7 @@ mock.module("@/stores/useConfigStore", () => ({ mock.module("@/stores/useProjectsStore", () => ({ useProjectsStore: { getState: () => ({ - projects: [], + projects, activeProjectId: null, getActiveProject: () => null, }), @@ -127,6 +129,12 @@ mock.module("@/stores/useDirectoryStore", () => ({ }, })) +mock.module("@/stores/useSessionGoalArmStore", () => ({ + useSessionGoalArmStore: { + getState: () => ({ setArmed: () => undefined }), + }, +})) + mock.module("@/stores/useGlobalSessionsStore", () => ({ useGlobalSessionsStore: { getState: () => ({ @@ -304,6 +312,33 @@ mock.module("../session-actions", () => ({ abortCurrentOperation: mock(async () => undefined), })) +mock.module("@/lib/git/branchNameGenerator", () => ({ + generateBranchName: () => "generated-branch", +})) + +mock.module("@/lib/openchamberConfig", () => ({ + getWorktreeSetupCommands: async () => [], + getWorktreeSetupWaitEnabled: async () => false, +})) + +mock.module("@/lib/worktrees/worktreeBootstrap", () => ({ + waitForWorktreeBootstrap: async () => undefined, +})) + +mock.module("@/lib/worktrees/worktreeCreate", () => ({ + createWorktreeWithDefaults: async (project: { id: string; path: string }) => { + createdWorktreeProjects.push(project) + return { + source: "sdk", + name: "generated-branch", + path: "/worktrees/generated-branch", + projectDirectory: project.path, + branch: "generated-branch", + label: "generated-branch", + } + }, +})) + const { materializeOpenDraftSession, useSessionUIStore } = await import("../session-ui-store") describe("issue 2039 draft auto-accept", () => { @@ -500,3 +535,94 @@ describe("issue 2039 draft auto-accept", () => { expect(useSessionUIStore.getState().getDirectoryForSession(sessionId)).toBe("/canonical/worktree") }) }) + +describe("assistant answer worktree routing", () => { + test("reports session creation failure instead of completing silently", async () => { + const state = useSessionUIStore.getState() + const createFromAssistantMessage = state.createSessionFromAssistantMessage + const originalCreateSession = state.createSession + + useSessionUIStore.setState({ + createSession: async () => null, + }) + + try { + await expect(createFromAssistantMessage({ + sessionId: "source-session", + directory: "/repo", + text: "Implement the plan", + }, { + providerID: "provider", + modelID: "model", + variant: "", + agent: "build", + instructions: "Follow the answer", + })).rejects.toThrow("Failed to create session") + } finally { + useSessionUIStore.setState({ createSession: originalCreateSession }) + } + }) + + test("creates a sibling worktree from the captured source worktree directory", async () => { + projects = [ + { id: "project", path: "/repo", label: "Repo" }, + { id: "source-worktree", path: "/worktrees/source", label: "Source worktree" }, + ] + createdWorktreeProjects.length = 0 + const sourceWorktree = { + path: "/worktrees/source", + projectDirectory: "/repo", + branch: "source", + label: "source", + } + const state = useSessionUIStore.getState() + const createFromAssistantMessage = state.createSessionFromAssistantMessage + const originalCreateSession = state.createSession + const originalSendMessage = state.sendMessage + const originalWorktreeMetadata = state.worktreeMetadata + let createdDirectory: string | null | undefined + + useSessionUIStore.setState({ + availableWorktreesByProject: new Map([["/repo", [sourceWorktree]]]), + worktreeMetadata: new Map([["source-session", sourceWorktree]]), + createSession: async (_title, directory) => { + createdDirectory = directory + return { + id: "created-session", + slug: "created-session", + projectID: "project", + directory: directory ?? "", + title: "Created session", + version: "1", + time: { created: 1, updated: 1 }, + } + }, + sendMessage: async () => undefined, + }) + + try { + await createFromAssistantMessage({ + sessionId: "source-session", + directory: "/worktrees/source", + text: "Implement the plan", + }, { + providerID: "provider", + modelID: "model", + variant: "", + agent: "build", + instructions: "Follow the answer", + createWorktree: true, + }) + } finally { + useSessionUIStore.setState({ + createSession: originalCreateSession, + sendMessage: originalSendMessage, + worktreeMetadata: originalWorktreeMetadata, + }) + projects = [] + } + + expect(createdWorktreeProjects).toEqual([{ id: "project", path: "/repo" }]) + expect(createdDirectory).toBe("/worktrees/generated-branch") + }) +}) diff --git a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts index b3874a0d..c03bfbe5 100644 --- a/packages/ui/src/sync/__tests__/session-switch-resync.test.ts +++ b/packages/ui/src/sync/__tests__/session-switch-resync.test.ts @@ -67,6 +67,7 @@ mock.module("@/components/ui", () => ({ import { INITIAL_STATE, type State } from "../types" import { ChildStoreManager, type DirectoryStore } from "../child-store" import { getRuntimeKey } from "@/lib/runtime-switch" +import { sessionEvents } from "@/lib/sessionEvents" const { createEventRoutingIndex, handleEvent, @@ -340,4 +341,40 @@ describe("resyncBlockingRequestsForDirectory", () => { unsubscribe() childStores.disposeAll() }) + + test("refreshes Git once when a live mutating tool completes between renders", () => { + const childStores = new ChildStoreManager() + childStores.ensureChild("/repo", { bootstrap: false }) + const routingIndex = createEventRoutingIndex() + const refreshes: Array<{ directory: string; paths?: string[] }> = [] + const unsubscribe = sessionEvents.onGitRefreshHint((hint) => refreshes.push(hint)) + // SAFETY: this fixture supplies the SDK event discriminator and the tool + // part identity, tool name, and state fields consumed by the reducer. + const toolEvent = (tool: string, status: "pending" | "completed" | "error") => ({ + type: "message.part.updated", + properties: { + part: { + id: "prt_tool", + messageID: "msg_assistant", + sessionID: "ses_a", + type: "tool", + tool, + state: { status, input: {}, metadata: {} }, + }, + }, + }) as Event + + try { + handleEvent("/repo", toolEvent("apply_patch", "pending"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("apply_patch", "completed"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("read", "completed"), childStores, routingIndex, getRuntimeKey()) + handleEvent("/repo", toolEvent("edit", "error"), childStores, routingIndex, getRuntimeKey()) + + expect(refreshes).toEqual([{ directory: "/repo" }]) + } finally { + unsubscribe() + childStores.disposeAll() + } + }) }) diff --git a/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts b/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts index d846e868..5a42c6c5 100644 --- a/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts +++ b/packages/ui/src/sync/__tests__/sync-context-session-events.test.ts @@ -45,7 +45,15 @@ mock.module("@/lib/runtime-switch", () => ({ return () => undefined }, })) -import { applySessionEventsToGlobalSessions, applySessionEventToGlobalSessions } from "../session-event-router" +import { + applySessionEventsToGlobalSessions, + applySessionEventToGlobalSessions, +} from "../session-event-router" +import { + registerBulkArchiveEchoes, + releaseBulkArchiveEchoes, + shouldConsumeBulkArchiveEcho, +} from "../bulk-archive-echo" const buildSession = (title: string, time: Session["time"]): Session => ({ id: "ses_1", @@ -146,4 +154,35 @@ describe("applySessionEventToGlobalSessions", () => { expect(mutationCalls).toBe(1) expect(upsertedSessions).toHaveLength(1_000) }) + + test("consumes only the matching bulk archive echo", () => { + registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100) + + expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", { + created: 1, + updated: 20, + archived: 20, + })), runtimeKey, 101)).toBe(true) + expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", { + created: 1, + updated: 21, + archived: 21, + })), runtimeKey, 101)).toBe(false) + expect(shouldConsumeBulkArchiveEcho(buildEvent(buildSession("Initial", { + created: 1, + updated: 20, + archived: 20, + })), "runtime-b", 101)).toBe(false) + }) + + test("does not consume an expired or released bulk archive echo", () => { + registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100) + const event = buildEvent(buildSession("Initial", { created: 1, updated: 20, archived: 20 })) + + expect(shouldConsumeBulkArchiveEcho(event, runtimeKey, 30_101)).toBe(false) + + registerBulkArchiveEchoes(runtimeKey, [{ id: "ses_1", archivedAt: 20 }], 100) + releaseBulkArchiveEchoes(runtimeKey, ["ses_1"]) + expect(shouldConsumeBulkArchiveEcho(event, runtimeKey, 101)).toBe(false) + }) }) diff --git a/packages/ui/src/sync/bulk-archive-echo.ts b/packages/ui/src/sync/bulk-archive-echo.ts new file mode 100644 index 00000000..0e9f469e --- /dev/null +++ b/packages/ui/src/sync/bulk-archive-echo.ts @@ -0,0 +1,49 @@ +import type { Event } from "@opencode-ai/sdk/v2/client" +import { subscribeRuntimeEndpointWillChange } from "@/lib/runtime-switch" + +const BULK_ARCHIVE_ECHO_TTL_MS = 30_000 +const pendingEchoes = new Map>() + +subscribeRuntimeEndpointWillChange(() => pendingEchoes.clear()) + +export const registerBulkArchiveEchoes = ( + runtimeKey: string, + sessions: Iterable<{ id: string; archivedAt: number }>, + now = Date.now(), +): void => { + let runtimeEchoes = pendingEchoes.get(runtimeKey) + if (!runtimeEchoes) { + runtimeEchoes = new Map() + pendingEchoes.set(runtimeKey, runtimeEchoes) + } + for (const session of sessions) { + runtimeEchoes.set(session.id, { + archivedAt: session.archivedAt, + expiresAt: now + BULK_ARCHIVE_ECHO_TTL_MS, + }) + } +} + +export const releaseBulkArchiveEchoes = (runtimeKey: string, sessionIds: Iterable): void => { + const runtimeEchoes = pendingEchoes.get(runtimeKey) + if (!runtimeEchoes) return + for (const sessionId of sessionIds) runtimeEchoes.delete(sessionId) + if (runtimeEchoes.size === 0) pendingEchoes.delete(runtimeKey) +} + +export const shouldConsumeBulkArchiveEcho = ( + event: Event, + runtimeKey: string, + now = Date.now(), +): boolean => { + if (event.type !== "session.updated") return false + const runtimeEchoes = pendingEchoes.get(runtimeKey) + const expected = runtimeEchoes?.get(event.properties.info.id) + if (!expected) return false + if (expected.expiresAt < now) { + runtimeEchoes?.delete(event.properties.info.id) + if (runtimeEchoes?.size === 0) pendingEchoes.delete(runtimeKey) + return false + } + return event.properties.info.time.archived === expected.archivedAt +} diff --git a/packages/ui/src/sync/persist-cache.test.ts b/packages/ui/src/sync/persist-cache.test.ts index 2afc3889..95427e33 100644 --- a/packages/ui/src/sync/persist-cache.test.ts +++ b/packages/ui/src/sync/persist-cache.test.ts @@ -92,6 +92,16 @@ describe("persisted directory sessions", () => { expect(readManagedChatSessions()).toEqual([]) }) + test("coalesces a continuing burst into one trailing session write", async () => { + persistSessions(directory, [session(1, 1)]) + await new Promise((resolve) => setTimeout(resolve, 30)) + persistSessions(directory, [session(1, 2)]) + await waitForPersistence() + + expect(storage.writes).toBe(1) + expect(readDirCache(directory).sessions?.[0]?.time.updated).toBe(2) + }) + test("keeps the 50 most recently updated sessions across restart reads", async () => { const sessions = Array.from({ length: 60 }, (_, updated) => session(59 - updated, updated)) diff --git a/packages/ui/src/sync/persist-cache.ts b/packages/ui/src/sync/persist-cache.ts index 51d3e580..f2a43af5 100644 --- a/packages/ui/src/sync/persist-cache.ts +++ b/packages/ui/src/sync/persist-cache.ts @@ -177,7 +177,7 @@ function scheduleSessionCacheWrite(directory: string, sessions: Session[]): void if (pending.runtimeKey !== runtimeKey) pendingSessionWrites.delete(pendingKey) } pendingSessionWrites.set(key, { runtimeKey, key, legacyKey: legacyCacheKey(directory, "sessions"), sessions }) - if (pendingSessionWriteTimer !== undefined) return + if (pendingSessionWriteTimer !== undefined) clearTimeout(pendingSessionWriteTimer) pendingSessionWriteTimer = setTimeout(flushPendingSessionWrites, SESSION_PERSIST_DEBOUNCE_MS) } diff --git a/packages/ui/src/sync/selection-store.ts b/packages/ui/src/sync/selection-store.ts index a8c1cb21..6762ef05 100644 --- a/packages/ui/src/sync/selection-store.ts +++ b/packages/ui/src/sync/selection-store.ts @@ -29,16 +29,21 @@ export type SelectionState = { getSessionAgentSelection: (sessionId: string) => string | null saveAgentModelForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => void getAgentModelForSession: (sessionId: string, agentName: string) => { providerId: string; modelId: string } | null - saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | undefined) => void - getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | undefined + /** + * `variant` is the effort chosen for this agent/model in this session: + * a name, `null` for an explicit "Default" (send no effort), or `undefined` + * to forget the choice so the inherited default applies again. + */ + saveAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string, variant: string | null | undefined) => void + getAgentModelVariantForSession: (sessionId: string, agentName: string, providerId: string, modelId: string) => string | null | undefined } const isPersistedSelectionState = (state: unknown): state is PersistedSelectionState => ( typeof state === "object" && state !== null ) -// In-memory variant storage (not persisted) -const agentModelVariantSelections = new Map>>() +// In-memory variant storage (not persisted). `null` is an explicit "Default". +const agentModelVariantSelections = new Map>>() // Maximum number of sessions to persist to local storage to prevent unbounded growth const MAX_PERSISTED_SESSIONS = 150 @@ -91,20 +96,21 @@ export const useSelectionStore = create()( saveAgentModelVariantForSession: (sessionId, agentName, providerId, modelId, variant) => { const key = `${providerId}/${modelId}` + const clears = variant === undefined let agentMap = agentModelVariantSelections.get(sessionId) - if (!agentMap && variant) { + if (!agentMap && !clears) { agentMap = new Map() agentModelVariantSelections.set(sessionId, agentMap) } if (!agentMap) return let modelMap = agentMap.get(agentName) - if (!modelMap && variant) { + if (!modelMap && !clears) { modelMap = new Map() agentMap.set(agentName, modelMap) } if (!modelMap) return - if (!variant) { + if (clears) { modelMap.delete(key) if (modelMap.size === 0) { agentMap.delete(agentName) diff --git a/packages/ui/src/sync/session-actions.test.ts b/packages/ui/src/sync/session-actions.test.ts index d4611074..01e703a1 100644 --- a/packages/ui/src/sync/session-actions.test.ts +++ b/packages/ui/src/sync/session-actions.test.ts @@ -21,7 +21,16 @@ let sessionDeleteError: unknown | null = null let beforeSessionUpdateResolve: ((sessionId: string) => void) | null = null let beforeSessionDeleteResolve: ((sessionId: string) => void) | null = null const globalUpsertedSessions: unknown[] = [] +const globalUpsertedSessionBatches: Session[][] = [] const globalRemovedSessionIds: string[] = [] +// Sessions this client is holding. `archiveSessions` reads them to decide which +// sessions can be archived by the server in one batch. +let globalActiveSessions: Session[] = [] +const archiveBatchRequests: Array<{ directory: string; ids: string[] }> = [] +let archiveBatchResponse: { status: number; body: unknown } = { + status: 404, + body: { error: 'not found' }, +} const deletedCleanupIdentities: Array<{ runtimeKey: string; directory: string; sessionId: string }> = [] const movedSessionDirectories: Array<{ sessionID: string; directory: string }> = [] @@ -243,11 +252,15 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({ }, useGlobalSessionsStore: { getState: () => ({ - activeSessions: [], + activeSessions: globalActiveSessions, archivedSessions: [], upsertSession: (session: unknown) => { globalUpsertedSessions.push(session) }, + upsertSessions: (sessions: Session[]) => { + globalUpsertedSessionBatches.push(sessions) + globalUpsertedSessions.push(...sessions) + }, removeSessions: (ids: Iterable) => { globalRemovedSessionIds.push(...ids) }, @@ -255,6 +268,18 @@ mock.module("@/stores/useGlobalSessionsStore", () => ({ }, })) +mock.module("@/lib/runtime-fetch", () => ({ + runtimeFetch: async (path: string, init?: { body?: string }) => { + const payload = JSON.parse(String(init?.body ?? "{}")) + archiveBatchRequests.push({ directory: payload.directory, ids: payload.ids }) + void path + return new Response(JSON.stringify(archiveBatchResponse.body), { + status: archiveBatchResponse.status, + headers: { "content-type": "application/json" }, + }) + }, +})) + mock.module("./session-deletion-cleanup", () => ({ cleanupPersistedSessionState: (identity: { runtimeKey: string; directory: string; sessionId: string }) => { deletedCleanupIdentities.push(identity) @@ -396,6 +421,10 @@ describe("confirmed session removal", () => { sessionUpdateResult = {} beforeSessionUpdateResolve = null beforeSessionDeleteResolve = null + globalUpsertedSessionBatches.length = 0 + globalActiveSessions = [] + archiveBatchRequests.length = 0 + archiveBatchResponse = { status: 404, body: { error: 'not found' } } }) test("does not remove live or persisted state when delete fails", async () => { @@ -636,6 +665,161 @@ describe("confirmed session removal", () => { }) }) +describe("archiving a batch through the server", () => { + const liveSession = (id: string, metadata?: Record): Session => ({ + id, + directory: "/test/project", + time: { created: 1 }, + ...(metadata ? { metadata } : {}), + } as unknown as Session) + + const archivedSession = (id: string): Session => ({ + id, + directory: "/test/project", + time: { created: 1, archived: 2 }, + } as unknown as Session) + + beforeEach(() => { + replyCalls.length = 0 + globalUpsertedSessions.length = 0 + globalUpsertedSessionBatches.length = 0 + globalActiveSessions = [] + archiveBatchRequests.length = 0 + archiveBatchResponse = { status: 404, body: { error: "not found" } } + sessionUpdateResult = {} + beforeSessionUpdateResolve = null + }) + + test("archives held sessions in one request and reconciles the stores once", async () => { + globalActiveSessions = [liveSession("session-a"), liveSession("session-b")] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a"), archivedSession("session-b")], failedIds: [] }, + } + const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"]) + + expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] }) + expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a", "session-b"] }]) + // The point of the batch: no per-session SDK call, and one store write for + // the whole set instead of one per session. + expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([]) + expect(globalUpsertedSessionBatches).toHaveLength(1) + expect(source.getState().session).toEqual([]) + expect(source.getState().sessionRevision).toBe(1) + }) + + test("batches sessions held only by the live directory store", async () => { + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a")], failedIds: [] }, + } + const source = createStore({}, { session: [liveSession("session-a")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a"]) + + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] }) + expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-a"] }]) + expect(replyCalls.filter((call) => call.method === "session.update")).toEqual([]) + }) + + test("reports the sessions the server could not archive without losing the rest", async () => { + globalActiveSessions = [liveSession("session-a"), liveSession("session-b")] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a")], failedIds: ["session-b"] }, + } + const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"]) + + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: ["session-b"] }) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-b"]) + }) + + test("falls back to archiving one by one when the runtime does not serve the route", async () => { + globalActiveSessions = [liveSession("session-a"), liveSession("session-b")] + archiveBatchResponse = { status: 501, body: { error: "not supported in VS Code" } } + sessionUpdateResult = { data: archivedSession("session-a") } + const source = createStore({}, { session: [liveSession("session-a"), liveSession("session-b")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a", "session-b"]) + + expect(result).toEqual({ archivedIds: ["session-a", "session-b"], failedIds: [] }) + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-a", "session-b"]) + expect(source.getState().session).toEqual([]) + }) + + test("treats a malformed batch answer as unavailable instead of as an empty success", async () => { + globalActiveSessions = [liveSession("session-a")] + archiveBatchResponse = { status: 200, body: { archived: [{ title: "no id" }], failedIds: [] } } + sessionUpdateResult = { data: archivedSession("session-a") } + const source = createStore({}, { session: [liveSession("session-a")] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const result = await archiveSessions(["session-a"]) + + expect(result).toEqual({ archivedIds: ["session-a"], failedIds: [] }) + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-a"]) + }) + + test("keeps review and btw sessions on the per-session path", async () => { + const review = liveSession("session-review", { openchamber: { kind: "review", originalSessionID: "session-parent" } }) + const parentWithFork = liveSession("session-parent", { openchamber: { btwSessionID: "session-fork" } }) + globalActiveSessions = [liveSession("session-plain"), review, parentWithFork] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-plain")], failedIds: [] }, + } + sessionUpdateResult = { data: archivedSession("session-review") } + const source = createStore({}, { session: [liveSession("session-plain"), review, parentWithFork] }) + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + await archiveSessions(["session-plain", "session-review", "session-parent"]) + + // Unlinking a partner rewrites another session's metadata, so those two + // never travel in the batch. + expect(archiveBatchRequests).toEqual([{ directory: "/test/project", ids: ["session-plain"] }]) + expect(replyCalls.filter((call) => call.method === "session.update").map((call) => call.params.sessionID)) + .toEqual(["session-review", "session-parent"]) + }) + + test("does not reconcile a batch answered after a runtime switch", async () => { + globalActiveSessions = [liveSession("session-a")] + archiveBatchResponse = { + status: 200, + body: { archived: [archivedSession("session-a")], failedIds: [] }, + } + const source = createStore({}, { session: [liveSession("session-a")] }) + const { getRuntimeKey, switchRuntimeEndpoint } = await import("../lib/runtime-switch") + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-a.test", runtimeKey: "archive-bulk-a" }) + const capturedRuntimeKey = getRuntimeKey() + const { archiveSessions, setActionRefs } = await import("./session-actions") + setActionRefs(mockSdk as unknown as OpencodeClient, createChildStores([["/test/project", source]]), () => "/test/project") + + const pending = archiveSessions(["session-a"], { expectedRuntimeKey: capturedRuntimeKey }) + switchRuntimeEndpoint({ apiBaseUrl: "http://archive-bulk-b.test", runtimeKey: "archive-bulk-b" }) + const result = await pending + + expect(result).toEqual({ archivedIds: [], failedIds: ["session-a"] }) + expect(source.getState().session.map((item) => item.id)).toEqual(["session-a"]) + expect(globalUpsertedSessionBatches).toEqual([]) + }) +}) + describe("session restore (unarchive)", () => { beforeEach(() => { replyCalls.length = 0 diff --git a/packages/ui/src/sync/session-actions.ts b/packages/ui/src/sync/session-actions.ts index c208e0cc..85ddbd37 100644 --- a/packages/ui/src/sync/session-actions.ts +++ b/packages/ui/src/sync/session-actions.ts @@ -33,6 +33,8 @@ import { getBtwOriginalSessionID, getBtwSessionID, isBtwSession, withoutBtwSessi import { withLinkedIssue, type LinkedIssue } from "@/lib/linkedIssues" import { getImperativeSessionMessageLoader } from "./session-message-loader" import { cleanupPersistedSessionState } from "./session-deletion-cleanup" +import { requestSessionArchiveBatch } from "./session-archive-batch" +import { registerBulkArchiveEchoes, releaseBulkArchiveEchoes } from "./bulk-archive-echo" import { getRuntimeKey } from "@/lib/runtime-switch" import { markAmbiguousTransportFailure } from "@/lib/relay/transport-error" import { getErrorStatus, isAmbiguousSendFailure } from "./send-failure-classification" @@ -75,20 +77,29 @@ let _optimisticAdd: ((input: OptimisticAddInput) => void) | null = null let _optimisticRemove: ((input: OptimisticRemoveInput) => void) | null = null let _optimisticConfirm: ((input: OptimisticConfirmInput) => void) | null = null -function sessionMutationPatch( +/** + * Revision patch for one or more sessions changing in the same store write. + * + * A batch bumps the revision once, because it is one state change: consumers + * compare revisions to decide whether their view of the list is stale, and a + * batch leaves them stale exactly once rather than once per session. + */ +function sessionsMutationPatch( state: ReturnType, - sessionId: string, + sessionIds: Iterable, deleted: boolean, ) { const revision = (state.sessionRevision ?? 0) + 1 const sessionEventRevision = { ...(state.sessionEventRevision ?? {}) } const sessionDeletedRevision = { ...(state.sessionDeletedRevision ?? {}) } - if (deleted) { - sessionDeletedRevision[sessionId] = revision - delete sessionEventRevision[sessionId] - } else { - sessionEventRevision[sessionId] = revision - delete sessionDeletedRevision[sessionId] + for (const sessionId of sessionIds) { + if (deleted) { + sessionDeletedRevision[sessionId] = revision + delete sessionEventRevision[sessionId] + } else { + sessionEventRevision[sessionId] = revision + delete sessionDeletedRevision[sessionId] + } } return { sessionListSource: "live" as const, @@ -98,6 +109,14 @@ function sessionMutationPatch( } } +function sessionMutationPatch( + state: ReturnType, + sessionId: string, + deleted: boolean, +) { + return sessionsMutationPatch(state, [sessionId], deleted) +} + function invalidateSessionLoads(sessionId: string, directories: Iterable): void { const loader = getImperativeSessionMessageLoader() if (!loader) return @@ -1039,6 +1058,50 @@ function removeSessionFromLiveStores(sessionId: string, preferredDirectory?: str return snapshots } +/** + * Remove a batch of server-confirmed sessions from every live child store. + * + * Each affected store is written once for the whole batch. Removing the + * sessions one at a time notified every subscriber — and therefore re-rendered + * the sidebar — once per session, which is what made archiving a worktree's + * sessions block the main thread for seconds. + */ +function removeSessionsFromLiveStores(sessionIds: Iterable, preferredDirectory?: string): SessionListSnapshot[] { + const ids = new Set(sessionIds) + if (!_childStores || ids.size === 0) return [] + + const snapshots: SessionListSnapshot[] = [] + const visited = new Set() + const candidates: Array<[string, DirectoryStoreApi]> = [] + + if (preferredDirectory) { + const preferredStore = _childStores.children.get(preferredDirectory) + if (preferredStore) { + candidates.push([preferredDirectory, preferredStore]) + visited.add(preferredDirectory) + } + } + + for (const entry of _childStores.children.entries()) { + if (visited.has(entry[0])) continue + candidates.push(entry) + } + + for (const [directory, store] of candidates) { + const current = store.getState() + const removed = current.session.filter((session) => ids.has(session.id)).map((session) => session.id) + if (removed.length === 0) continue + + snapshots.push({ directory }) + store.setState({ + session: current.session.filter((session) => !ids.has(session.id)), + ...sessionsMutationPatch(current, removed, true), + }) + } + + return snapshots +} + function cleanupSessionWorktreeMetadata(sessionId: string): void { useSessionUIStore.getState().setWorktreeMetadata(sessionId, null) } @@ -1252,7 +1315,14 @@ export type ArchiveSessionsOptions = { } /** - * Archive several sessions sequentially, preserving partial results. + * Archive several sessions, preserving partial results. + * + * Sessions that carry no review or btw link are archived by their directory's + * server in one request, and the whole answer is reconciled with a single store + * write. The remainder — review sessions, btw forks, sessions with an active + * btw fork, and any session this client does not hold — keep the per-session + * path, because unlinking a partner is UI-owned work that reads and rewrites + * another session's metadata. * * One failed session never blocks or erases the others: it is reported in * `failedIds` while the remaining IDs are still attempted. When @@ -1269,10 +1339,55 @@ export async function archiveSessions( const archivedIds: string[] = [] const failedIds: string[] = [] const expectedRuntimeKey = options?.expectedRuntimeKey ?? getRuntimeKey() + if (ids.length === 0) return { archivedIds, failedIds } - for (const [index, id] of ids.entries()) { + const plan = planArchiveBatches(ids) + + for (const [directory, batchIds] of plan.batchesByDirectory) { if (isStaleRuntime(expectedRuntimeKey)) { - failedIds.push(...ids.slice(index)) + failedIds.push(...batchIds) + continue + } + + const archivedAt = Date.now() + registerBulkArchiveEchoes( + expectedRuntimeKey, + batchIds.map((id) => ({ id, archivedAt })), + ) + const result = await requestSessionArchiveBatch(directory, batchIds, archivedAt) + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...batchIds) + continue + } + + if (result.outcome === "archived") { + releaseBulkArchiveEchoes(expectedRuntimeKey, batchIds) + registerBulkArchiveEchoes( + expectedRuntimeKey, + result.archived.flatMap((session) => ( + session.time?.archived === undefined + ? [] + : [{ id: session.id, archivedAt: session.time.archived }] + )), + ) + commitArchivedSessions(result.archived, directory) + archivedIds.push(...result.archived.map((session) => session.id)) + failedIds.push(...result.failedIds) + continue + } + + // The runtime does not serve the batch route, or its answer could not be + // trusted. Archiving each session individually is slower but reaches the + // same state, and re-archiving a session the server already archived writes + // the same field again. + console.warn("[session-actions] archive batch unavailable, archiving one by one", result.reason) + releaseBulkArchiveEchoes(expectedRuntimeKey, batchIds) + plan.individualIds.push(...batchIds) + } + + for (const [index, id] of plan.individualIds.entries()) { + if (isStaleRuntime(expectedRuntimeKey)) { + failedIds.push(...plan.individualIds.slice(index)) break } if (await archiveSession(id, expectedRuntimeKey)) archivedIds.push(id) @@ -1282,6 +1397,82 @@ export async function archiveSessions( return { archivedIds, failedIds } } +/** + * A session whose archive also has to rewrite another session's metadata. + * + * Review sessions and btw forks point at a parent that must be unlinked, and a + * parent with an active btw fork has to delete that fork. Those are + * read-modify-write pairs on a second session, so they stay on the per-session + * path instead of the server batch. + */ +function hasLinkedSessionCleanup(session: Session): boolean { + return isReviewSession(session) || isBtwSession(session) || Boolean(getBtwSessionID(session)) +} + +/** + * Split the requested IDs into per-directory server batches and the sessions + * that must be archived individually. + * + * Link classification reads this client's session records rather than + * refetching each session: those records are kept current by the same + * `session.updated` events that publish a link created anywhere else, so a + * fetch per session would buy no authority the store does not already have. + * A session this client does not hold is classified as individual, which + * restores the per-session fetch for exactly the cases where the store has + * nothing to say. + */ +function planArchiveBatches(ids: string[]) { + const global = useGlobalSessionsStore.getState() + const knownSessions = new Map() + for (const session of [...global.activeSessions, ...global.archivedSessions]) { + knownSessions.set(session.id, session) + } + for (const store of _childStores?.children.values() ?? []) { + for (const session of store.getState().session) knownSessions.set(session.id, session) + } + + const batchesByDirectory = new Map() + const individualIds: string[] = [] + + for (const id of ids) { + const session = knownSessions.get(id) + const directory = session + ? resolveGlobalSessionDirectory(session) ?? getSessionDirectory(id) + : undefined + if (!session || !directory || hasLinkedSessionCleanup(session)) { + individualIds.push(id) + continue + } + const batch = batchesByDirectory.get(directory) + if (batch) batch.push(id) + else batchesByDirectory.set(directory, [id]) + } + + return { batchesByDirectory, individualIds } +} + +/** + * Reconcile a server-confirmed archive batch with one write per store. + * + * This mirrors what `archiveSession` does for a single session — drop it from + * the live directory stores, invalidate its cached messages, move it to the + * archived bucket, and clear it if it was open — with the per-session store + * notifications collapsed into one. + */ +function commitArchivedSessions(sessions: Session[], directory: string): void { + if (sessions.length === 0) return + + const ids = sessions.map((session) => session.id) + const snapshots = removeSessionsFromLiveStores(ids, directory) + const directories = [...snapshots.map((snapshot) => snapshot.directory), directory] + for (const id of ids) invalidateSessionLoads(id, directories) + + useGlobalSessionsStore.getState().upsertSessions(sessions) + + const ui = useSessionUIStore.getState() + if (ui.currentSessionId && ids.includes(ui.currentSessionId)) ui.setCurrentSession(null) +} + /** * Sentinel written to `time.archived` when restoring a session. * diff --git a/packages/ui/src/sync/session-archive-batch.ts b/packages/ui/src/sync/session-archive-batch.ts new file mode 100644 index 00000000..7774a43e --- /dev/null +++ b/packages/ui/src/sync/session-archive-batch.ts @@ -0,0 +1,77 @@ +/** + * Server-side archive batch. + * + * Archiving the sessions linked to a worktree one request at a time is what + * made removing a worktree with many sessions take tens of seconds: every + * session cost its own round trip and its own store reconciliation. This asks + * the OpenChamber server to archive the whole batch next to OpenCode, so the + * browser spends one request and reconciles once. + * + * The route is an OpenChamber capability, not an OpenCode one. Runtimes that do + * not serve it (the VS Code webview has no server process) answer with a stable + * unsupported status, and callers fall back to archiving session by session. + */ + +import type { Session } from '@opencode-ai/sdk/v2/client'; +import { z } from 'zod'; + +import { runtimeFetch } from '@/lib/runtime-fetch'; + +/** + * The route answers with sessions OpenCode itself returned from + * `session.update`. Only the identity this layer routes on is asserted here; + * every other field is carried through to the stores exactly as the server + * sent it, the same as for any other session response. + */ +const archiveResponseSchema = z.object({ + archived: z.array(z.looseObject({ id: z.string().min(1) })), + failedIds: z.array(z.string().min(1)), +}); + +export type SessionArchiveBatchResult = + | { outcome: 'archived'; archived: Session[]; failedIds: string[] } + | { outcome: 'unavailable'; reason: string }; + +export async function requestSessionArchiveBatch( + directory: string, + ids: string[], + archivedAt: number, +): Promise { + let response: Response; + try { + response = await runtimeFetch('/api/openchamber/sessions/archive', { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ directory, ids, archivedAt }), + }); + } catch (error) { + return { outcome: 'unavailable', reason: error instanceof Error ? error.message : 'archive request failed' }; + } + + if (!response.ok) { + return { outcome: 'unavailable', reason: `archive request failed with ${response.status}` }; + } + + let body: unknown; + try { + body = await response.json(); + } catch (error) { + return { outcome: 'unavailable', reason: error instanceof Error ? error.message : 'archive response was not JSON' }; + } + + const parsed = archiveResponseSchema.safeParse(body); + if (!parsed.success) { + // A body this layer cannot read is reported as unavailable rather than as + // an empty success, so a caller never mistakes "the response made no + // sense" for "nothing needed archiving" and drops the sessions. + return { outcome: 'unavailable', reason: `malformed archive response: ${parsed.error.issues[0]?.message ?? 'unknown shape'}` }; + } + + return { + outcome: 'archived', + // SAFETY: the schema guarantees the non-empty string `id` this layer keys + // on; the remaining fields are the server's own session payload. + archived: parsed.data.archived as Session[], + failedIds: parsed.data.failedIds, + }; +} diff --git a/packages/ui/src/sync/session-deletion-cleanup.test.ts b/packages/ui/src/sync/session-deletion-cleanup.test.ts index 276c9fd6..6c10c31b 100644 --- a/packages/ui/src/sync/session-deletion-cleanup.test.ts +++ b/packages/ui/src/sync/session-deletion-cleanup.test.ts @@ -3,7 +3,7 @@ import type { Todo } from '@opencode-ai/sdk/v2/client'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { createChatDraftIdentity, readChatDraft, writeChatDraft } from '@/lib/chatDraftPersistence'; -import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore'; +import { createMessageQueueTarget, getMessageQueueKey, useMessageQueueStore } from '@/stores/messageQueueStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useTodosPersistStore } from '@/stores/useTodosPersistStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; @@ -25,8 +25,14 @@ describe('cleanupPersistedSessionState', () => { const runtimeKey = getRuntimeKey(); const deleted = createMessageQueueTarget('session-1', '/repo-a', runtimeKey)!; const retained = createMessageQueueTarget('session-1', '/repo-b', runtimeKey)!; - useMessageQueueStore.getState().addToQueue(deleted, { content: 'delete' }); - useMessageQueueStore.getState().addToQueue(retained, { content: 'retain' }); + // Outside VS Code the queue is a projection of the server's; seed it the + // way a server snapshot would, and expect only the projection to go. + useMessageQueueStore.setState({ + queuedMessages: { + [getMessageQueueKey(deleted)]: [{ id: 'q-delete', content: 'delete', text: 'delete', createdAt: 1 }], + [getMessageQueueKey(retained)]: [{ id: 'q-retain', content: 'retain', text: 'retain', createdAt: 1 }], + }, + }); useTodosPersistStore.getState().setSessionTodos('/repo-a', 'session-1', [todo]); useTodosPersistStore.getState().setSessionTodos('/repo-b', 'session-1', [todo]); const deletedDraft = createChatDraftIdentity(runtimeKey, '/repo-a', 'session-1')!; diff --git a/packages/ui/src/sync/session-deletion-cleanup.ts b/packages/ui/src/sync/session-deletion-cleanup.ts index e055dfa0..7460c621 100644 --- a/packages/ui/src/sync/session-deletion-cleanup.ts +++ b/packages/ui/src/sync/session-deletion-cleanup.ts @@ -1,6 +1,6 @@ import { getRuntimeKey } from '@/lib/runtime-switch'; import { clearChatDraft, createChatDraftIdentity } from '@/lib/chatDraftPersistence'; -import { createMessageQueueTarget, useMessageQueueStore } from '@/stores/messageQueueStore'; +import { createMessageQueueTarget, isServerOwnedMessageQueue, useMessageQueueStore } from '@/stores/messageQueueStore'; import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore'; import { useTodosPersistStore } from '@/stores/useTodosPersistStore'; import { useInlineCommentDraftStore } from '@/stores/useInlineCommentDraftStore'; @@ -14,7 +14,12 @@ export const cleanupPersistedSessionState = (identity: { if (identity.runtimeKey !== getRuntimeKey() || !identity.directory || identity.directory === 'global' || !identity.sessionId) return; const queueTarget = createMessageQueueTarget(identity.sessionId, identity.directory, identity.runtimeKey); - if (queueTarget) useMessageQueueStore.getState().clearQueue(queueTarget); + if (queueTarget) { + // A server-owned queue drops the deleted session itself; only the local + // projection needs to go. VS Code owns its queue and clears it here. + if (isServerOwnedMessageQueue()) useMessageQueueStore.getState().forgetQueue(queueTarget); + else useMessageQueueStore.getState().clearQueue(queueTarget); + } useTodosPersistStore.getState().clearSessionTodos(identity.runtimeKey, identity.directory, identity.sessionId); useSessionFoldersStore.getState().removeSessionEverywhere(identity.runtimeKey, identity.sessionId); useInlineCommentDraftStore.getState().clearSessionDrafts(identity.runtimeKey, identity.directory, identity.sessionId); diff --git a/packages/ui/src/sync/session-ui-store.test.js b/packages/ui/src/sync/session-ui-store.test.js index bff2e2f2..4dcb50a8 100644 --- a/packages/ui/src/sync/session-ui-store.test.js +++ b/packages/ui/src/sync/session-ui-store.test.js @@ -8,8 +8,10 @@ import { setActionRefs, setOptimisticRefs } from './session-actions'; import { useSkillsStore } from '@/stores/useSkillsStore'; import { useCommandsStore } from '@/stores/useCommandsStore'; import { useConfigStore } from '@/stores/useConfigStore'; +import { useSelectionStore } from './selection-store'; import { getRuntimeKey } from '@/lib/runtime-switch'; import { getDeferredSafeStorage } from '@/stores/utils/safeStorage'; +import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories'; import { useSessionDisplayStore } from '@/stores/useSessionDisplayStore'; /** @@ -398,7 +400,10 @@ describe('openNewSessionDraft project binding', () => { const projectA = { id: 'proj-a', path: '/projects/alpha', label: 'Alpha' }; const projectB = { id: 'proj-b', path: '/projects/beta', label: 'Beta' }; + const DRAFT_TARGET_KEY = 'oc.chatInput.lastDraftTarget'; + beforeEach(() => { + getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY); useSessionUIStore.setState({ currentSessionId: null, currentSessionDirectory: null, @@ -412,6 +417,10 @@ describe('openNewSessionDraft project binding', () => { useDirectoryStore.getState().setDirectory(projectB.path, { showOverlay: false }); }); + afterEach(() => { + getDeferredSafeStorage().removeItem(DRAFT_TARGET_KEY); + }); + test('defaults an implicit draft to Chat when active project differs', () => { useSessionUIStore.getState().openNewSessionDraft(); const draft = useSessionUIStore.getState().newSessionDraft; @@ -449,6 +458,82 @@ describe('openNewSessionDraft project binding', () => { expect(draft.open).toBe(true); expect(draft.selectedProjectId).toBe(projectB.id); }); + + test('reopens an implicit draft on the project the target selector was last set to', () => { + useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id }); + useSessionUIStore.getState().closeNewSessionDraft(); + // A chat session leaves its managed scratch directory current; the project + // to reopen on can only come from the recorded target. + useDirectoryStore.getState().setDirectory( + '/Users/tester/.config/openchamber/chats/ses_chat', + { showOverlay: false }, + ); + + useSessionUIStore.getState().openNewSessionDraft(); + const draft = useSessionUIStore.getState().newSessionDraft; + + expect(draft.target).toBe('project'); + expect(draft.selectedProjectId).toBe(projectB.id); + expect(draft.directoryOverride).toBe(projectB.path); + }); + + test('setNewSessionDraftTarget records Chat, so the next implicit draft opens on Chat', () => { + useSessionUIStore.getState().openNewSessionDraft({ selectedProjectId: projectB.id }); + useSessionUIStore.getState().setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID }); + useSessionUIStore.getState().closeNewSessionDraft(); + + useSessionUIStore.getState().openNewSessionDraft(); + + expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat'); + }); + + test('keeps the Chat default for a record written before the target was stored', () => { + getDeferredSafeStorage().setItem( + DRAFT_TARGET_KEY, + JSON.stringify({ projectId: projectB.id, directory: projectB.path }), + ); + + useSessionUIStore.getState().openNewSessionDraft(); + + expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat'); + }); + + test('a chat scratch directory forwarded as override opens a chat draft', () => { + // "New session in the current directory" callers forward the current + // session's directory even when that session is a chat; its scratch + // directory names no project. + useSessionUIStore.getState().openNewSessionDraft({ + directoryOverride: '/Users/tester/.config/openchamber/chats/ses_chat', + }); + const draft = useSessionUIStore.getState().newSessionDraft; + + expect(draft.target).toBe('chat'); + expect(draft.directoryOverride).toBeNull(); + }); + + test('a chat scratch override opens Chat even when the recorded target is a project', () => { + getDeferredSafeStorage().setItem( + DRAFT_TARGET_KEY, + JSON.stringify({ projectId: projectB.id, directory: projectB.path, target: 'project' }), + ); + + useSessionUIStore.getState().openNewSessionDraft({ + directoryOverride: '/Users/tester/.config/openchamber/chats/ses_chat', + }); + + expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat'); + }); + + test('falls back to Chat when the last project target no longer exists', () => { + getDeferredSafeStorage().setItem( + DRAFT_TARGET_KEY, + JSON.stringify({ projectId: 'proj-removed', directory: '/projects/removed', target: 'project' }), + ); + + useSessionUIStore.getState().openNewSessionDraft(); + + expect(useSessionUIStore.getState().newSessionDraft.target).toBe('chat'); + }); }); describe('createSession draft lifecycle', () => { @@ -962,3 +1047,84 @@ describe('deleteSessions option forwarding', () => { expect(deleteSessionCalls).toEqual([]); }); }); + +describe('sendMessage effort record', () => { + let originalSendMessage; + + const SESSION = 'session-effort'; + const PROVIDER = 'provider-a'; + const MODEL = 'model-a'; + const AGENT = 'build'; + + const readRecord = () => useSelectionStore + .getState() + .getAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL); + + beforeEach(() => { + const childStore = { + getState: () => ({ session: [], message: {}, part: {}, session_status: {} }), + setState: () => {}, + }; + const childStores = { + children: new Map(), + ensureChild: () => childStore, + getChild: () => childStore, + }; + setActionRefs(opencodeClient, childStores, () => '/current/project'); + setOptimisticRefs(() => {}, () => {}); + useConfigStore.setState({ + isConnected: true, + currentProviderId: PROVIDER, + currentModelId: MODEL, + currentAgentName: AGENT, + currentVariant: undefined, + currentVariantSelection: { override: undefined, inherited: undefined }, + }); + useSessionUIStore.setState({ + currentSessionId: SESSION, + currentSessionDirectory: '/current/project', + newSessionDraft: { open: false, directoryOverride: null, parentID: null }, + }); + originalSendMessage = opencodeClient.sendMessage; + opencodeClient.sendMessage = async () => 'msg'; + }); + + afterEach(() => { + opencodeClient.sendMessage = originalSendMessage; + useSelectionStore.getState().saveAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL, undefined); + }); + + const send = (variant) => useSessionUIStore.getState().sendMessage( + 'hello', PROVIDER, MODEL, AGENT, undefined, undefined, undefined, variant, 'normal', + ); + + test('keeps an explicit Default across the send that follows it', async () => { + // What the picker leaves behind: `null` recorded, and a send that carries + // no effort because "Default" means exactly that. + useSelectionStore.getState().saveAgentModelVariantForSession(SESSION, AGENT, PROVIDER, MODEL, null); + useConfigStore.setState({ currentVariantSelection: { override: null, inherited: 'high' } }); + + await send(undefined); + + expect(readRecord()).toBeNull(); + }); + + test('records the effort a send carries', async () => { + useConfigStore.setState({ currentVariantSelection: { override: 'high', inherited: 'high' } }); + + await send('high'); + + expect(readRecord()).toBe('high'); + }); + + test('records no choice when the live selection inherits its effort', async () => { + useConfigStore.setState({ + currentVariant: 'high', + currentVariantSelection: { override: undefined, inherited: 'high' }, + }); + + await send('high'); + + expect(readRecord()).toBe(undefined); + }); +}); diff --git a/packages/ui/src/sync/session-ui-store.ts b/packages/ui/src/sync/session-ui-store.ts index 74fbac64..1f0414f6 100644 --- a/packages/ui/src/sync/session-ui-store.ts +++ b/packages/ui/src/sync/session-ui-store.ts @@ -14,7 +14,7 @@ import type { ContextPartMetadata } from "@/lib/messages/contextParts" import { create } from "zustand" -import type { Session, Part, Message, TextPart } from "@opencode-ai/sdk/v2/client" +import type { Session, Part, TextPart } from "@opencode-ai/sdk/v2/client" import type { AttachedFile, SessionContextUsage, SessionWorktreeAttachment } from "@/stores/types/sessionTypes" import type { WorktreeMetadata } from "@/types/worktree" import { opencodeClient } from "@/lib/opencode/client" @@ -31,9 +31,8 @@ import { useSkillsStore } from "@/stores/useSkillsStore" import { getDeferredSafeStorage } from "@/stores/utils/safeStorage" import { markPendingUserSendAnimation } from "@/lib/userSendAnimation" import { normalizePath } from "@/lib/pathNormalization" -import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, warmChatsRootDirectory } from "@/lib/chatDirectories" +import { CHAT_DRAFT_PROJECT_ID, createChatDirectory, deleteChatDirectory, getChatsRootFromDirectory, isChatDirectoryPath, warmChatsRootDirectory } from "@/lib/chatDirectories" import { isVSCodeRuntime } from "@/lib/desktop" -import { flattenAssistantTextParts } from "@/lib/messages/messageText" import { composeForkSessionMessage } from "@/lib/messages/executionMeta" import { findLatestUserModelChoice } from "@/lib/messages/userModelChoice" import { waitForPendingDraftWorktreeRequest } from "@/lib/worktrees/pendingDraftWorktree" @@ -252,6 +251,12 @@ type AssistantMessageSessionExecution = { runAsGoal?: boolean } +type AssistantMessageSessionSource = { + sessionId: string + directory: string + text: string +} + function notifyMessageSent(sessionId: string): void { runtimeFetch(`/api/sessions/${sessionId}/message-sent`, { method: "POST" }) .catch(() => { /* ignore */ }) @@ -261,6 +266,8 @@ function notifyMessageSent(sessionId: string): void { // Types // --------------------------------------------------------------------------- +type NewSessionDraftTarget = "chat" | "project" + export type NewSessionDraftState = { draftId: number open: boolean @@ -276,7 +283,7 @@ export type NewSessionDraftState = { syntheticParts?: SyntheticContextPart[] targetFolderId?: string projectContextPins?: { notes: string[]; plans: string[] } - target: "chat" | "project" + target: NewSessionDraftTarget preparedChatDirectory?: string | null } @@ -383,7 +390,7 @@ export type SessionUIState = { forkFromMessage: (sessionId: string, messageId: string) => Promise handleSlashUndo: (sessionId: string) => Promise handleSlashRedo: (sessionId: string, options?: { fullUnrevert?: boolean }) => Promise - createSessionFromAssistantMessage: (sourceMessageId: string, execution: AssistantMessageSessionExecution) => Promise + createSessionFromAssistantMessage: (source: AssistantMessageSessionSource, execution: AssistantMessageSessionExecution) => Promise // Data access helpers (read from sync) getSessionsByDirectory: (directory: string) => Session[] @@ -418,16 +425,25 @@ const resolveDirectoryKey = (session: Session): string | null => { const safeStorage = getDeferredSafeStorage() const DRAFT_TARGET_STORAGE_KEY = "oc.chatInput.lastDraftTarget" -type PersistedDraftTarget = { projectId: string | null; directory: string | null } +// `target` records which side of the composer's target selector the user last +// worked on, so a plain "new session" reopens there instead of always landing +// on Chat. Records written before this field existed carry no kind — they stay +// `null` and leave the Chat default in place rather than guessing one. +type PersistedDraftTarget = { + projectId: string | null + directory: string | null + target: NewSessionDraftTarget | null +} const readPersistedDraftTarget = (): PersistedDraftTarget | null => { try { const raw = safeStorage.getItem(DRAFT_TARGET_STORAGE_KEY) if (!raw) return null - const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown } + const parsed = JSON.parse(raw) as { projectId?: unknown; directory?: unknown; target?: unknown } return { projectId: typeof parsed?.projectId === "string" ? parsed.projectId : null, directory: normalizePath(typeof parsed?.directory === "string" ? parsed.directory : null), + target: parsed?.target === "chat" || parsed?.target === "project" ? parsed.target : null, } } catch { return null @@ -723,7 +739,7 @@ const recoverStaleDraftDirectory = async (openedDraft: NewSessionDraftState): Pr } useSessionUIStore.setState({ newSessionDraft: nextDraft }) writeRuntimeSessionMemory(runtimeMemoryKey(), { draft: nextDraft }) - persistDraftTarget({ projectId: nextDraft.selectedProjectId ?? null, directory: recovered }) + persistDraftTarget({ projectId: nextDraft.selectedProjectId ?? null, directory: recovered, target: nextDraft.target }) void activateConfigForDirectory(recovered) } @@ -769,6 +785,29 @@ const createSessionWithDraftLifecycle = async ( } } +/** + * The effort a send should record for its session. + * + * A send carries `undefined` both when no effort was ever chosen and when the + * user explicitly picked "Default", so the sent value alone cannot tell the two + * apart, and recording it raw clears a real "Default". The live selection can + * tell them apart, because its `override` keeps `null` for "Default" — but only + * while it still describes the agent and model being sent to. Otherwise the + * send's own value is all there is to go on. + */ +const resolveVariantToRecord = ( + agentName: string | undefined, + providerID: string, + modelID: string, + sentVariant: string | undefined, +): string | null | undefined => { + const config = useConfigStore.getState() + const describesThisSend = config.currentProviderId === providerID + && config.currentModelId === modelID + && config.currentAgentName === agentName + return describesThisSend ? config.currentVariantSelection.override : sentVariant +} + export async function materializeOpenDraftSession(selection: { providerID: string modelID: string @@ -831,6 +870,7 @@ export async function materializeOpenDraftSession(selection: { persistDraftTarget({ projectId: draftProjectId, directory: createdDirectory, + target: draft.target, }) const draftSyntheticParts = draft.syntheticParts @@ -840,11 +880,12 @@ export async function materializeOpenDraftSession(selection: { }) const effectiveDraftAgent = trimmedAgent ?? configState.currentAgentName - const variantOverride = configState.currentProviderId === selection.providerID - && configState.currentModelId === selection.modelID - && configState.currentAgentName === effectiveDraftAgent - ? configState.currentVariantSelection.override ?? undefined - : selection.variant + const variantOverride = resolveVariantToRecord( + effectiveDraftAgent, + selection.providerID, + selection.modelID, + selection.variant, + ) useSelectionStore.getState().saveSessionModelSelection(created.id, selection.providerID, selection.modelID) @@ -1103,17 +1144,43 @@ export const useSessionUIStore = create()((set, get) => ({ const currentDirectory = normalizePath(useDirectoryStore.getState().currentDirectory ?? null) const persistedTarget = readPersistedDraftTarget() - const explicitDirectory = options?.directoryOverride !== undefined + // Callers that forward "the current session's directory" forward it for + // chat sessions too, and a chat session's scratch directory names no + // project. Treating it as an explicit project target would force a project + // draft rooted in scratch; it is a request for another chat. + const rawExplicitDirectory = options?.directoryOverride !== undefined ? normalizePath(options.directoryOverride) : null + const explicitDirectoryIsChat = rawExplicitDirectory !== null && isChatDirectoryPath(rawExplicitDirectory) + const explicitDirectory = explicitDirectoryIsChat ? null : rawExplicitDirectory + const persistedProjectById = persistedTarget?.projectId + ? projects.find((p) => p.id === persistedTarget.projectId) ?? null + : null + const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null) + const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) + const persistedProject = persistedProjectById ?? persistedProjectByDir + + // Nothing explicit was asked for: reopen on the side the user last worked + // on. Only a recorded project target that still resolves to an existing + // project beats Chat — a project removed since must not open a draft + // pointing at a directory that is no longer registered. + const restoresProjectTarget = !isVSCodeRuntime() + && !options?.target + && options?.directoryOverride === undefined + && options?.selectedProjectId === undefined + && persistedTarget?.target === "project" + && persistedProject !== null + let target = isVSCodeRuntime() ? "project" : options?.target if (!target) { - const hasExplicitProjectTarget = options?.directoryOverride !== undefined + const hasExplicitProjectTarget = (options?.directoryOverride !== undefined && !explicitDirectoryIsChat) || (options?.selectedProjectId !== undefined && options.selectedProjectId !== CHAT_DRAFT_PROJECT_ID) || isVSCodeRuntime() - target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID || !hasExplicitProjectTarget + target = options?.selectedProjectId === CHAT_DRAFT_PROJECT_ID ? "chat" - : "project" + : hasExplicitProjectTarget || restoresProjectTarget + ? "project" + : "chat" } const explicitProject = target === "project" && options?.selectedProjectId ? projects.find((p) => p.id === options.selectedProjectId) ?? null @@ -1126,24 +1193,23 @@ export const useSessionUIStore = create()((set, get) => ({ return projects[0] ?? null })() - const persistedProjectById = persistedTarget?.projectId - ? projects.find((p) => p.id === persistedTarget.projectId) ?? null - : null - const persistedProjectByDir = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, persistedTarget?.directory ?? null) - const currentDirProject = resolveDraftProjectForDirectory(projects, availableWorktreesByProject, currentDirectory) - const selectedProject = target === "chat" ? null : (() => { if (explicitProject) return explicitProject if (explicitDirectory !== null) return inferredProjectFromDir - if (currentDirectory) return currentDirProject - return persistedProjectByDir ?? persistedProjectById ?? fallbackProject + // A chat session leaves a managed scratch directory behind as the current + // one; it owns no project, so it must not decide this draft's project — + // the recorded target below knows which project the user last chose. + if (currentDirectory && !isChatDirectoryPath(currentDirectory)) return currentDirProject + return persistedProject ?? fallbackProject })() const directory = target === "chat" ? null : (() => { if (explicitDirectory !== null) return explicitDirectory if (explicitProject) return normalizePath(explicitProject.path ?? null) - if (currentDirectory) return currentDirectory - if (persistedTarget?.directory) return persistedTarget.directory + // A chat session's directory is a managed scratch folder, never a + // project: letting it through would open a project draft rooted in it. + if (currentDirectory && !isChatDirectoryPath(currentDirectory)) return currentDirectory + if (persistedTarget?.directory && !isChatDirectoryPath(persistedTarget.directory)) return persistedTarget.directory return normalizePath(selectedProject?.path ?? null) })() @@ -1151,7 +1217,7 @@ export const useSessionUIStore = create()((set, get) => ({ warmChatsRootDirectory() } - persistDraftTarget({ projectId: selectedProject?.id ?? null, directory }) + persistDraftTarget({ projectId: selectedProject?.id ?? null, directory, target }) const nextDraft: NewSessionDraftState = { draftId: nextDraftId++, @@ -1305,6 +1371,15 @@ export const useSessionUIStore = create()((set, get) => ({ }, } }) + // Picking a side of the target selector is the choice the next plain "new + // session" reopens on, so it is recorded here too — not only when a draft + // is opened or a session is created from one. + const chosenDraft = get().newSessionDraft + persistDraftTarget({ + projectId: chosenDraft.target === "chat" ? null : chosenDraft.selectedProjectId ?? null, + directory: chosenDraft.directoryOverride ?? null, + target: chosenDraft.target, + }) void activateConfigForDirectory(nextDirectory) if (nextDirectory && nextDirectory !== useDirectoryStore.getState().currentDirectory) { @@ -1667,7 +1742,13 @@ export const useSessionUIStore = create()((set, get) => ({ if (targetSessionId && effectiveAgent) { useSelectionStore.getState().saveSessionAgentSelection(targetSessionId, effectiveAgent) useSelectionStore.getState().saveAgentModelForSession(targetSessionId, effectiveAgent, providerID, modelID) - useSelectionStore.getState().saveAgentModelVariantForSession(targetSessionId, effectiveAgent, providerID, modelID, variant) + useSelectionStore.getState().saveAgentModelVariantForSession( + targetSessionId, + effectiveAgent, + providerID, + modelID, + resolveVariantToRecord(effectiveAgent, providerID, modelID, variant), + ) } if (targetSessionId) { @@ -1901,47 +1982,26 @@ export const useSessionUIStore = create()((set, get) => ({ }, // --------------------------------------------------------------------------- - // createSessionFromAssistantMessage — reads from sync + // createSessionFromAssistantMessage — uses the rendered source context // --------------------------------------------------------------------------- - createSessionFromAssistantMessage: async (sourceMessageId, execution) => { - if (!sourceMessageId) return + createSessionFromAssistantMessage: async (source, execution) => { + if (!source.sessionId) return if (!execution?.instructions?.trim()) return - - // Find which session this message belongs to by scanning sync state - const state = getDirectoryState() - if (!state) return - - let sourceSessionId: string | undefined - let sourceMessage: Message | undefined - - for (const [sid, msgs] of Object.entries(state.message ?? {})) { - const found = msgs.find((m) => m.id === sourceMessageId) - if (found) { - sourceSessionId = sid - sourceMessage = found - break - } - } - - if (!sourceMessage || sourceMessage.role !== "assistant") return - - const sourceParts = getSyncParts(sourceMessageId) - const assistantPlanText = flattenAssistantTextParts(sourceParts) + const assistantPlanText = source.text if (!assistantPlanText.trim()) return - const directory = resolveSessionDirectory( - sourceSessionId ?? null, - (sid) => get().worktreeMetadata.get(sid), - ) - const sourceWorktreeMetadata = sourceSessionId ? get().worktreeMetadata.get(sourceSessionId) : undefined + const sourceDirectory = normalizePath(source.directory) + if (!sourceDirectory) { + throw new Error("Source session directory is unavailable") + } + const sourceWorktreeMetadata = get().worktreeMetadata.get(source.sessionId) - const pID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID - const mID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID + const providerID = execution.providerID || useSelectionStore.getState().lastUsedProvider?.providerID + const modelID = execution.modelID || useSelectionStore.getState().lastUsedProvider?.modelID - if (!pID || !mID) return + if (!providerID || !modelID) return - const sourceDirectory = normalizePath(directory ?? opencodeClient.getDirectory() ?? null) - let sessionDirectory = sourceDirectory + let sessionDirectory: string | null = sourceDirectory let createdWorktree: WorktreeMetadata | null = null let createdWorktreeProject: { id: string; path: string } | null = null @@ -1950,11 +2010,11 @@ export const useSessionUIStore = create()((set, get) => ({ const project = resolveProjectForSessionDirectory( projects, get().availableWorktreesByProject, - sourceDirectory, + sourceWorktreeMetadata?.projectDirectory ?? null, ) ?? resolveProjectForSessionDirectory( projects, get().availableWorktreesByProject, - sourceWorktreeMetadata?.projectDirectory ?? null, + sourceDirectory, ) if (!project?.path) { throw new Error("Project is not registered in OpenChamber") @@ -1985,13 +2045,13 @@ export const useSessionUIStore = create()((set, get) => ({ } } - const session = await get().createSession(undefined, sessionDirectory || null, null) + const session = await get().createSession(undefined, sessionDirectory, null) if (!session) { if (createdWorktree && createdWorktreeProject) { const { removeProjectWorktree } = await import("@/lib/worktrees/worktreeManager") await removeProjectWorktree(createdWorktreeProject, createdWorktree, { deleteLocalBranch: true }).catch(() => undefined) } - return + throw new Error("Failed to create session") } if (createdWorktree) { @@ -2011,8 +2071,8 @@ export const useSessionUIStore = create()((set, get) => ({ await get().sendMessage( composeForkSessionMessage(execution.instructions, assistantPlanText), - pID, - mID, + providerID, + modelID, execution.agent || undefined, undefined, undefined, diff --git a/packages/ui/src/sync/sync-context.tsx b/packages/ui/src/sync/sync-context.tsx index ed2772ed..53f61bab 100644 --- a/packages/ui/src/sync/sync-context.tsx +++ b/packages/ui/src/sync/sync-context.tsx @@ -38,12 +38,17 @@ import { setSyncRefs, getAllSyncSessions } from "./sync-refs" import { useSessionUIStore } from "./session-ui-store" import { stripSessionDiffSnapshots } from "./sanitize" import { upsertSessionRecord } from "./session-records" -import { applySessionEventToGlobalSessions, applySessionEventsToGlobalSessions } from "./session-event-router" +import { + applySessionEventToGlobalSessions, + applySessionEventsToGlobalSessions, +} from "./session-event-router" +import { shouldConsumeBulkArchiveEcho } from "./bulk-archive-echo" import { syncDebug } from "./debug" import { getReconnectCandidateSessionIds, mergeBootstrapSessions } from "./reconnect-recovery" import { messagesBefore } from "./message-ordering" import { opencodeClient } from "@/lib/opencode/client" import { usePermissionStore } from "@/stores/permissionStore" +import { applyMessageQueueUpdatedEvent, useMessageQueueStore } from "@/stores/messageQueueStore" import { processVSCodePermissionAutoAccept, processVSCodeReconciledPermissionAutoAccept, @@ -78,6 +83,7 @@ import { getRuntimeKey } from "@/lib/runtime-switch" import { getRegisteredRuntimeAPIs } from "@/contexts/runtimeAPIRegistry" import { isFilesystemError } from "@/lib/api/files-errors" import { formatMessage, useI18nStore } from "@/lib/i18n" +import { sessionEvents } from "@/lib/sessionEvents" import { listGlobalSessionPages } from "@/stores/globalSessions" import { areRequestArraysReferentiallyEqual, collectScopedBlockingRequests } from "./scoped-blocking-requests" import { EMPTY_USER_MESSAGE_HISTORY_SNAPSHOT, buildUserMessageHistorySnapshot, type UserMessageHistorySnapshot } from "./user-message-history" @@ -1572,6 +1578,11 @@ export function handleEvent( batch?: DirectoryEventBatch, globalEffectsAlreadyApplied = false, ) { + if ((payload as { type?: unknown }).type === "openchamber:message-queue.updated") { + applyMessageQueueUpdatedEvent(payload, expectedRuntimeKey) + return + } + if ((payload as { type?: unknown }).type === "openchamber:permission-auto-accept.updated") { const properties = (payload as unknown as { properties?: unknown }).properties if (properties && typeof properties === "object") { @@ -1586,6 +1597,8 @@ export function handleEvent( return } + if (shouldConsumeBulkArchiveEcho(payload, expectedRuntimeKey)) return + const directory = resolveDirectoryFromRoutingIndex(routingIndex, rawDirectory, payload, childStores, batch) if (payload.type === "session.deleted" && expectedRuntimeKey === getRuntimeKey()) { @@ -1818,6 +1831,10 @@ export function handleEvent( // type will mutate. This preserves reference identity for untouched slices // so Zustand selectors skip re-renders for unrelated subscribers. const current = getDirectoryEventState(store, batch) + const updatedPart = payload.type === "message.part.updated" ? payload.properties.part : undefined + const previousPart = updatedPart && "messageID" in updatedPart + ? current.part[updatedPart.messageID]?.find((part) => part.id === updatedPart.id) + : undefined const draft: State = { ...current } const clonedFields = batch?.clonedFields.get(store) ?? new Set() const newlyClonedFields: Array = [] @@ -1894,6 +1911,10 @@ export function handleEvent( const reducerChanged = typeof reducerResult === "boolean" ? reducerResult : reducerResult.changed const materializationResult = typeof reducerResult === "boolean" ? undefined : reducerResult.materialization + if (reducerChanged && updatedPart) { + sessionEvents.requestGitRefreshForToolTransition(resolvedDirectory, previousPart, updatedPart) + } + if (reducerChanged) { countSyncPerformance("reducerChangedEvents") const eventSessionID = getSessionIdFromPayload(payload) ?? undefined @@ -2226,6 +2247,7 @@ export function SyncProvider(props: { // Configure child store manager useEffect(() => { void usePermissionStore.getState().hydrate().catch(() => undefined) + void useMessageQueueStore.getState().hydrate().catch(() => undefined) }, [props.sdk]) useEffect(() => { diff --git a/packages/vscode/CHANGELOG.md b/packages/vscode/CHANGELOG.md index 29772c5e..3c46ec66 100644 --- a/packages/vscode/CHANGELOG.md +++ b/packages/vscode/CHANGELOG.md @@ -1,3 +1,16 @@ +## [1.22.1] - 2026-09-04 + +- **OpenCode Go:** the usage request the extension sends to OpenCode Go now carries the `x-opencode-session` header that OpenCode Go requires from 6 September. Chat traffic already had it, because it goes through OpenCode. +- Message queue: a queued message keeps its attached context, file mentions, and skill; editing it brings them back to the composer. +- Thinking effort: picking Default now sticks after a send and across agent or session switches, and a reopened session restores the effort its last message used (thanks to @yulia-ivashko). +- Worktrees: removing a worktree no longer freezes the interface; it runs in the background with a progress toast (thanks to @yulia-ivashko). A worktree created from a branch behind its upstream now fetches first and branches from the remote (thanks to @jtatum). +- Worktrees: the New Worktree dialog keeps its form when the worktree list changes while open, and a removed worktree leaves the sidebar under every project it was listed in (thanks to @yulia-ivashko). +- Sessions: starting a rename selects the whole title (thanks to @yulia-ivashko). +- Settings: the theme no longer flips when switching sessions across directories, and a theme the extension cannot fully report keeps the current preference (thanks to @kydorn). +- Settings: Fixel Text is available as an interface font. +- Usage: exe.dev usage windows are tracked. +- Settings: the Integrations page, which offered the Claude Code and Cursor plugin installs, is gone. + ## [1.22.0] - 2026-08-30 - Switching sessions is now visually stable, without conversation jumps or partial rendering. diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 0bbb543c..bf4d6aeb 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -2,7 +2,7 @@ "name": "openchamber", "displayName": "OpenChamber", "description": "%extension.description%", - "version": "1.22.0", + "version": "1.22.1", "publisher": "fedaykindev", "private": true, "repository": { @@ -245,7 +245,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "adm-zip": "^0.6.0", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/vscode/src/DOCUMENTATION.md b/packages/vscode/src/DOCUMENTATION.md index 38bba565..7aefa3ab 100644 --- a/packages/vscode/src/DOCUMENTATION.md +++ b/packages/vscode/src/DOCUMENTATION.md @@ -72,7 +72,7 @@ The webview build emits each worker as one self-contained file. VS Code webviews - Includes session activity snapshot bridge handler used by webview parity routes (`/api/session-activity`). - Includes Zen utility model parity handler used by shared notification settings (`/api/zen/models`). - Owns managed OpenCode upgrade status and mutation handlers, including capability reporting, upgrade serialization, and process restart after a successful upgrade. - - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). + - Provider handlers cover source lookup, disconnect (`DELETE /api/provider/:id/auth`), and custom provider upsert (`PUT /api/provider`; create/update OpenAI Chat Completions, OpenAI Responses, or Anthropic Messages config with explicit `scope` for user/project/custom layers; requires `env` or stored auth; secrets via OpenCode auth API). Updates preserve existing provider, option, and retained-model fields that the form does not manage while honoring explicit model, header, and env removal. Legacy `providers` entries migrate to the canonical `provider` key when edited. - Quota handlers keep managed exe.dev, Ollama Cloud, and Cursor credentials in the extension data directory with the same private-file contract as the web runtime. exe.dev uses one command-scoped usage token for the aggregate billing shared by every `exe-*` model provider. - `opencode-upgrade-runtime.ts` diff --git a/packages/vscode/src/opencodeConfig.providers.test.ts b/packages/vscode/src/opencodeConfig.providers.test.ts index aaec3965..8a4ac246 100644 --- a/packages/vscode/src/opencodeConfig.providers.test.ts +++ b/packages/vscode/src/opencodeConfig.providers.test.ts @@ -93,7 +93,10 @@ describe('custom provider config persistence (VS Code parity)', () => { models: { m: { name: 'M' } }, }); assert.equal(unsupported.ok, false); - assert.match(unsupported.error ?? '', /not supported/); + assert.equal( + unsupported.error, + 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic', + ); }); test('upsertProviderConfig writes and round-trips project config', () => { @@ -177,6 +180,110 @@ describe('custom provider config persistence (VS Code parity)', () => { assert.deepEqual(written.disabled_providers, ['other']); }); + test('upsertProviderConfig preserves unmanaged provider and model metadata', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + provider: { + 'campus-llm': { + npm: '@ai-sdk/openai-compatible', + name: 'Old', + customProviderField: { owner: 'user' }, + env: ['OLD_KEY'], + options: { + baseURL: 'https://old.example.edu/v1', + headers: { 'X-Old': '1' }, + timeout: 45_000, + }, + models: { + retained: { + name: 'Old retained name', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + removed: { + name: 'Remove me', + reasoning: true, + }, + }, + }, + }, + }); + + upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + options: { baseURL: 'https://new.example.edu/v1' }, + models: { + retained: { name: 'Retained model' }, + added: { name: 'Added model' }, + }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath).provider['campus-llm']; + assert.deepEqual(written, { + npm: '@ai-sdk/openai-compatible', + name: 'Campus LLM', + customProviderField: { owner: 'user' }, + options: { + baseURL: 'https://new.example.edu/v1', + timeout: 45_000, + }, + models: { + retained: { + name: 'Retained model', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + added: { name: 'Added model' }, + }, + }); + }); + + test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + providers: { + legacy: { + name: 'Legacy provider', + options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Old model', reasoning: true } }, + }, + }, + }); + + upsertProviderConfig('legacy', { + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1' }, + models: { model: { name: 'Updated model' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath); + assert.equal(written.providers, undefined); + assert.deepEqual(written.provider.legacy, { + npm: '@ai-sdk/openai-compatible', + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Updated model', reasoning: true } }, + }); + }); + test('upsert then remove restores absence', () => { upsertProviderConfig('temp-provider', { name: 'Temp', diff --git a/packages/vscode/src/opencodeConfig.ts b/packages/vscode/src/opencodeConfig.ts index e3371720..629aecaa 100644 --- a/packages/vscode/src/opencodeConfig.ts +++ b/packages/vscode/src/opencodeConfig.ts @@ -2272,6 +2272,21 @@ const CUSTOM_PROVIDER_NPM_PACKAGES = new Set([ '@ai-sdk/anthropic', ]); +type JsonValue = string | number | boolean | null | JsonObject | JsonValue[]; +type JsonObject = { [key: string]: JsonValue }; +type NormalizedCustomProviderModel = JsonObject & { name: string }; +type NormalizedCustomProviderOptions = JsonObject & { + baseURL: string; + headers?: Record; +}; +type NormalizedCustomProviderConfig = JsonObject & { + npm: string; + name: string; + options: NormalizedCustomProviderOptions; + models: Record; + env?: string[]; +}; + export const validateCustomProviderConfig = ( providerId: string, config: unknown, @@ -2292,7 +2307,10 @@ export const validateCustomProviderConfig = ( const npm = typeof config.npm === 'string' ? config.npm.trim() : OPENAI_COMPATIBLE_NPM; if (!CUSTOM_PROVIDER_NPM_PACKAGES.has(npm)) { - return { ok: false as const, error: 'Custom provider npm package is not supported' }; + return { + ok: false as const, + error: 'Custom providers must use @ai-sdk/openai-compatible, @ai-sdk/openai, or @ai-sdk/anthropic', + }; } const optionsBlock = isPlainObject(config.options) ? config.options : null; @@ -2313,7 +2331,7 @@ export const validateCustomProviderConfig = ( return { ok: false as const, error: 'At least one model is required' }; } - const normalizedModels: Record = {}; + const normalizedModels: Record = {}; for (const [modelId, modelValue] of Object.entries(models)) { const trimmedId = typeof modelId === 'string' ? modelId.trim() : ''; if (!trimmedId) { @@ -2329,7 +2347,7 @@ export const validateCustomProviderConfig = ( normalizedModels[trimmedId] = { name: modelName }; } - const normalized: Record = { + const normalized: NormalizedCustomProviderConfig = { npm, name, options: { @@ -2364,13 +2382,44 @@ export const validateCustomProviderConfig = ( headers[headerKey.trim()] = headerValue.trim(); } if (Object.keys(headers).length > 0) { - (normalized.options as Record).headers = headers; + normalized.options.headers = headers; } } return { ok: true as const, value: { providerId, config: normalized } }; }; +const mergeCustomProviderConfig = ( + existingValue: JsonValue | undefined, + normalizedConfig: NormalizedCustomProviderConfig, +) => { + const existing = isPlainObject(existingValue) ? existingValue : {}; + const existingOptions = isPlainObject(existing.options) ? existing.options : {}; + const mergedOptions = { ...existingOptions, ...normalizedConfig.options }; + if (!Object.prototype.hasOwnProperty.call(normalizedConfig.options, 'headers')) { + delete mergedOptions.headers; + } + + const existingModels = isPlainObject(existing.models) ? existing.models : {}; + const mergedModels = Object.fromEntries( + Object.entries(normalizedConfig.models).map(([modelId, normalizedModel]) => { + const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {}; + return [modelId, { ...existingModel, ...normalizedModel }]; + }), + ); + + const merged = { + ...existing, + ...normalizedConfig, + options: mergedOptions, + models: mergedModels, + }; + if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) { + delete merged.env; + } + return merged; +}; + export const upsertProviderConfig = ( providerId: string, config: unknown, @@ -2406,8 +2455,24 @@ export const upsertProviderConfig = ( const providerConfig = isPlainObject(targetConfig.provider) ? { ...(targetConfig.provider as Record) } : {}; - providerConfig[validated.value.providerId] = validated.value.config; + const providersAlias = isPlainObject(targetConfig.providers) + ? { ...targetConfig.providers } + : {}; + const existingProviderValue = providerConfig[validated.value.providerId] + ?? providersAlias[validated.value.providerId]; + // SAFETY: config layers come from the JSONC parser, so provider entries are JSON values. + const existingProvider = existingProviderValue as JsonValue | undefined; + const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config); + providerConfig[validated.value.providerId] = mergedConfig; targetConfig.provider = providerConfig; + if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) { + delete providersAlias[validated.value.providerId]; + if (Object.keys(providersAlias).length === 0) { + delete targetConfig.providers; + } else { + targetConfig.providers = providersAlias; + } + } if (Array.isArray(targetConfig.disabled_providers)) { targetConfig.disabled_providers = targetConfig.disabled_providers.filter( @@ -2421,7 +2486,7 @@ export const upsertProviderConfig = ( return { providerId: validated.value.providerId, path: writePath, - config: validated.value.config, + config: mergedConfig, }; }; diff --git a/packages/vscode/webview/api/git.ts b/packages/vscode/webview/api/git.ts index 7ed75e21..94de428f 100644 --- a/packages/vscode/webview/api/git.ts +++ b/packages/vscode/webview/api/git.ts @@ -69,7 +69,7 @@ export const createVSCodeGitAPI = (): GitAPI => ({ return sendBridgeMessage('api:git/check', { directory }); }, - getGitStatus: async (directory: string, options?: { mode?: 'light' }): Promise => { + getGitStatus: async (directory: string, options?: { mode?: 'light'; fresh?: boolean }): Promise => { return sendBridgeMessage('api:git/status', { directory, mode: options?.mode }); }, diff --git a/packages/vscode/webview/main.tsx b/packages/vscode/webview/main.tsx index 03e8b1d1..4a4bc409 100644 --- a/packages/vscode/webview/main.tsx +++ b/packages/vscode/webview/main.tsx @@ -384,6 +384,14 @@ const handleLocalApiRequest = async (input: RequestInfo | URL, url: URL, init: R return unsupportedWebRouteResponse('Remote tunnel settings'); } + // Archiving a batch of sessions server-side needs an OpenChamber server + // process; the extension host has none. Answering explicitly keeps the + // shared UI on its per-session archive path instead of leaving the request + // to the generic proxy. + if (normalizedPathname === '/api/openchamber/sessions/archive') { + return unsupportedWebRouteResponse('Server-side session archiving'); + } + if (/^\/api\/projects\/[^/]+\/scheduled-tasks(?:\/[^/]+)?$/.test(normalizedPathname)) { return unsupportedWebRouteResponse('Scheduled tasks'); } diff --git a/packages/web/package.json b/packages/web/package.json index c25da06d..aef5c6ca 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -1,6 +1,6 @@ { "name": "@openchamber/web", - "version": "1.22.0", + "version": "1.22.1", "private": false, "type": "module", "main": "./server/index.js", @@ -14,7 +14,7 @@ "scripts": { "dev": "bun run build:watch", "dev:server": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} bun server/index.js --port ${OPENCHAMBER_PORT:-3001}", - "dev:server:watch": "OPENCHAMBER_RELAY_HOST=${OPENCHAMBER_RELAY_HOST:-off} nodemon --watch server --ext js --exec \"bun server/index.js --port ${OPENCHAMBER_PORT:-3001}\"", + "dev:server:watch": "node ./scripts/dev-server-watch.mjs", "build": "vite build", "build:watch": "vite build --watch", "type-check": "tsc --noEmit", @@ -25,7 +25,7 @@ "dependencies": { "@clack/prompts": "^1.1.0", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.25", + "@opencode-ai/sdk": "1.18.28", "@simplewebauthn/server": "13.3.1", "bun-pty": "^0.4.5", "compression": "^1.8.1", diff --git a/packages/web/scripts/dev-server-watch.mjs b/packages/web/scripts/dev-server-watch.mjs new file mode 100644 index 00000000..c07fcb0b --- /dev/null +++ b/packages/web/scripts/dev-server-watch.mjs @@ -0,0 +1,76 @@ +#!/usr/bin/env node +import { spawn } from 'node:child_process'; +import path from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +import { resolveBunExecutable } from '../../../scripts/lib/bun-executable.mjs'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const webRoot = path.resolve(__dirname, '..'); + +export function createDevServerWatchCommand(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const bunExecutable = options.bunExecutable ?? resolveBunExecutable({ env, platform }); + const configuredPort = env.OPENCHAMBER_PORT?.trim(); + const port = configuredPort || '3001'; + + if (!/^\d+$/.test(port) || Number(port) < 1 || Number(port) > 65535) { + throw new Error(`Invalid OPENCHAMBER_PORT: ${port}`); + } + + return { + command: bunExecutable, + args: platform === 'win32' + ? ['--watch', 'server/index.js', '--port', port] + : [ + 'x', + 'nodemon', + '--watch', + 'server', + '--ext', + 'js', + '--exec', + `bun server/index.js --port ${port}`, + ], + spawnOptions: { + cwd: webRoot, + stdio: 'inherit', + env: { + ...env, + OPENCHAMBER_RELAY_HOST: env.OPENCHAMBER_RELAY_HOST || 'off', + }, + windowsHide: true, + }, + }; +} + +export function runDevServerWatch(options = {}) { + const runner = options.runner ?? spawn; + const { command, args, spawnOptions } = createDevServerWatchCommand(options); + const child = runner(command, args, spawnOptions); + + child.on('error', (error) => { + console.error('[dev:web:server] Failed to start server watcher:', error); + process.exitCode = 1; + }); + child.on('exit', (code, signal) => { + if (signal) { + process.kill(process.pid, signal); + return; + } + process.exitCode = code ?? 1; + }); + return child; +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href; +if (isMain) { + try { + runDevServerWatch(); + } catch (error) { + console.error('[dev:web:server] Unable to configure server watcher:', error); + process.exitCode = 1; + } +} diff --git a/packages/web/scripts/dev-server-watch.test.mjs b/packages/web/scripts/dev-server-watch.test.mjs new file mode 100644 index 00000000..b4c32628 --- /dev/null +++ b/packages/web/scripts/dev-server-watch.test.mjs @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest'; + +import { createDevServerWatchCommand } from './dev-server-watch.mjs'; + +describe('createDevServerWatchCommand', () => { + it('uses the package default port and disables relay hosting by default', () => { + const command = createDevServerWatchCommand({ + platform: 'win32', + env: {}, + bunExecutable: '/opt/bun/bin/bun', + }); + + expect(command.command).toBe('/opt/bun/bin/bun'); + expect(command.args).toEqual(['--watch', 'server/index.js', '--port', '3001']); + expect(command.spawnOptions.env.OPENCHAMBER_RELAY_HOST).toBe('off'); + expect(command.spawnOptions.windowsHide).toBe(true); + }); + + it('passes the configured port and relay host to the Bun watcher', () => { + const command = createDevServerWatchCommand({ + platform: 'win32', + env: { + OPENCHAMBER_PORT: '58992', + OPENCHAMBER_RELAY_HOST: 'relay.example.test', + }, + bunExecutable: 'C:\\Tools\\Bun\\bun.exe', + }); + + expect(command.args).toEqual(['--watch', 'server/index.js', '--port', '58992']); + expect(command.spawnOptions.env.OPENCHAMBER_RELAY_HOST).toBe('relay.example.test'); + }); + + it('rejects an invalid configured port before spawning', () => { + expect(() => createDevServerWatchCommand({ + platform: 'win32', + env: { OPENCHAMBER_PORT: 'not-a-port' }, + bunExecutable: 'bun', + })).toThrow(/Invalid OPENCHAMBER_PORT/); + }); + + it('preserves the nodemon watcher command outside Windows', () => { + const command = createDevServerWatchCommand({ + platform: 'linux', + env: { OPENCHAMBER_PORT: '4200' }, + bunExecutable: '/opt/bun/bin/bun', + }); + + expect(command.command).toBe('/opt/bun/bin/bun'); + expect(command.args).toEqual([ + 'x', + 'nodemon', + '--watch', + 'server', + '--ext', + 'js', + '--exec', + 'bun server/index.js --port 4200', + ]); + }); +}); diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 911b1844..6f7795a8 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -91,6 +91,7 @@ import { createPushRuntime } from './lib/notifications/push-runtime.js'; import { createApnsRuntime } from './lib/notifications/apns-runtime.js'; import { createNotificationTemplateRuntime } from './lib/notifications/template-runtime.js'; import { createPermissionAutoAcceptRuntime } from './lib/permission-auto-accept/runtime.js'; +import { createMessageQueueRuntime } from './lib/message-queue/runtime.js'; import { createGracefulShutdownRuntime } from './lib/opencode/shutdown-runtime.js'; import { createProjectConfigRuntime } from './lib/projects/project-config.js'; import { createProjectContextRuntime } from './lib/project-context/runtime.js'; @@ -110,6 +111,7 @@ import { createDevServerScanner } from './lib/dev-servers/routes.js'; import { createDevTunnelRuntime } from './lib/dev-tunnel/runtime.js'; import { registerBrowserControlRoutes } from './lib/browser-control/routes.js'; import { createSystemPromptRuntime } from './lib/system-prompt/runtime.js'; +import { createMcpReconnectRuntime } from './lib/mcp-reconnect/runtime.js'; import { createOpenChamberSessionService } from './lib/openchamber-sessions/routes.js'; import { createScheduledTaskService } from './lib/scheduled-tasks/service.js'; import { createOpenChamberControlService } from './lib/openchamber-control/service.js'; @@ -284,6 +286,7 @@ const readCustomThemesFromDisk = (...args) => themeRuntime.readCustomThemesFromD let notificationTemplateRuntime = null; let agentToolRuntime = null; let systemPromptRuntime = null; +let mcpReconnectRuntime = null; const createTimeoutSignal = (...args) => notificationTemplateRuntime.createTimeoutSignal(...args); const formatProjectLabel = (...args) => notificationTemplateRuntime.formatProjectLabel(...args); @@ -878,6 +881,19 @@ notificationTriggerRuntime.setGetIsSessionAutoAccepting( (sessionId, directory) => permissionAutoAcceptRuntime.isSessionAutoAccepting(sessionId, directory), ); +// Queued follow-up messages are delivered by the server so a closed tab or a +// dropped connection no longer strands them (VS Code keeps its UI-side queue). +const messageQueueRuntime = createMessageQueueRuntime({ + globalEventHub: globalMessageStreamHub, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + sessionKnowledgeRuntime, + broadcastGlobalUiEvent, + onPromptSent: (sessionId) => sessionRuntime.markUserMessageSent(sessionId), + dataDir: OPENCHAMBER_DATA_DIR, +}); +messageQueueRuntime.start(); + const openCodeWatcherRuntime = createOpenCodeWatcherRuntime({ waitForOpenCodePort: (...args) => waitForOpenCodePort(...args), buildOpenCodeUrl, @@ -1208,11 +1224,15 @@ const openCodeLifecycleRuntime = createOpenCodeLifecycleRuntime({ const managedEnv = includeControl || includeWeb || includeMemory ? await (agentToolRuntime?.prepareManagedOpenCodeEnv({ includeControl, includeWeb, includeMemory }) || {}) : {}; - if (settings?.optimizeSystemPrompt !== true) return managedEnv; - const configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; - const systemPromptEnv = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent); - return { ...managedEnv, ...systemPromptEnv }; + // Each managed plugin appends itself to the config the previous one produced. + let configContent = managedEnv.OPENCODE_CONFIG_CONTENT ?? process.env.OPENCODE_CONFIG_CONTENT; + if (settings?.optimizeSystemPrompt === true) { + ({ OPENCODE_CONFIG_CONTENT: configContent } = await systemPromptRuntime.prepareManagedOpenCodeEnv(configContent)); + } + // Always on for managed OpenCode: it only retries servers OpenCode gave up on. + const mcpReconnectEnv = await mcpReconnectRuntime.prepareManagedOpenCodeEnv(configContent); + return { ...managedEnv, ...mcpReconnectEnv }; }, }); @@ -1427,6 +1447,7 @@ const gracefulShutdownRuntime = createGracefulShutdownRuntime({ sessionAssistRuntime, sessionGoalRuntime, contextObligatoryRuntime, + messageQueueRuntime, sessionRuntime, getHealthCheckInterval: () => healthCheckInterval, clearHealthCheckInterval: (value) => clearInterval(value), @@ -1485,6 +1506,11 @@ async function main(options = {}) { path, dataDir: OPENCHAMBER_DATA_DIR, }); + mcpReconnectRuntime = createMcpReconnectRuntime({ + fsPromises, + path, + dataDir: OPENCHAMBER_DATA_DIR, + }); // Pairing transports advertised to the create-device dialog. LAN reachability is // derived from the SERVER's actual bind (a wildcard bind → the machine's LAN IP; @@ -1914,6 +1940,7 @@ async function main(options = {}) { getOpenChamberEventClients: () => uiOpenChamberEventClients, writeSseEvent, permissionAutoAcceptRuntime, + messageQueueRuntime, }); const startupPipelineResult = await startupPipelineRuntime.run({ diff --git a/packages/web/server/lib/agent-tool/runtime.js b/packages/web/server/lib/agent-tool/runtime.js index 442b80fe..77545df2 100644 --- a/packages/web/server/lib/agent-tool/runtime.js +++ b/packages/web/server/lib/agent-tool/runtime.js @@ -1,5 +1,5 @@ -import { parse as parseJsonc } from 'jsonc-parser'; import { pathToFileURL } from 'node:url'; +import { appendManagedPlugin } from '../opencode/managed-plugin-config.js'; import { OPENCHAMBER_AGENT_TOOL_ACTION_DEFINITIONS, OPENCHAMBER_AGENT_TOOL_ACTIONS, @@ -252,23 +252,6 @@ ${entries.join('')} }, `; }; -const mergePluginConfig = (rawConfig, pluginUrl) => { - const errors = []; - const parsed = asNonEmptyString(rawConfig) ? parseJsonc(rawConfig, errors, { allowTrailingComma: true }) : {}; - if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its managed tool'); - } - if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) { - throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its managed tool'); - } - const configured = Array.isArray(parsed.plugin) ? parsed.plugin : []; - parsed.plugin = [ - ...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)), - pluginUrl, - ]; - return JSON.stringify(parsed); -}; - export const createAgentToolRuntime = (dependencies) => { const { crypto, @@ -296,7 +279,7 @@ export const createAgentToolRuntime = (dependencies) => { activeToken = crypto.randomBytes(32).toString('base64url'); const pluginUrl = pathToFileURL(pluginPath).href; return { - OPENCODE_CONFIG_CONTENT: mergePluginConfig(env.OPENCODE_CONFIG_CONTENT, pluginUrl), + OPENCODE_CONFIG_CONTENT: appendManagedPlugin(env.OPENCODE_CONFIG_CONTENT, pluginUrl, 'managed tool'), OPENCHAMBER_AGENT_TOOL_URL: `http://127.0.0.1:${port}/api/openchamber/agent-tool`, OPENCHAMBER_AGENT_TOOL_TOKEN: activeToken, }; diff --git a/packages/web/server/lib/mcp-reconnect/DOCUMENTATION.md b/packages/web/server/lib/mcp-reconnect/DOCUMENTATION.md new file mode 100644 index 00000000..1de0ff43 --- /dev/null +++ b/packages/web/server/lib/mcp-reconnect/DOCUMENTATION.md @@ -0,0 +1,55 @@ +# Managed MCP Reconnect + +## Purpose + +OpenCode connects each configured MCP server once, when a project directory is +first used. A server that does not come up then is marked `failed` and never +retried; a server whose live connection later drops is marked `failed` too and +stays that way until OpenCode restarts. This module injects a small plugin into +the OpenCode process OpenChamber launches that reconnects those servers, so a +server that was slow to start or crashed mid-session comes back on its own. + +## Runtime flow + +1. `prepareManagedOpenCodeEnv(configContent)` materializes the plugin under + `/mcp-reconnect/` and appends its `file://` URL to + `OPENCODE_CONFIG_CONTENT` through the shared merge in + `packages/web/server/lib/opencode/managed-plugin-config.js`. +2. It is always on for managed OpenCode. There is no setting, because it only + acts on servers OpenCode has already given up on. +3. OpenCode loads the plugin once per project directory with an SDK client + scoped to that directory, so each directory reconnects its own servers. +4. The plugin reads MCP status one second after load, calls connect for every + server in the `failed` state, then re-reads status after a per-server delay + that doubles from one second to a cap of thirty. A server seen in any other + state resets its counter. While nothing is failed it checks every thirty + seconds. +5. A dropped connection publishes `mcp.tools.changed`, which the plugin uses to + check right away instead of waiting out the idle interval. +6. OpenCode calls the plugin's `dispose` hook when it tears the directory down, + which stops the loop. + +## Invariants + +- Only `failed` is retried. `disabled` is the user's choice, and `needs_auth` + or `needs_client_registration` need the user to act; retrying those would + either re-enable a server the user turned off or loop on a login prompt. +- The plugin logs nothing and swallows every error. OpenCode already logs each + failed attempt, and a status call failing during an OpenCode restart is not + news. +- One check runs at a time; a wake-up arriving during a check is honored once + it finishes rather than starting a second loop. + +## What the UI sees + +OpenCode publishes no event when a reconnect succeeds. The chat picks the +server up on the next prompt because tools are resolved from live state, but +the MCP page reads status on bootstrap and refresh, so it can show `failed` for +a while after the server is back. + +## Runtime parity + +- Web and Desktop managed OpenCode: injected automatically. +- External OpenCode (`OPENCODE_HOST` or skip-start) and VS Code's separate + OpenCode lifecycle: not injected, because OpenChamber does not control that + process environment. diff --git a/packages/web/server/lib/mcp-reconnect/runtime.js b/packages/web/server/lib/mcp-reconnect/runtime.js new file mode 100644 index 00000000..92ea605b --- /dev/null +++ b/packages/web/server/lib/mcp-reconnect/runtime.js @@ -0,0 +1,122 @@ +import { pathToFileURL } from 'node:url'; +import { appendManagedPlugin } from '../opencode/managed-plugin-config.js'; + +/** + * OpenCode marks an MCP server `failed` when it does not come up at startup or + * when a live connection drops, and never tries again. This plugin runs inside + * the managed OpenCode process and reconnects those servers with a per-server + * exponential backoff, so a server that was merely slow to start, or a local + * one that crashed, comes back without an OpenCode restart. + * + * Only `failed` is retried. `needs_auth`, `needs_client_registration`, and + * `disabled` are user decisions or need user action, and retrying them would + * either loop on a login prompt or re-enable a server the user turned off. + * + * The plugin talks to OpenCode through the SDK client OpenCode hands it, which + * is scoped to one project directory, so each open directory reconnects its + * own servers. Nothing is logged: OpenCode already reports each failed attempt. + */ +const createPluginSource = () => String.raw` +const INITIAL_RETRY_MS = 1000 +const MAX_RETRY_MS = 30000 +const IDLE_CHECK_MS = 30000 + +export const OpenChamberMcpReconnectPlugin = async ({ client }) => { + let disposed = false + let running = false + let wakeRequested = false + let timer + // Consecutive failed attempts per server, cleared once it is seen healthy. + const attempts = new Map() + const dueAt = new Map() + + const schedule = (delayMs) => { + if (disposed) return + clearTimeout(timer) + timer = setTimeout(tick, delayMs) + } + + const tick = async () => { + if (running) { + wakeRequested = true + return + } + running = true + let delay = IDLE_CHECK_MS + try { + const statuses = (await client.mcp.status())?.data ?? {} + const now = Date.now() + const due = [] + for (const [name, entry] of Object.entries(statuses)) { + if (entry?.status !== "failed") { + attempts.delete(name) + dueAt.delete(name) + continue + } + const at = dueAt.get(name) ?? now + if (at > now) { + delay = Math.min(delay, at - now) + continue + } + due.push(name) + } + for (const name of attempts.keys()) { + if (!Object.hasOwn(statuses, name)) { + attempts.delete(name) + dueAt.delete(name) + } + } + + await Promise.allSettled(due.map((name) => client.mcp.connect({ path: { name } }))) + + // The result is read on the next tick: a server that came back clears + // its counter there, one still failed waits out its backoff. + const after = Date.now() + for (const name of due) { + const count = (attempts.get(name) ?? 0) + 1 + const wait = Math.min(INITIAL_RETRY_MS * 2 ** (count - 1), MAX_RETRY_MS) + attempts.set(name, count) + dueAt.set(name, after + wait) + delay = Math.min(delay, wait) + } + } catch { + // Status is unavailable while OpenCode is shutting down or restarting; + // the idle check picks up again when it is back. + } finally { + running = false + const wake = wakeRequested + wakeRequested = false + schedule(wake ? INITIAL_RETRY_MS : delay) + } + } + + schedule(INITIAL_RETRY_MS) + + return { + // A dropped connection publishes this event; checking right away beats + // waiting out the idle interval. + event: async ({ event }) => { + if (event?.type === "mcp.tools.changed") schedule(INITIAL_RETRY_MS) + }, + dispose: async () => { + disposed = true + clearTimeout(timer) + }, + } +} +`; + +export const createMcpReconnectRuntime = ({ fsPromises, path, dataDir }) => { + const pluginDirectory = path.join(dataDir, 'mcp-reconnect'); + const pluginPath = path.join(pluginDirectory, 'openchamber-mcp-reconnect-plugin.js'); + + const prepareManagedOpenCodeEnv = async (rawConfig) => { + await fsPromises.mkdir(pluginDirectory, { recursive: true }); + await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 }); + return { + OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'MCP reconnect plugin'), + }; + }; + + return { prepareManagedOpenCodeEnv }; +}; diff --git a/packages/web/server/lib/mcp-reconnect/runtime.test.js b/packages/web/server/lib/mcp-reconnect/runtime.test.js new file mode 100644 index 00000000..052fc73b --- /dev/null +++ b/packages/web/server/lib/mcp-reconnect/runtime.test.js @@ -0,0 +1,140 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { pathToFileURL } from 'node:url'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { createMcpReconnectRuntime } from './runtime.js'; + +const temporaryDirectories = []; +const disposers = []; + +afterEach(async () => { + await Promise.all(disposers.splice(0).map((dispose) => dispose())); + vi.useRealTimers(); + await Promise.all(temporaryDirectories.splice(0).map((directory) => fs.rm(directory, { recursive: true, force: true }))); +}); + +const materialize = async (rawConfig = '{}') => { + const dataDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-mcp-reconnect-')); + temporaryDirectories.push(dataDir); + const runtime = createMcpReconnectRuntime({ fsPromises: fs, path, dataDir }); + const prepared = await runtime.prepareManagedOpenCodeEnv(rawConfig); + const pluginPath = path.join(dataDir, 'mcp-reconnect', 'openchamber-mcp-reconnect-plugin.js'); + const pluginModule = await import(`${pathToFileURL(pluginPath).href}?test=${Date.now()}-${Math.random()}`); + return { prepared, pluginPath, plugin: pluginModule.OpenChamberMcpReconnectPlugin }; +}; + +/** + * A stand-in for the SDK client OpenCode hands to plugins. `statuses` is live + * state the test mutates; `connect` records when each attempt happened. + */ +const createClient = (statuses, { onConnect } = {}) => { + const attempts = []; + return { + attempts, + statuses, + mcp: { + status: vi.fn(async () => ({ data: { ...statuses } })), + connect: vi.fn(async ({ path: { name } }) => { + attempts.push({ name, at: Date.now() }); + onConnect?.(name); + return { data: true }; + }), + }, + }; +}; + +const start = async (plugin, client) => { + const hooks = await plugin({ client }); + disposers.push(hooks.dispose); + return hooks; +}; + +describe('managed MCP reconnect runtime', () => { + it('materializes the plugin and preserves existing plugin entries', async () => { + const { prepared, pluginPath } = await materialize('{ "plugin": ["file:///existing.js"], "model": "test/model" }'); + const config = JSON.parse(prepared.OPENCODE_CONFIG_CONTENT); + expect(config.model).toBe('test/model'); + expect(config.plugin).toEqual(['file:///existing.js', pathToFileURL(pluginPath).href]); + }); + + it('reconnects only servers in the failed state', async () => { + vi.useFakeTimers(); + const { plugin } = await materialize(); + const client = createClient({ + broken: { status: 'failed', error: 'spawn ENOENT' }, + healthy: { status: 'connected' }, + off: { status: 'disabled' }, + login: { status: 'needs_auth' }, + registration: { status: 'needs_client_registration', error: 'no client id' }, + }); + await start(plugin, client); + + await vi.advanceTimersByTimeAsync(1000); + + expect(client.attempts.map((attempt) => attempt.name)).toEqual(['broken']); + }); + + it('backs off per server up to a cap while it keeps failing', async () => { + vi.useFakeTimers(); + const { plugin } = await materialize(); + const client = createClient({ broken: { status: 'failed', error: 'refused' } }); + const started = Date.now(); + await start(plugin, client); + + await vi.advanceTimersByTimeAsync(92_000); + + expect(client.attempts.map((attempt) => attempt.at - started)).toEqual([ + 1000, 2000, 4000, 8000, 16_000, 32_000, 62_000, 92_000, + ]); + }); + + it('stops retrying once a server is back and starts fresh when it drops again', async () => { + vi.useFakeTimers(); + const { plugin } = await materialize(); + const statuses = { flaky: { status: 'failed', error: 'refused' } }; + const client = createClient(statuses, { + onConnect: () => { statuses.flaky = { status: 'connected' }; }, + }); + const hooks = await start(plugin, client); + + await vi.advanceTimersByTimeAsync(60_000); + expect(client.attempts).toHaveLength(1); + + statuses.flaky = { status: 'failed', error: 'Connection closed' }; + await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'flaky' } } }); + await vi.advanceTimersByTimeAsync(1000); + + expect(client.attempts).toHaveLength(2); + expect(client.attempts[1].at - client.attempts[0].at).toBeGreaterThan(30_000); + }); + + it('keeps going when status is temporarily unavailable', async () => { + vi.useFakeTimers(); + const { plugin } = await materialize(); + const client = createClient({ broken: { status: 'failed', error: 'refused' } }); + client.mcp.status.mockRejectedValueOnce(new Error('ECONNREFUSED')); + await start(plugin, client); + + await vi.advanceTimersByTimeAsync(1000); + expect(client.attempts).toHaveLength(0); + + await vi.advanceTimersByTimeAsync(30_000); + expect(client.attempts).toHaveLength(1); + }); + + it('does nothing after dispose', async () => { + vi.useFakeTimers(); + const { plugin } = await materialize(); + const client = createClient({ broken: { status: 'failed', error: 'refused' } }); + const hooks = await plugin({ client }); + + await hooks.dispose(); + await hooks.event({ event: { type: 'mcp.tools.changed', properties: { server: 'broken' } } }); + await vi.advanceTimersByTimeAsync(120_000); + + expect(client.mcp.status).not.toHaveBeenCalled(); + expect(client.attempts).toHaveLength(0); + }); +}); diff --git a/packages/web/server/lib/message-queue/DOCUMENTATION.md b/packages/web/server/lib/message-queue/DOCUMENTATION.md new file mode 100644 index 00000000..3ce85087 --- /dev/null +++ b/packages/web/server/lib/message-queue/DOCUMENTATION.md @@ -0,0 +1,148 @@ +# Message Queue + +## Purpose + +Owns the messages a user queued while a session was busy, and sends them the +moment the session goes idle. The queue lives in the web server so a closed +tab, a locked phone, or a dropped connection no longer strands it. Structural +template: `permission-auto-accept` — the server is authoritative, the shared UI +renders a projection, and VS Code (which has no server of its own) keeps its +UI-side queue and foreground auto-send hook. + +## Files + +- `runtime.js` — `createMessageQueueRuntime(...)` (state, persistence, + dispatch loop, event handling) and `registerMessageQueueRoutes(app, runtime)`. +- `runtime.test.js` — delivery, idleness gates, retries, holds, persistence, + concurrency with in-flight sends, slash commands, and project knowledge. + +Wiring: created in `server/index.js` after the global event hub and the +session-knowledge runtime; routes registered in +`opencode/feature-routes-runtime.js` (before the generic OpenCode proxy) with +JSON bodies enabled in `opencode/core-routes.js`; stopped by +`opencode/shutdown-runtime.js`. + +## Item + +An item is what the UI would have sent itself, captured at queue time so the +send never re-resolves mutable UI state: + +``` +{ + id, createdAt, + content, // raw text for display and editing + text, // text to deliver (agent mention stripped, file mentions resolved); defaults to content + agentMention?, // delivered as an `agent` part + attachments: [{ id, filename, mimeType, size, source, serverPath?, dataUrl }], + context: [ // what the composer had attached, in send order + { kind: 'context', text, metadata, instructions? }, // a draft chip or linked issue/PR; metadata is the UI's structured payload + { kind: 'instruction', text }, // derived from the text (skill instruction) + { kind: 'synthetic', text }, // handed to the composer by another surface + ], + sendConfig: { providerID, modelID, agent?, variant? } // required +} +``` + +The server is a courier for `context`: it validates the shape (a kind it +knows, a `metadata` object on `context` entries) and delivers each entry as a +synthetic text part, an entry's `instructions` going out as its own part just +before it and its `metadata` riding the part verbatim so the timeline renders +the context block back. The payload inside `metadata` is the UI's contract +(`lib/messages/contextParts.ts`), parsed by the UI on the way back. + +`parseQueuedItemInput` rejects anything the server could not deliver later +(no text, attachments, or context; missing model; malformed attachment or +context entry). Public snapshots and broadcasts strip the payloads — +attachment `dataUrl` (megabytes of base64) and `context` (a PR diff, say) — +so they do not ride every update; the only way to get them back is a `take`. + +## Persistence + +`/message-queue.json` (`OPENCHAMBER_DATA_DIR` or +`~/.config/openchamber`): `{ version, revision, sessions: { [sessionId]: +{ directory, items } } }`, written atomically (temp file + rename) through a +serialized write chain. A missing file is an empty queue. A malformed file is +a failure, not an empty queue: it is moved aside as +`message-queue.json.corrupt-` before the runtime starts empty, so +the next write cannot overwrite the user's data. A failed read leaves writes +disabled until a later load succeeds. `revision` is a global monotonic counter +bumped on every mutation; clients use it to reject stale snapshots. + +In-memory only, deliberately: the in-flight item (`sendingId`), retry +backoff, abort timestamps, and holds. A restart has no in-flight sends; a +persisted "sending" flag would strand a message forever. + +## Delivery loop + +1. `start()` subscribes to the global upstream hub and loads the file; on + load and on every hub `connect` it arms every session that has items. +2. `session.status` for a queued session: `idle` arms a short quiet timer + (500 ms, coalescing the burst around a turn boundary), `busy`/`retry` + clears it. A `message.updated` for a completed assistant reply arms as + well, so a missed idle event cannot strand the queue. `session.deleted` + drops the session's queue. An assistant `MessageAbortedError` records an + abort. +3. `tick(sessionId)` bails when the queue is empty, an item is in flight, or + the session is held. It re-arms after a 2 s post-abort hold (the UI's + old behavior: a stop is not immediately followed by the next prompt) or + while the head item is in retry backoff. +4. Idleness is re-verified against OpenCode before sending, because + `prompt_async` into a running turn steers into it instead of starting the + next one: `GET /session/status` must not list the session as busy/retry, + and the trailing message must not be an unfinished assistant reply (the + status map only lists busy sessions, so a missed busy event leaves no + entry while a turn still streams). A failed fetch is unknown, never idle: + the tick re-arms with backoff. +5. The head is marked in flight (broadcast), then sent: + - text starting with `/` that names a command in OpenCode's `/command` + list (skills included) goes to `POST /session/:id/command` with the + captured model, agent, variant, and file parts; + - otherwise `POST /session/:id/prompt_async` with the parts in the same + order a UI send uses: text, files, the captured context, pending project + knowledge (`sessionKnowledgeRuntime.resolvePendingForSession`, synthetic, + recorded as delivered only after the prompt is accepted), then the agent + mention. The command path sends files and captured context as `parts`. + Success removes the item, persists, broadcasts, and marks the user + message sent for notifications. Failure keeps the item, backs off + 2 s → 60 s (doubling per consecutive failure of that item), and re-arms. +6. The next item goes out after the next busy → idle cycle. + +## Holds + +Auto-review is driven from the UI and bounces the original session through +idle between iterations; the UI tells the server to hold that session's queue +(`PUT .../hold { held: true, ttlMs? }`) while a run is going and releases it +when the run ends. A hold expires on its own (default 5 min, cap 10 min) +because the UI that asserted it may be gone; the UI re-asserts it every two +minutes while the run continues. Releasing arms a dispatch. + +## Routes (`/api/message-queue`) + +Normal authenticated OpenChamber runtime routes; never on browser URL-token +allowlists. + +| Route | Purpose | +|---|---| +| `GET /api/message-queue` | Full snapshot `{ revision, sessions[] }` | +| `POST .../sessions/:id/items` | Append `{ directory, item }`; returns `{ revision, session, itemId }` and arms a dispatch (the session may already be idle) | +| `DELETE .../sessions/:id/items/:itemId` | Remove; `409` while that item is in flight | +| `POST .../sessions/:id/items/:itemId/take` | Remove and return the full item (payloads included); `404`/`409` | +| `POST .../sessions/:id/take` | Remove and return every item not in flight, in order | +| `PUT .../sessions/:id/order` | `{ itemIds }` must be a complete permutation | +| `DELETE .../sessions/:id` | Clear; the in-flight item stays | +| `PUT .../sessions/:id/hold` | `{ held, ttlMs? }` | + +Every mutation broadcasts `openchamber:message-queue.updated` with +`{ revision, session }` to all connected clients (SSE and WS), so several +devices on one server see one queue. + +Limits: 20 items per session, 50 sessions (oldest evicted, never one with an +item in flight), 200k characters of content; attachment payloads are bounded +by the route family's 50 MB JSON limit. + +## UI ownership + +`packages/ui/src/stores/messageQueueStore.ts` is the projection: see its +section in `packages/ui/src/stores/DOCUMENTATION.md`. VS Code intentionally +does not use this module; with all OpenChamber webviews closed, queued +messages there are not delivered. diff --git a/packages/web/server/lib/message-queue/runtime.js b/packages/web/server/lib/message-queue/runtime.js new file mode 100644 index 00000000..370dbe9c --- /dev/null +++ b/packages/web/server/lib/message-queue/runtime.js @@ -0,0 +1,818 @@ +// Server-owned message queue: messages the user queued while a session was +// busy, delivered by the web server the moment the session goes idle. The +// queue lives here, not in the browser, so closing the tab, locking the phone, +// or losing the connection no longer strands what was queued. Structural +// template: permission-auto-accept (server-authoritative state, UI as a +// projection, VS Code keeps its own foreground implementation). +// +// Event-driven like session-goal: the shared upstream hub delivers +// `session.status`, and an idle transition arms a short per-session timer. The +// tick re-verifies idleness against OpenCode (status map + message tail) before +// it sends, because a queued prompt sent into a running turn would be steered +// into it instead of starting the next one. + +import fs from 'fs'; +import path from 'path'; + +const QUEUE_FILE_NAME = 'message-queue.json'; +const QUEUE_FILE_VERSION = 1; + +const MAX_SESSIONS = 50; +const MAX_ITEMS_PER_SESSION = 20; +const CONTENT_CHAR_LIMIT = 200_000; + +// Idle events arrive in bursts around a turn boundary; a short quiet window +// coalesces them before the tick verifies idleness against OpenCode. +const DISPATCH_QUIET_MS = 500; +// After a user abort the UI held the queue for two seconds so the stop is not +// immediately followed by the next prompt; the server keeps that window. +const ABORT_HOLD_MS = 2_000; +const RETRY_BASE_DELAY_MS = 2_000; +const RETRY_MAX_DELAY_MS = 60_000; +// A hold is asserted by a UI-driven process (auto-review) that dies with the +// UI; it expires unless the UI keeps re-asserting it. +const HOLD_DEFAULT_TTL_MS = 5 * 60 * 1000; +const HOLD_MAX_TTL_MS = 10 * 60 * 1000; +const FETCH_TIMEOUT_MS = 15_000; +const MESSAGE_TAIL_LIMIT = 2; + +const ATTACHMENT_SOURCES = new Set(['local', 'server', 'vscode']); +// Context captured with a queued message (see QueuedContextPart in the UI +// store): attached context items carry metadata the timeline renders back; +// the other kinds are plain synthetic text. +const CONTEXT_PART_KINDS = new Set(['context', 'instruction', 'synthetic']); +const SESSION_ID_PATTERN = /^[A-Za-z0-9_-]{4,128}$/; + +const getQueuedSendRetryDelayMs = (failures) => + Math.min(RETRY_BASE_DELAY_MS * 2 ** Math.max(failures - 1, 0), RETRY_MAX_DELAY_MS); + +// Boundary readers: the only place raw JSON (client bodies, the queue file, +// OpenCode responses, hub events) is inspected. Everything below them +// branches on the domain values they return. +const asNonEmptyString = (value) => (typeof value === 'string' && value.trim() ? value.trim() : ''); +const asText = (value) => (typeof value === 'string' ? value : ''); +const asRecord = (value) => (value && typeof value === 'object' && !Array.isArray(value) ? value : null); +const asList = (value) => (Array.isArray(value) ? value : null); +const asCount = (value) => (Number.isFinite(value) && value >= 0 ? Math.floor(value) : null); + +const isValidSessionId = (value) => SESSION_ID_PATTERN.test(asNonEmptyString(value)); + +const httpError = (message, status) => Object.assign(new Error(message), { status }); + +const parseSendConfig = (value) => { + const raw = asRecord(value); + if (!raw) return null; + const providerID = asNonEmptyString(raw.providerID); + const modelID = asNonEmptyString(raw.modelID); + if (!providerID || !modelID) return null; + const sendConfig = { providerID, modelID }; + const agent = asNonEmptyString(raw.agent); + if (agent) sendConfig.agent = agent; + const variant = asNonEmptyString(raw.variant); + if (variant) sendConfig.variant = variant; + return sendConfig; +}; + +const parseAttachment = (value) => { + const raw = asRecord(value); + if (!raw) return null; + const filename = asNonEmptyString(raw.filename); + const mimeType = asNonEmptyString(raw.mimeType); + const dataUrl = asText(raw.dataUrl); + if (!filename || !mimeType || !dataUrl) return null; + const attachment = { + id: asNonEmptyString(raw.id) || `attachment-${Date.now()}-${Math.random().toString(36).slice(2, 9)}`, + filename, + mimeType, + size: asCount(raw.size) ?? 0, + source: ATTACHMENT_SOURCES.has(raw.source) ? raw.source : 'local', + }; + const serverPath = asNonEmptyString(raw.serverPath); + if (serverPath) attachment.serverPath = serverPath; + attachment.dataUrl = dataUrl; + return attachment; +}; + +const parseContextPart = (value) => { + const raw = asRecord(value); + if (!raw || !CONTEXT_PART_KINDS.has(raw.kind)) return null; + const text = asText(raw.text); + if (raw.kind !== 'context') return { kind: raw.kind, text }; + // The metadata is the UI's structured payload; the server only carries it + // to the prompt, so its shape is the UI's to validate on the way back. + const metadata = asRecord(raw.metadata); + if (!metadata) return null; + const part = { kind: 'context', text, metadata }; + const instructions = asNonEmptyString(raw.instructions); + if (instructions) part.instructions = instructions; + return part; +}; + +/** + * Validates a queued item posted by a client. Throws a TypeError (→ 400) for + * anything that could not be delivered later: a queue must never hold an item + * the server cannot send. + */ +export const parseQueuedItemInput = (value) => { + const raw = asRecord(value); + if (!raw) throw new TypeError('item is required'); + const content = asText(raw.content).replace(/^\n+|\n+$/g, ''); + if (content.length > CONTENT_CHAR_LIMIT) throw new TypeError('item content is too long'); + const text = raw.text === undefined ? content : asText(raw.text); + const attachments = (asList(raw.attachments) ?? []).map(parseAttachment); + if (attachments.some((attachment) => attachment === null)) throw new TypeError('invalid attachment'); + const context = (asList(raw.context) ?? []).map(parseContextPart); + if (context.some((part) => part === null)) throw new TypeError('invalid context part'); + if (!text.trim() && attachments.length === 0 && context.length === 0) { + throw new TypeError('item needs text, attachments, or context'); + } + const sendConfig = parseSendConfig(raw.sendConfig); + if (!sendConfig) throw new TypeError('item sendConfig with providerID and modelID is required'); + const item = { content, text }; + const agentMention = asNonEmptyString(raw.agentMention); + if (agentMention) item.agentMention = agentMention; + item.attachments = attachments; + item.context = context; + item.sendConfig = sendConfig; + return item; +}; + +const parseStoredItem = (value) => { + const raw = asRecord(value); + const id = raw ? asNonEmptyString(raw.id) : ''; + if (!id) return null; + try { + return { id, createdAt: asCount(raw.createdAt) ?? Date.now(), ...parseQueuedItemInput(raw) }; + } catch { + return null; + } +}; + +const toPublicAttachment = ({ dataUrl: _dataUrl, ...attachment }) => attachment; + +// What clients see: everything except the payloads — attachment data URLs +// (megabytes of base64) and captured context (a PR diff, say) — which would +// otherwise ride every broadcast. A take hands the full item back. +const toPublicItem = (item) => { + const publicItem = { id: item.id, createdAt: item.createdAt, content: item.content, text: item.text }; + if (item.agentMention) publicItem.agentMention = item.agentMention; + publicItem.attachments = item.attachments.map(toPublicAttachment); + publicItem.sendConfig = { ...item.sendConfig }; + return publicItem; +}; + +const extractSessionStatus = (payload) => { + if (payload.type !== 'session.status') return null; + const properties = asRecord(payload.properties) ?? {}; + const status = asRecord(properties.status) ?? {}; + const info = asRecord(properties.info) ?? {}; + const sessionId = asNonEmptyString(properties.sessionID); + const type = asNonEmptyString(status.type) || asNonEmptyString(info.type); + if (!sessionId || !type) return null; + return { sessionId, type }; +}; + +const extractAssistantMessageUpdate = (payload) => { + if (payload.type !== 'message.updated') return null; + const info = asRecord(asRecord(payload.properties)?.info); + if (!info || info.role !== 'assistant') return null; + const sessionId = asNonEmptyString(info.sessionID); + if (!sessionId) return null; + return { + sessionId, + aborted: asRecord(info.error)?.name === 'MessageAbortedError', + completed: asCount(asRecord(info.time)?.completed) !== null, + }; +}; + +const extractDeletedSessionId = (payload) => { + if (payload.type !== 'session.deleted') return null; + const properties = asRecord(payload.properties) ?? {}; + return asNonEmptyString(asRecord(properties.info)?.id) || asNonEmptyString(properties.sessionID) || null; +}; + +export function createMessageQueueRuntime({ + globalEventHub, + buildOpenCodeUrl, + getOpenCodeAuthHeaders, + sessionKnowledgeRuntime = null, + broadcastGlobalUiEvent, + onPromptSent, + dataDir, + fetchImpl = fetch, + now = Date.now, + dispatchQuietMs = DISPATCH_QUIET_MS, + abortHoldMs = ABORT_HOLD_MS, + retryDelayMs = getQueuedSendRetryDelayMs, +}) { + const filePath = path.join(dataDir, QUEUE_FILE_NAME); + + /** sessionId → { directory, items } */ + const queues = new Map(); + let revision = 0; + let loadPromise = null; + let writePromise = Promise.resolve(); + let stopped = false; + + /** In-memory only — a restart has no in-flight sends. */ + const sending = new Map(); // sessionId → itemId + const timers = new Map(); // sessionId → timeout + const failures = new Map(); // sessionId → { itemId, failures, nextAttemptAt } + const abortedAt = new Map(); // sessionId → timestamp + const holds = new Map(); // sessionId → expiresAt + + // --- persistence --------------------------------------------------------- + + const serialize = () => ({ + version: QUEUE_FILE_VERSION, + revision, + sessions: Object.fromEntries( + Array.from(queues.entries()).map(([sessionId, queue]) => [sessionId, { directory: queue.directory, items: queue.items }]), + ), + }); + + const readFile = async () => { + let raw; + try { + raw = await fs.promises.readFile(filePath, 'utf8'); + } catch (error) { + if (asRecord(error)?.code === 'ENOENT') return { sessions: {}, revision: 0 }; + throw error; + } + let parsed; + try { + parsed = JSON.parse(raw); + } catch (error) { + // Malformed is a failure, not an empty queue: keep the bytes for the + // user and start over rather than overwriting them on the next write. + const backup = `${filePath}.corrupt-${now()}`; + await fs.promises.rename(filePath, backup).catch(() => undefined); + console.warn(`[message-queue] queue file was unreadable and moved to ${backup}: ${error?.message ?? error}`); + return { sessions: {}, revision: 0 }; + } + const stored = asRecord(parsed) ?? {}; + const sessions = {}; + for (const [sessionId, value] of Object.entries(asRecord(stored.sessions) ?? {})) { + const entry = asRecord(value); + if (!entry || !isValidSessionId(sessionId)) continue; + const directory = asNonEmptyString(entry.directory); + const items = (asList(entry.items) ?? []).map(parseStoredItem).filter(Boolean); + if (!directory || items.length === 0) continue; + sessions[sessionId] = { directory, items }; + } + return { sessions, revision: asCount(stored.revision) ?? 0 }; + }; + + const load = () => { + if (!loadPromise) { + loadPromise = readFile() + .then((stored) => { + for (const [sessionId, entry] of Object.entries(stored.sessions)) queues.set(sessionId, entry); + revision = Math.max(revision, stored.revision); + }) + .catch((error) => { + // A read failure keeps the in-memory (empty) queue but must not be + // mistaken for "nothing queued": the next write would clobber the + // file, so writes stay disabled until a later load succeeds. + loadPromise = null; + throw error; + }); + } + return loadPromise; + }; + + const persist = () => { + const payload = JSON.stringify(serialize()); + writePromise = writePromise + .then(async () => { + await fs.promises.mkdir(dataDir, { recursive: true }); + const tmpPath = `${filePath}.${process.pid}.tmp`; + await fs.promises.writeFile(tmpPath, payload, 'utf8'); + await fs.promises.rename(tmpPath, filePath); + }) + .catch((error) => { + console.warn('[message-queue] failed to persist queue:', error?.message ?? error); + }); + return writePromise; + }; + + // --- snapshots ----------------------------------------------------------- + + const sessionSnapshot = (sessionId) => { + const queue = queues.get(sessionId); + return { + sessionId, + directory: queue?.directory ?? '', + items: (queue?.items ?? []).map(toPublicItem), + sendingId: sending.get(sessionId) ?? null, + }; + }; + + const snapshot = () => ({ + revision, + sessions: Array.from(queues.keys()).map(sessionSnapshot), + }); + + const broadcast = (sessionId) => { + broadcastGlobalUiEvent?.({ + type: 'openchamber:message-queue.updated', + properties: { revision, session: sessionSnapshot(sessionId) }, + }); + }; + + /** Every mutation goes through here: bump, persist, broadcast. */ + const commit = (sessionId) => { + revision += 1; + void persist(); + broadcast(sessionId); + return { revision, session: sessionSnapshot(sessionId) }; + }; + + const setQueueItems = (sessionId, directory, items) => { + if (items.length === 0) { + queues.delete(sessionId); + return; + } + queues.set(sessionId, { directory, items }); + }; + + // --- OpenCode access ----------------------------------------------------- + + const openCodeFetch = async (fetchPath, { directory, method = 'GET', body, query } = {}) => { + const base = buildOpenCodeUrl(fetchPath, ''); + const params = new URLSearchParams(query || {}); + if (directory) params.set('directory', directory); + const search = params.toString(); + const headers = { Accept: 'application/json', ...getOpenCodeAuthHeaders() }; + const init = { method, headers, signal: AbortSignal.timeout(FETCH_TIMEOUT_MS) }; + if (body) { + headers['Content-Type'] = 'application/json'; + init.body = JSON.stringify(body); + } + const response = await fetchImpl(search ? `${base}?${search}` : base, init); + if (!response.ok) { + const detail = await response.text().catch(() => ''); + throw httpError(`OpenCode ${method} ${fetchPath} failed with ${response.status}${detail ? `: ${detail.slice(0, 200)}` : ''}`, response.status); + } + return response.json().catch(() => null); + }; + + /** + * Live idleness, or null when it could not be established. Unknown is never + * idle: a fetch failure re-arms instead of sending into a running turn. + */ + const isSessionIdle = async (sessionId, directory) => { + const statuses = asRecord(await openCodeFetch('/session/status', { directory }).catch(() => null)); + if (!statuses) return null; + const type = asRecord(statuses[sessionId])?.type; + if (type === 'busy' || type === 'retry') return false; + // The status map lists only busy sessions, so a missed busy event leaves + // no entry while a turn still streams. The trailing unfinished assistant + // message is the live evidence of that turn (mirrors the UI gate). + const messages = asList(await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/message`, { + directory, + query: { limit: String(MESSAGE_TAIL_LIMIT) }, + }).catch(() => null)); + if (!messages) return null; + const last = asRecord(asRecord(messages[messages.length - 1])?.info); + if (last?.role === 'assistant' && asCount(asRecord(last.time)?.completed) === null) return false; + return true; + }; + + const resolveSlashCommand = async (text, directory) => { + if (!text.startsWith('/')) return null; + const [head, ...tail] = text.split(' '); + const name = head.slice(1); + if (!name) return null; + const commands = asList(await openCodeFetch('/command', { directory })) ?? []; + if (!commands.some((command) => asRecord(command)?.name === name)) return null; + return { name, arguments: tail.join(' ') }; + }; + + const toFilePart = (attachment) => ({ + type: 'file', + mime: attachment.mimeType, + filename: attachment.filename, + url: attachment.dataUrl, + }); + + // Captured context is delivered the way the composer delivers it: one + // synthetic text part per entry, an attached item's metadata riding along + // and its reading instructions (a linked PR) going first. + const toContextParts = (part) => { + const synthetic = { type: 'text', text: part.text, synthetic: true }; + if (part.kind !== 'context') return [synthetic]; + synthetic.metadata = part.metadata; + return part.instructions + ? [{ type: 'text', text: part.instructions, synthetic: true }, synthetic] + : [synthetic]; + }; + + const sendItem = async (sessionId, directory, item) => { + const { providerID, modelID, agent, variant } = item.sendConfig; + const fileParts = item.attachments.map(toFilePart); + const contextParts = item.context.flatMap(toContextParts); + const command = await resolveSlashCommand(item.text, directory); + if (command) { + const body = { command: command.name, arguments: command.arguments, model: `${providerID}/${modelID}` }; + if (agent) body.agent = agent; + if (variant) body.variant = variant; + const extraParts = [...fileParts, ...contextParts]; + if (extraParts.length > 0) body.parts = extraParts; + await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/command`, { directory, method: 'POST', body }); + return; + } + + // Standing project context rides the prompt exactly as a UI send would + // attach it; a failed lookup sends without it rather than not at all. + const knowledge = sessionKnowledgeRuntime + ? await sessionKnowledgeRuntime.resolvePendingForSession(sessionId, directory) + .catch(() => ({ text: '', signature: '' })) + : { text: '', signature: '' }; + // Same order as a UI send: the user's text and files, the context queued + // with them, then the standing context, then the mentioned agent. + const parts = []; + if (item.text.trim()) parts.push({ type: 'text', text: item.text }); + parts.push(...fileParts); + parts.push(...contextParts); + if (knowledge.text) parts.push({ type: 'text', text: knowledge.text, synthetic: true }); + if (item.agentMention) parts.push({ type: 'agent', name: item.agentMention }); + const body = { model: { providerID, modelID } }; + if (agent) body.agent = agent; + if (variant) body.variant = variant; + body.parts = parts; + await openCodeFetch(`/session/${encodeURIComponent(sessionId)}/prompt_async`, { directory, method: 'POST', body }); + if (knowledge.text && sessionKnowledgeRuntime) { + // After the prompt is accepted, so a rejected dispatch carries it again. + await sessionKnowledgeRuntime.recordDelivered(sessionId, directory, knowledge.signature).catch(() => undefined); + } + }; + + // --- dispatch loop ------------------------------------------------------- + + const clearTimer = (sessionId) => { + const existing = timers.get(sessionId); + if (existing) { + clearTimeout(existing); + timers.delete(sessionId); + } + }; + + const armDispatch = (sessionId, delayMs = dispatchQuietMs) => { + if (stopped || !queues.has(sessionId)) return; + clearTimer(sessionId); + const timer = setTimeout(() => { + timers.delete(sessionId); + tick(sessionId).catch((error) => { + console.warn('[message-queue] dispatch tick failed:', error?.message ?? error); + }); + }, Math.max(0, delayMs)); + timer.unref?.(); + timers.set(sessionId, timer); + }; + + const isHeld = (sessionId) => { + const expiresAt = holds.get(sessionId); + if (expiresAt === undefined) return false; + if (expiresAt > now()) return true; + holds.delete(sessionId); + return false; + }; + + async function tick(sessionId) { + if (stopped) return; + const queue = queues.get(sessionId); + if (!queue || queue.items.length === 0 || sending.has(sessionId) || isHeld(sessionId)) return; + + const abortHoldUntil = (abortedAt.get(sessionId) ?? 0) + abortHoldMs; + if (abortHoldUntil > now()) { + armDispatch(sessionId, abortHoldUntil - now()); + return; + } + + const head = queue.items[0]; + const failure = failures.get(sessionId); + if (failure && failure.itemId !== head.id) failures.delete(sessionId); + else if (failure && failure.nextAttemptAt > now()) { + armDispatch(sessionId, failure.nextAttemptAt - now()); + return; + } + + const idle = await isSessionIdle(sessionId, queue.directory); + if (idle === null) { + armDispatch(sessionId, retryDelayMs(1)); + return; + } + // Busy: the next idle status event re-arms the loop. + if (!idle) return; + + // Re-read after the awaits — the user may have edited the queue meanwhile. + const current = queues.get(sessionId); + const item = current?.items[0]; + if (!item || item.id !== head.id || sending.has(sessionId)) return; + + sending.set(sessionId, item.id); + broadcast(sessionId); + try { + await sendItem(sessionId, current.directory, item); + const after = queues.get(sessionId); + if (after) setQueueItems(sessionId, after.directory, after.items.filter((entry) => entry.id !== item.id)); + failures.delete(sessionId); + sending.delete(sessionId); + commit(sessionId); + try { + onPromptSent?.(sessionId); + } catch { + // bookkeeping only + } + console.log(`[message-queue] sent queued message to ${sessionId}`); + } catch (error) { + sending.delete(sessionId); + const count = (failure?.itemId === item.id ? failure.failures : 0) + 1; + const nextAttemptAt = now() + retryDelayMs(count); + failures.set(sessionId, { itemId: item.id, failures: count, nextAttemptAt }); + console.warn(`[message-queue] send to ${sessionId} failed (attempt ${count}):`, error?.message ?? error); + broadcast(sessionId); + armDispatch(sessionId, nextAttemptAt - now()); + } + } + + const reconcileAll = () => { + for (const sessionId of queues.keys()) { + if (!timers.has(sessionId)) armDispatch(sessionId, dispatchQuietMs); + } + }; + + // --- public mutations ---------------------------------------------------- + + const requireSessionId = (sessionId) => { + if (!isValidSessionId(sessionId)) throw new TypeError('sessionId is invalid'); + return sessionId; + }; + + const enqueue = async (sessionIdInput, directoryInput, itemInput) => { + const sessionId = requireSessionId(sessionIdInput); + const directory = asNonEmptyString(directoryInput); + if (!directory) throw new TypeError('directory is required'); + const parsed = parseQueuedItemInput(itemInput); + await load(); + const item = { + id: `queued-${now()}-${Math.random().toString(36).slice(2, 9)}`, + createdAt: now(), + ...parsed, + }; + const existing = queues.get(sessionId); + const items = [...(existing?.items ?? []), item].slice(-MAX_ITEMS_PER_SESSION); + queues.set(sessionId, { directory, items }); + if (queues.size > MAX_SESSIONS) { + const oldest = Array.from(queues.entries()) + .filter(([id]) => id !== sessionId && !sending.has(id)) + .sort((left, right) => (left[1].items[0]?.createdAt ?? 0) - (right[1].items[0]?.createdAt ?? 0)) + .slice(0, queues.size - MAX_SESSIONS); + for (const [staleId] of oldest) { + queues.delete(staleId); + clearTimer(staleId); + broadcast(staleId); + } + } + const result = commit(sessionId); + // The session may already be idle (queued from a busy-looking composer + // right as the turn ended); the tick verifies before sending. + armDispatch(sessionId); + return { ...result, itemId: item.id }; + }; + + const remove = async (sessionIdInput, itemId) => { + const sessionId = requireSessionId(sessionIdInput); + await load(); + if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409); + const queue = queues.get(sessionId); + if (!queue || !queue.items.some((item) => item.id === itemId)) { + return { revision, session: sessionSnapshot(sessionId) }; + } + setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id !== itemId)); + return commit(sessionId); + }; + + /** Removes the item and hands its full payload (attachments included) back. */ + const take = async (sessionIdInput, itemId) => { + const sessionId = requireSessionId(sessionIdInput); + await load(); + if (sending.get(sessionId) === itemId) throw httpError('message is being sent', 409); + const queue = queues.get(sessionId); + const item = queue?.items.find((entry) => entry.id === itemId); + if (!queue || !item) throw httpError('queued message not found', 404); + setQueueItems(sessionId, queue.directory, queue.items.filter((entry) => entry.id !== itemId)); + return { ...commit(sessionId), item }; + }; + + /** Removes every item not currently being sent and hands them back in order. */ + const takeAll = async (sessionIdInput) => { + const sessionId = requireSessionId(sessionIdInput); + await load(); + const queue = queues.get(sessionId); + if (!queue) return { revision, session: sessionSnapshot(sessionId), items: [] }; + const sendingId = sending.get(sessionId) ?? null; + const items = queue.items.filter((item) => item.id !== sendingId); + if (items.length === 0) return { revision, session: sessionSnapshot(sessionId), items: [] }; + setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId)); + return { ...commit(sessionId), items }; + }; + + const reorder = async (sessionIdInput, itemIds) => { + const sessionId = requireSessionId(sessionIdInput); + if (!asList(itemIds) || itemIds.some((id) => !asNonEmptyString(id))) { + throw new TypeError('itemIds must be a list of ids'); + } + await load(); + const queue = queues.get(sessionId); + if (!queue) return { revision, session: sessionSnapshot(sessionId) }; + const byId = new Map(queue.items.map((item) => [item.id, item])); + if (itemIds.length !== byId.size || new Set(itemIds).size !== itemIds.length || itemIds.some((id) => !byId.has(id))) { + throw new TypeError('itemIds must list every queued message exactly once'); + } + queues.set(sessionId, { directory: queue.directory, items: itemIds.map((id) => byId.get(id)) }); + return commit(sessionId); + }; + + const clear = async (sessionIdInput) => { + const sessionId = requireSessionId(sessionIdInput); + await load(); + const queue = queues.get(sessionId); + if (!queue) return { revision, session: sessionSnapshot(sessionId) }; + // Never drop a message already handed to OpenCode: its send resolves and + // must find its entry. + const sendingId = sending.get(sessionId) ?? null; + setQueueItems(sessionId, queue.directory, queue.items.filter((item) => item.id === sendingId)); + clearTimer(sessionId); + return commit(sessionId); + }; + + const setHold = (sessionIdInput, held, ttlMs = HOLD_DEFAULT_TTL_MS) => { + const sessionId = requireSessionId(sessionIdInput); + if (held !== true && held !== false) throw new TypeError('held must be a boolean'); + if (held) { + const ttl = Math.min(asCount(ttlMs) || HOLD_DEFAULT_TTL_MS, HOLD_MAX_TTL_MS); + holds.set(sessionId, now() + ttl); + clearTimer(sessionId); + return { held: true, expiresAt: holds.get(sessionId) }; + } + holds.delete(sessionId); + armDispatch(sessionId); + return { held: false, expiresAt: null }; + }; + + // --- events -------------------------------------------------------------- + + const processPayload = (value) => { + const payload = asRecord(value); + if (stopped || !payload) return; + + const deletedSessionId = extractDeletedSessionId(payload); + if (deletedSessionId) { + if (!queues.has(deletedSessionId)) return; + queues.delete(deletedSessionId); + clearTimer(deletedSessionId); + failures.delete(deletedSessionId); + commit(deletedSessionId); + return; + } + + const status = extractSessionStatus(payload); + if (status) { + if (!queues.has(status.sessionId)) return; + if (status.type === 'idle') armDispatch(status.sessionId); + else clearTimer(status.sessionId); + return; + } + + const assistant = extractAssistantMessageUpdate(payload); + if (assistant && queues.has(assistant.sessionId)) { + if (assistant.aborted) abortedAt.set(assistant.sessionId, now()); + // A completed reply without a following idle status (missed event) + // must still drain the queue; the tick verifies idleness itself. + if (assistant.completed && !timers.has(assistant.sessionId)) armDispatch(assistant.sessionId); + } + }; + + const processEvent = (event) => { + const raw = asRecord(asRecord(event)?.payload); + processPayload(asRecord(raw?.payload) ?? raw); + }; + + const start = () => { + const unsubscribeEvent = globalEventHub.subscribeEvent(processEvent); + const unsubscribeStatus = globalEventHub.subscribeStatus((status) => { + if (status?.type === 'connect') reconcileAll(); + }); + void load() + .then(() => { + if (queues.size > 0) console.log(`[message-queue] restored queues for ${queues.size} session(s)`); + reconcileAll(); + }) + .catch((error) => { + console.warn('[message-queue] failed to load queue file:', error?.message ?? error); + }); + return () => { + unsubscribeEvent(); + unsubscribeStatus(); + }; + }; + + const stop = () => { + stopped = true; + for (const timer of timers.values()) clearTimeout(timer); + timers.clear(); + }; + + return { + load, + snapshot, + sessionSnapshot, + enqueue, + remove, + take, + takeAll, + reorder, + clear, + setHold, + processPayload, + start, + stop, + /** Drains the pending write; tests and shutdown use it. */ + flush: () => writePromise, + }; +} + +export function registerMessageQueueRoutes(app, runtime) { + const respondError = (res, error, fallback) => { + const status = error instanceof TypeError ? 400 : (Number.isInteger(error?.status) ? error.status : 500); + res.status(status).json({ error: error?.message ?? fallback }); + }; + + app.get('/api/message-queue', async (_req, res) => { + try { + await runtime.load(); + res.json(runtime.snapshot()); + } catch (error) { + respondError(res, error, 'Failed to load message queue'); + } + }); + + app.post('/api/message-queue/sessions/:sessionId/items', async (req, res) => { + try { + res.json(await runtime.enqueue(req.params.sessionId, req.body?.directory, req.body?.item)); + } catch (error) { + respondError(res, error, 'Failed to queue message'); + } + }); + + app.post('/api/message-queue/sessions/:sessionId/take', async (req, res) => { + try { + res.json(await runtime.takeAll(req.params.sessionId)); + } catch (error) { + respondError(res, error, 'Failed to take queued messages'); + } + }); + + app.put('/api/message-queue/sessions/:sessionId/order', async (req, res) => { + try { + res.json(await runtime.reorder(req.params.sessionId, req.body?.itemIds)); + } catch (error) { + respondError(res, error, 'Failed to reorder queue'); + } + }); + + app.put('/api/message-queue/sessions/:sessionId/hold', async (req, res) => { + try { + await runtime.load(); + res.json(runtime.setHold(req.params.sessionId, req.body?.held, req.body?.ttlMs)); + } catch (error) { + respondError(res, error, 'Failed to update queue hold'); + } + }); + + app.delete('/api/message-queue/sessions/:sessionId', async (req, res) => { + try { + res.json(await runtime.clear(req.params.sessionId)); + } catch (error) { + respondError(res, error, 'Failed to clear queue'); + } + }); + + app.post('/api/message-queue/sessions/:sessionId/items/:itemId/take', async (req, res) => { + try { + res.json(await runtime.take(req.params.sessionId, req.params.itemId)); + } catch (error) { + respondError(res, error, 'Failed to take queued message'); + } + }); + + app.delete('/api/message-queue/sessions/:sessionId/items/:itemId', async (req, res) => { + try { + res.json(await runtime.remove(req.params.sessionId, req.params.itemId)); + } catch (error) { + respondError(res, error, 'Failed to remove queued message'); + } + }); +} diff --git a/packages/web/server/lib/message-queue/runtime.test.js b/packages/web/server/lib/message-queue/runtime.test.js new file mode 100644 index 00000000..fb64405a --- /dev/null +++ b/packages/web/server/lib/message-queue/runtime.test.js @@ -0,0 +1,426 @@ +import fs from 'fs'; +import os from 'os'; +import path from 'path'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMessageQueueRuntime, parseQueuedItemInput } from './runtime.js'; + +const SESSION = 'ses_queue_test_1'; +const DIRECTORY = '/repo'; + +const item = (overrides = {}) => ({ + content: 'follow up', + text: 'follow up', + attachments: [], + sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' }, + ...overrides, +}); + +const tempDirs = []; +const makeDataDir = () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-message-queue-')); + tempDirs.push(dir); + return dir; +}; + +afterEach(() => { + vi.useRealTimers(); + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +/** + * A fake OpenCode: status map, message tail, command list, and a log of every + * prompt/command it received. + */ +const createOpenCode = () => { + const state = { + statuses: {}, + tail: [], + commands: [], + sent: [], + failNext: null, + }; + const fetchImpl = vi.fn(async (url, init = {}) => { + const { pathname } = new URL(url); + const method = init.method ?? 'GET'; + if (state.failNext && state.failNext.test(pathname)) { + state.failNext = null; + return new Response('boom', { status: 500 }); + } + if (pathname === '/session/status') return Response.json(state.statuses); + if (pathname.endsWith('/message')) return Response.json(state.tail); + if (pathname === '/command') return Response.json(state.commands); + if (method === 'POST' && (pathname.endsWith('/prompt_async') || pathname.endsWith('/command'))) { + state.sent.push({ path: pathname, body: JSON.parse(init.body) }); + return new Response(null, { status: 204 }); + } + return new Response('not found', { status: 404 }); + }); + return { state, fetchImpl }; +}; + +const createRuntime = ({ dataDir = makeDataDir(), openCode = createOpenCode(), knowledge = null, retryDelayMs } = {}) => { + let eventHandler = () => {}; + let statusHandler = () => {}; + const broadcasts = []; + const promptSent = []; + const options = { + globalEventHub: { + subscribeEvent(handler) { eventHandler = handler; return () => {}; }, + subscribeStatus(handler) { statusHandler = handler; return () => {}; }, + }, + buildOpenCodeUrl: (fetchPath) => `http://opencode.test${fetchPath}`, + getOpenCodeAuthHeaders: () => ({}), + sessionKnowledgeRuntime: knowledge, + broadcastGlobalUiEvent: (event) => broadcasts.push(event), + onPromptSent: (sessionId) => promptSent.push(sessionId), + dataDir, + fetchImpl: openCode.fetchImpl, + dispatchQuietMs: 0, + abortHoldMs: 50, + }; + if (retryDelayMs) options.retryDelayMs = retryDelayMs; + const runtime = createMessageQueueRuntime(options); + return { + runtime, + openCode, + dataDir, + broadcasts, + promptSent, + emit: (payload, directory = DIRECTORY) => eventHandler({ payload, directory }), + connect: () => statusHandler({ type: 'connect' }), + }; +}; + +const settle = async (ms = 30) => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +describe('parseQueuedItemInput', () => { + it('rejects an item the server could not deliver later', () => { + expect(() => parseQueuedItemInput({ content: 'x' })).toThrow(TypeError); + expect(() => parseQueuedItemInput(item({ content: '', text: '' }))).toThrow(TypeError); + expect(() => parseQueuedItemInput(item({ attachments: [{ filename: 'a.png' }] }))).toThrow(TypeError); + }); + + it('keeps delivery fields and trims blank edges of the content', () => { + const parsed = parseQueuedItemInput(item({ content: '\n\nhello\n', text: 'hello', agentMention: 'reviewer' })); + expect(parsed).toEqual({ + content: 'hello', + text: 'hello', + agentMention: 'reviewer', + attachments: [], + context: [], + sendConfig: { providerID: 'anthropic', modelID: 'claude', agent: 'build' }, + }); + }); + + it('keeps captured context and rejects a malformed part', () => { + const context = [ + { kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } }, instructions: '' }, + { kind: 'instruction', text: 'use the skill' }, + { kind: 'synthetic', text: 'conflict payload' }, + ]; + expect(parseQueuedItemInput(item({ context })).context).toEqual([ + { kind: 'context', text: 'Comment on `a.ts`', metadata: { openchamberContext: { kind: 'code-comment' } } }, + { kind: 'instruction', text: 'use the skill' }, + { kind: 'synthetic', text: 'conflict payload' }, + ]); + expect(() => parseQueuedItemInput(item({ context: [{ kind: 'context', text: 'no metadata' }] }))).toThrow(TypeError); + expect(() => parseQueuedItemInput(item({ context: [{ kind: 'other', text: 'x' }] }))).toThrow(TypeError); + }); + + it('accepts an item that is only context', () => { + const parsed = parseQueuedItemInput(item({ content: '', text: '', context: [{ kind: 'synthetic', text: 'just context' }] })); + expect(parsed.text).toBe(''); + expect(parsed.context).toHaveLength(1); + }); +}); + +describe('message queue runtime', () => { + it('delivers the head of the queue when the session goes idle, in order', async () => { + const { runtime, openCode, emit, promptSent, broadcasts } = createRuntime(); + runtime.start(); + openCode.state.statuses = { [SESSION]: { type: 'busy' } }; + + await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'first', text: 'first' })); + await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'second', text: 'second' })); + await settle(); + expect(openCode.state.sent).toHaveLength(0); + + openCode.state.statuses = {}; + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + + expect(openCode.state.sent).toHaveLength(1); + expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/prompt_async`); + expect(openCode.state.sent[0].body).toEqual({ + model: { providerID: 'anthropic', modelID: 'claude' }, + agent: 'build', + parts: [{ type: 'text', text: 'first' }], + }); + expect(promptSent).toEqual([SESSION]); + expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['second']); + // Clients learned about the in-flight item and then the removal. + expect(broadcasts.at(-1)).toMatchObject({ + type: 'openchamber:message-queue.updated', + properties: { session: { sessionId: SESSION, sendingId: null } }, + }); + + // The next turn: busy, then idle again — the second message goes out. + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'busy' } } }); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent).toHaveLength(2); + expect(runtime.sessionSnapshot(SESSION).items).toEqual([]); + }); + + it('does not send into a running turn even when the status event says idle', async () => { + const { runtime, openCode, emit } = createRuntime(); + runtime.start(); + openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1 } } }]; + await runtime.enqueue(SESSION, DIRECTORY, item()); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent).toHaveLength(0); + + // The reply completes: that alone drains the queue (a missed idle event + // must not strand it). + openCode.state.tail = [{ info: { role: 'assistant', time: { created: 1, completed: 2 } } }]; + emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, time: { created: 1, completed: 2 } } } }); + await settle(); + expect(openCode.state.sent).toHaveLength(1); + }); + + it('treats an unreachable OpenCode as unknown, not idle', async () => { + const { runtime, openCode, emit } = createRuntime({ retryDelayMs: () => 10 }); + runtime.start(); + await runtime.enqueue(SESSION, DIRECTORY, item()); + openCode.state.failNext = /\/session\/status$/; + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(5); + expect(openCode.state.sent).toHaveLength(0); + // Retried after the status fetch recovers. + await settle(40); + expect(openCode.state.sent).toHaveLength(1); + }); + + it('keeps a failed item and retries with backoff', async () => { + const { runtime, openCode, emit, broadcasts } = createRuntime({ retryDelayMs: () => 20 }); + runtime.start(); + await runtime.enqueue(SESSION, DIRECTORY, item()); + openCode.state.failNext = /prompt_async$/; + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(10); + expect(openCode.state.sent).toHaveLength(0); + expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1); + expect(runtime.sessionSnapshot(SESSION).sendingId).toBeNull(); + expect(broadcasts.at(-1).properties.session.sendingId).toBeNull(); + await settle(40); + expect(openCode.state.sent).toHaveLength(1); + expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0); + }); + + it('holds delivery briefly after a user abort', async () => { + const { runtime, openCode, emit } = createRuntime(); + runtime.start(); + await runtime.enqueue(SESSION, DIRECTORY, item()); + emit({ type: 'message.updated', properties: { info: { role: 'assistant', sessionID: SESSION, error: { name: 'MessageAbortedError' } } } }); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(10); + expect(openCode.state.sent).toHaveLength(0); + await settle(80); + expect(openCode.state.sent).toHaveLength(1); + }); + + it('honors a hold until it is released', async () => { + const { runtime, openCode, emit } = createRuntime(); + runtime.start(); + await runtime.enqueue(SESSION, DIRECTORY, item()); + runtime.setHold(SESSION, true, 60_000); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent).toHaveLength(0); + + runtime.setHold(SESSION, false); + await settle(); + expect(openCode.state.sent).toHaveLength(1); + }); + + it('survives a restart and delivers once OpenCode reconnects', async () => { + const dataDir = makeDataDir(); + const first = createRuntime({ dataDir }); + first.runtime.start(); + first.openCode.state.statuses = { [SESSION]: { type: 'busy' } }; + await first.runtime.enqueue(SESSION, DIRECTORY, item({ content: 'persisted', text: 'persisted' })); + await first.runtime.flush(); + first.runtime.stop(); + + const second = createRuntime({ dataDir }); + second.runtime.start(); + await second.runtime.load(); + expect(second.runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['persisted']); + second.connect(); + await settle(); + expect(second.openCode.state.sent).toHaveLength(1); + expect(second.openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'persisted' }]); + }); + + it('moves an unreadable queue file aside instead of treating it as empty', async () => { + const dataDir = makeDataDir(); + fs.writeFileSync(path.join(dataDir, 'message-queue.json'), '{ not json'); + const { runtime } = createRuntime({ dataDir }); + await runtime.load(); + expect(runtime.snapshot().sessions).toEqual([]); + expect(fs.readdirSync(dataDir).some((name) => name.startsWith('message-queue.json.corrupt-'))).toBe(true); + }); + + it('refuses to remove or take the item currently being sent', async () => { + const { runtime, openCode, emit } = createRuntime(); + runtime.start(); + let release; + // status map, message tail, then the prompt itself (held open until released) + openCode.fetchImpl.mockImplementationOnce(async () => Response.json({})) + .mockImplementationOnce(async () => Response.json([])) + .mockImplementationOnce(() => new Promise((resolve) => { release = () => resolve(new Response(null, { status: 204 })); })); + const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item()); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(runtime.sessionSnapshot(SESSION).sendingId).toBe(itemId); + + await expect(runtime.remove(SESSION, itemId)).rejects.toMatchObject({ status: 409 }); + await expect(runtime.take(SESSION, itemId)).rejects.toMatchObject({ status: 409 }); + const taken = await runtime.takeAll(SESSION); + expect(taken.items).toEqual([]); + expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(1); + + release(); + await settle(); + expect(runtime.sessionSnapshot(SESSION).items).toHaveLength(0); + }); + + it('take hands back the full payload and leaves the rest queued', async () => { + const { runtime } = createRuntime(); + runtime.start(); + const attachment = { id: 'a1', filename: 'shot.png', mimeType: 'image/png', size: 3, source: 'local', dataUrl: 'data:image/png;base64,AAA=' }; + const first = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'with image', attachments: [attachment] })); + await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'plain' })); + + expect(runtime.sessionSnapshot(SESSION).items[0].attachments[0]).not.toHaveProperty('dataUrl'); + const taken = await runtime.take(SESSION, first.itemId); + expect(taken.item.attachments[0].dataUrl).toBe(attachment.dataUrl); + expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['plain']); + + const all = await runtime.takeAll(SESSION); + expect(all.items.map((entry) => entry.content)).toEqual(['plain']); + expect(runtime.snapshot().sessions).toEqual([]); + }); + + it('reorders only with a complete permutation', async () => { + const { runtime } = createRuntime(); + runtime.start(); + const a = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'a' })); + const b = await runtime.enqueue(SESSION, DIRECTORY, item({ content: 'b' })); + await expect(runtime.reorder(SESSION, [b.itemId])).rejects.toThrow(TypeError); + await runtime.reorder(SESSION, [b.itemId, a.itemId]); + expect(runtime.sessionSnapshot(SESSION).items.map((entry) => entry.content)).toEqual(['b', 'a']); + }); + + it('drops the queue of a deleted session', async () => { + const { runtime, emit, broadcasts } = createRuntime(); + runtime.start(); + await runtime.enqueue(SESSION, DIRECTORY, item()); + emit({ type: 'session.deleted', properties: { info: { id: SESSION } } }); + expect(runtime.snapshot().sessions).toEqual([]); + expect(broadcasts.at(-1).properties.session).toMatchObject({ sessionId: SESSION, items: [] }); + }); + + it('dispatches a queued slash command through the command endpoint', async () => { + const { runtime, openCode, emit } = createRuntime(); + runtime.start(); + openCode.state.commands = [{ name: 'review' }]; + await runtime.enqueue(SESSION, DIRECTORY, item({ content: '/review src', text: '/review src', sendConfig: { providerID: 'p', modelID: 'm', agent: 'build', variant: 'max' } })); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent).toHaveLength(1); + expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`); + expect(openCode.state.sent[0].body).toEqual({ command: 'review', arguments: 'src', model: 'p/m', agent: 'build', variant: 'max' }); + }); + + it('delivers captured context as synthetic parts, instructions first, before project knowledge', async () => { + const knowledge = { + resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }), + recordDelivered: async () => {}, + }; + const { runtime, openCode, emit } = createRuntime({ knowledge }); + runtime.start(); + const metadata = { openchamberContext: { kind: 'github-pr', number: 7, title: 'PR', url: 'https://x/pr/7' } }; + await runtime.enqueue(SESSION, DIRECTORY, item({ + agentMention: 'reviewer', + attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }], + context: [ + { kind: 'context', text: 'the diff', metadata, instructions: 'how to read it' }, + { kind: 'synthetic', text: 'conflict payload' }, + { kind: 'instruction', text: 'use the skill' }, + ], + })); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent[0].body.parts).toEqual([ + { type: 'text', text: 'follow up' }, + { type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' }, + { type: 'text', text: 'how to read it', synthetic: true }, + { type: 'text', text: 'the diff', synthetic: true, metadata }, + { type: 'text', text: 'conflict payload', synthetic: true }, + { type: 'text', text: 'use the skill', synthetic: true }, + { type: 'text', text: 'pinned notes', synthetic: true }, + { type: 'agent', name: 'reviewer' }, + ]); + }); + + it('sends captured context with a slash command too', async () => { + const { runtime, openCode, emit } = createRuntime(); + runtime.start(); + openCode.state.commands = [{ name: 'review' }]; + await runtime.enqueue(SESSION, DIRECTORY, item({ + content: '/review', + text: '/review', + context: [{ kind: 'synthetic', text: 'focus on tests' }], + })); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent[0].path).toBe(`/session/${SESSION}/command`); + expect(openCode.state.sent[0].body.parts).toEqual([{ type: 'text', text: 'focus on tests', synthetic: true }]); + }); + + it('keeps captured context out of snapshots and broadcasts, and hands it back on take', async () => { + const { runtime, broadcasts } = createRuntime(); + runtime.start(); + const context = [{ kind: 'synthetic', text: 'a large diff' }]; + const { itemId } = await runtime.enqueue(SESSION, DIRECTORY, item({ context })); + expect(runtime.sessionSnapshot(SESSION).items[0]).not.toHaveProperty('context'); + expect(runtime.sessionSnapshot(SESSION).items[0].text).toBe('follow up'); + expect(broadcasts.at(-1).properties.session.items[0]).not.toHaveProperty('context'); + const taken = await runtime.take(SESSION, itemId); + expect(taken.item.context).toEqual(context); + }); + + it('attaches pending project knowledge and records its delivery', async () => { + const recorded = []; + const knowledge = { + resolvePendingForSession: async () => ({ text: 'pinned notes', signature: 'sig-1' }), + recordDelivered: async (sessionId, directory, signature) => { recorded.push({ sessionId, directory, signature }); }, + }; + const { runtime, openCode, emit } = createRuntime({ knowledge }); + runtime.start(); + await runtime.enqueue(SESSION, DIRECTORY, item({ agentMention: 'reviewer', attachments: [{ id: 'a', filename: 'f.txt', mimeType: 'text/plain', size: 1, source: 'local', dataUrl: 'data:text/plain,hi' }] })); + emit({ type: 'session.status', properties: { sessionID: SESSION, status: { type: 'idle' } } }); + await settle(); + expect(openCode.state.sent[0].body.parts).toEqual([ + { type: 'text', text: 'follow up' }, + { type: 'file', mime: 'text/plain', filename: 'f.txt', url: 'data:text/plain,hi' }, + { type: 'text', text: 'pinned notes', synthetic: true }, + { type: 'agent', name: 'reviewer' }, + ]); + expect(recorded).toEqual([{ sessionId: SESSION, directory: DIRECTORY, signature: 'sig-1' }]); + }); +}); diff --git a/packages/web/server/lib/openchamber-sessions/routes.js b/packages/web/server/lib/openchamber-sessions/routes.js index 6c58b333..b9b768e0 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.js +++ b/packages/web/server/lib/openchamber-sessions/routes.js @@ -253,6 +253,40 @@ const latestCompletedAssistantMessageID = async ({ client, sessionID, directory return asNonEmptyString(latest?.id); }; +/** + * Upper bound on one archive batch. + * + * The batch is applied one session at a time against OpenCode, so an unbounded + * list would hold a request open for as long as the list is large. Callers with + * more sessions than this send several batches and keep their own partial + * results. + */ +const MAX_ARCHIVE_BATCH = 500; + +const parseArchiveRequest = (payload) => { + const rawIds = payload?.ids; + if (!Array.isArray(rawIds) || rawIds.length === 0) { + return { ok: false, error: 'ids must be a non-empty array of session ids' }; + } + if (rawIds.length > MAX_ARCHIVE_BATCH) { + return { ok: false, error: `ids must contain at most ${MAX_ARCHIVE_BATCH} session ids` }; + } + + const ids = []; + for (const value of rawIds) { + const id = asNonEmptyString(value); + if (!id) return { ok: false, error: 'ids must contain non-empty session ids' }; + ids.push(id); + } + + const archivedAt = payload?.archivedAt; + if (archivedAt !== undefined && (!Number.isSafeInteger(archivedAt) || archivedAt <= 0)) { + return { ok: false, error: 'archivedAt must be a positive integer timestamp' }; + } + + return { ok: true, ids, archivedAt: archivedAt ?? Date.now() }; +}; + const resolveRequestedDirectory = async ({ payload, readSettingsFromDiskMigrated, sanitizeProjects, validateDirectoryPath }) => { const projectID = asNonEmptyString(payload?.projectId) || asNonEmptyString(payload?.projectID); if (projectID) { @@ -579,6 +613,64 @@ export const createOpenChamberSessionService = (dependencies) => { return { model, agent, variant, promptDispatched: true, dispatchedAsCommand: Boolean(resolvedCommand) }; }; + /** + * Archive a batch of sessions in one request. + * + * The UI archives every session linked to a worktree before removing it. + * Doing that from the browser costs one request per session plus a store + * reconciliation between each of them, which is what made deleting a + * worktree with many sessions take tens of seconds. Here the batch stays on + * the server, next to OpenCode, and the client reconciles once. + * + * Sessions are updated one at a time on purpose: they are archived against a + * single OpenCode instance, and a fan-out of concurrent writes would trade a + * UI stall for server event-loop starvation. One failed session never stops + * the batch — it is reported in `failedIds` while the rest still archive, so + * callers keep the partial-failure behaviour they already show. + */ + const archive = async (payload = {}) => { + const parsed = parseArchiveRequest(payload); + if (!parsed.ok) { + throw new OpenChamberControlError(parsed.error, 400); + } + + const resolvedDirectory = await resolveRequestedDirectory({ + payload, + readSettingsFromDiskMigrated, + sanitizeProjects, + validateDirectoryPath, + }); + if (!resolvedDirectory.ok) { + throw new OpenChamberControlError(resolvedDirectory.error, resolvedDirectory.status || 400); + } + + if (typeof waitForOpenCodeReady === 'function') await waitForOpenCodeReady(10_000, 250); + + const directory = resolvedDirectory.directory; + const baseUrl = buildOpenCodeUrl('/', '').replace(/\/$/, ''); + const client = createOpencodeClient({ baseUrl, headers: getOpenCodeAuthHeaders() }); + + const archived = []; + const failedIds = []; + for (const sessionID of parsed.ids) { + try { + const response = await client.session.update({ + sessionID, + directory, + time: { archived: parsed.archivedAt }, + }); + const session = response?.data; + if (session?.id) archived.push(session); + else failedIds.push(sessionID); + } catch (error) { + console.warn('[OpenChamberSessions] failed to archive session', sessionID, error); + failedIds.push(sessionID); + } + } + + return { directory, archived, failedIds }; + }; + const create = async (payload = {}) => { const title = asNonEmptyString(payload.title); const prompt = asNonEmptyString(payload.prompt); @@ -813,6 +905,7 @@ export const createOpenChamberSessionService = (dependencies) => { return { create, + archive, send: (sessionID, payload) => runExisting('send', sessionID, payload), fork: (sessionID, payload) => runExisting('fork', sessionID, payload), }; @@ -843,6 +936,15 @@ export const registerOpenChamberSessionRoutes = (app, dependencies) => { } }); + app.post('/api/openchamber/sessions/archive', express.json({ limit: '1mb' }), async (req, res) => { + try { + return res.json(await service.archive(req.body && typeof req.body === 'object' ? req.body : {})); + } catch (error) { + console.error('[OpenChamberSessions] failed to archive sessions:', error); + return sendServiceError(res, error, 'Failed to archive sessions'); + } + }); + app.post( '/api/openchamber/sessions/:sessionId/send', express.json({ limit: '1mb' }), diff --git a/packages/web/server/lib/openchamber-sessions/routes.test.js b/packages/web/server/lib/openchamber-sessions/routes.test.js index c95fd6e2..6a60ce73 100644 --- a/packages/web/server/lib/openchamber-sessions/routes.test.js +++ b/packages/web/server/lib/openchamber-sessions/routes.test.js @@ -17,6 +17,7 @@ const getWorktreeBootstrapStatusMock = vi.fn(async () => ({ const sessionCreateMock = vi.fn(async () => ({ data: { id: 'ses_123' } })); const sessionForkMock = vi.fn(async () => ({ data: { id: 'ses_fork', title: 'Forked session' } })); const sessionMessagesMock = vi.fn(async () => ({ data: [] })); +const sessionUpdateMock = vi.fn(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } })); let existingSessionMessages = []; let dispatchedUserMessageSeq = 0; @@ -78,6 +79,7 @@ vi.mock('@opencode-ai/sdk/v2', () => ({ fork: sessionForkMock, messages: sessionMessagesMock, command: sessionCommandMock, + update: sessionUpdateMock, }, command: { list: commandListMock, @@ -132,6 +134,92 @@ describe('openchamber session routes', () => { sessionCommandMock.mockResolvedValue({ data: {} }); commandListMock.mockReset(); commandListMock.mockResolvedValue({ data: [] }); + sessionUpdateMock.mockReset(); + sessionUpdateMock.mockImplementation(async ({ sessionID }) => ({ data: { id: sessionID, time: { archived: 1 } } })); + }); + + describe('archiving a batch of sessions', () => { + it('archives every id against the resolved directory and returns the archived sessions', async () => { + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'], archivedAt: 1700 }) + .expect(200); + + expect(response.body.directory).toBe('/repo/app'); + expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_b']); + expect(response.body.failedIds).toEqual([]); + expect(sessionUpdateMock).toHaveBeenCalledTimes(2); + expect(sessionUpdateMock).toHaveBeenCalledWith({ + sessionID: 'ses_a', + directory: '/repo/app', + time: { archived: 1700 }, + }); + }); + + it('keeps archiving after a failed session and reports it as failed', async () => { + sessionUpdateMock.mockImplementation(async ({ sessionID }) => { + if (sessionID === 'ses_b') throw new Error('session.update failed'); + return { data: { id: sessionID } }; + }); + + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b', 'ses_c'] }) + .expect(200); + + expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a', 'ses_c']); + expect(response.body.failedIds).toEqual(['ses_b']); + }); + + it('reports a session the server did not confirm as failed instead of archived', async () => { + sessionUpdateMock.mockImplementation(async ({ sessionID }) => ( + sessionID === 'ses_b' ? { data: null } : { data: { id: sessionID } } + )); + + const { app } = createApp(); + const response = await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', 'ses_b'] }) + .expect(200); + + expect(response.body.archived.map((session) => session.id)).toEqual(['ses_a']); + expect(response.body.failedIds).toEqual(['ses_b']); + }); + + it('rejects an empty batch, an oversized batch, and non-string ids', async () => { + const { app } = createApp(); + + await request(app).post('/api/openchamber/sessions/archive').send({ directory: '/repo/app', ids: [] }).expect(400); + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: Array.from({ length: 501 }, (_, index) => `ses_${index}`) }) + .expect(400); + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a', ''] }) + .expect(400); + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/repo/app', ids: ['ses_a'], archivedAt: -1 }) + .expect(400); + + expect(sessionUpdateMock).not.toHaveBeenCalled(); + }); + + it('rejects a directory the runtime does not accept', async () => { + const { app } = createApp({ + validateDirectoryPath: async () => ({ ok: false, error: 'Invalid directory' }), + }); + + await request(app) + .post('/api/openchamber/sessions/archive') + .send({ directory: '/elsewhere', ids: ['ses_a'] }) + .expect(400); + + expect(sessionUpdateMock).not.toHaveBeenCalled(); + }); }); it('creates a session for a directory', async () => { diff --git a/packages/web/server/lib/opencode/DOCUMENTATION.md b/packages/web/server/lib/opencode/DOCUMENTATION.md index 49cd06a8..8576cde3 100644 --- a/packages/web/server/lib/opencode/DOCUMENTATION.md +++ b/packages/web/server/lib/opencode/DOCUMENTATION.md @@ -34,6 +34,8 @@ This module provides OpenCode server integration utilities for the web server ru - `packages/web/server/lib/opencode/startup-performance.js`: opt-in startup phase diagnostics with fixed labels and numeric metadata allowlists. - `packages/web/server/lib/agent-tool/runtime.js`: managed OpenCode custom-tool materialization, environment injection, loopback authentication, and fixed CLI action dispatch. - `packages/web/server/lib/system-prompt/runtime.js`: opt-in managed OpenCode system-prompt optimizer materialization and plugin injection. +- `packages/web/server/lib/mcp-reconnect/runtime.js`: always-on managed OpenCode plugin that reconnects MCP servers OpenCode marked `failed`, with per-server backoff. +- `packages/web/server/lib/opencode/managed-plugin-config.js`: the one `OPENCODE_CONFIG_CONTENT` merge every managed plugin (agent tools, system prompt optimizer, MCP reconnect) appends itself through. - `packages/web/server/lib/opencode/server-utils-runtime.js`: shared server runtime utilities for OpenCode proxy wiring, OpenCode port/readiness helpers, and snapshot fetchers. - `packages/web/server/lib/opencode/openchamber-routes.js`: OpenChamber update and models metadata route registration. - `packages/web/server/lib/opencode/pwa-manifest-routes.js`: PWA manifest route registration with recent-session shortcut resolution and short-lived caching. @@ -61,7 +63,7 @@ This module provides OpenCode server integration utilities for the web server ru ## Public exports (providers.js) - `getProviderSources(providerId, workingDirectory)`: Resolves which OpenCode config layers define a provider. -- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override. +- `upsertProviderConfig(providerId, config, workingDirectory, scope?, options?)`: Validates and writes a custom OpenAI-compatible provider block (`npm`, `name`, `options.baseURL`, `models`, optional `env`/`headers`) into the user/project/custom config layer. Existing provider, option, and retained-model fields not managed by the form are preserved; omitted models, headers, and env credentials remain explicit removals. Updating a legacy `providers` entry migrates it to the canonical `provider` key. Does not store API keys. Requires `config.env` or `options.hasStoredAuth` (auth already written via OpenCode `auth.set`). Edit flows must pass the provider's effective existing layer (`custom` > `project` > `user`) so updates do not create a global user override. - `validateCustomProviderConfig(providerId, config, options?)`: Structural validation for custom provider payloads (id format, http(s) base URL, models, credentials via `env` or `hasStoredAuth`). - `removeProviderConfig(providerId, workingDirectory, scope?)`: Removes a provider block from the selected config layer. @@ -130,7 +132,8 @@ The runtime maintains active-session count incrementally from idempotent activit - `killProcessOnPort(port)` Managed OpenCode launch also merges the environment returned by the agent-tool -runtime. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot +runtime, the opt-in system prompt optimizer, and the always-on MCP reconnect +plugin, each appending its `file://` entry to the previous one's config. PATH and `OPENCODE_SERVER_PASSWORD` remain lifecycle-owned and cannot be replaced by injected values. External OpenCode processes receive no OpenChamber tool injection. Managed launch env strips AppImage `ARGV0` before spawn so zsh-backed OpenCode tools do not rewrite child argv[0] to the AppImage @@ -208,6 +211,7 @@ Managed health failures are classified as `timeout`, `connection_refused`, `conn - `writeSettingsToDisk(settings)` - `persistSettings(changes)` - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`. +- Queued follow-up messages live in `/message-queue.json`, not in settings; execution ownership lives in `lib/message-queue/`. - Shared sidebar preferences are stored as validated top-level fields: `sidebarProjectDisplayMode`, `sidebarSessionGroupingMode`, `sidebarProjectSortOrder`, and `sidebarShowRecentSection`. Device-local picker selection and sticky-header state do not enter `settings.json`. ## Public exports (settings-helpers.js) diff --git a/packages/web/server/lib/opencode/core-routes.js b/packages/web/server/lib/opencode/core-routes.js index 4d6996a6..77419737 100644 --- a/packages/web/server/lib/opencode/core-routes.js +++ b/packages/web/server/lib/opencode/core-routes.js @@ -1065,6 +1065,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => { req.path.startsWith('/api/push') || req.path.startsWith('/api/notifications') || req.path.startsWith('/api/permission-auto-accept') || + req.path.startsWith('/api/message-queue') || req.path.startsWith('/api/provider') || req.path.startsWith('/api/session-folders') || req.path.startsWith('/api/small-model') || diff --git a/packages/web/server/lib/opencode/feature-routes-runtime.js b/packages/web/server/lib/opencode/feature-routes-runtime.js index 4a39043f..c0344a38 100644 --- a/packages/web/server/lib/opencode/feature-routes-runtime.js +++ b/packages/web/server/lib/opencode/feature-routes-runtime.js @@ -13,6 +13,7 @@ import { registerProjectContextRoutes } from '../project-context/routes.js'; import { registerAgentMemoryRoutes } from '../agent-memory/routes.js'; import { registerSessionKnowledgeRoutes } from '../session-knowledge/routes.js'; import { registerPermissionAutoAcceptRoutes } from '../permission-auto-accept/runtime.js'; +import { registerMessageQueueRoutes } from '../message-queue/runtime.js'; import { registerConfigEntityRoutes } from './config-entity-routes.js'; import { registerSettingsUtilityRoutes } from './core-routes.js'; import { registerProjectIconRoutes } from './project-icon-routes.js'; @@ -132,6 +133,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { writeSseEvent, emitSessionCreatedEvent, permissionAutoAcceptRuntime, + messageQueueRuntime, } = routeDependencies; registerSettingsUtilityRoutes(app, { @@ -141,6 +143,7 @@ export const createFeatureRoutesRuntime = (dependencies) => { }); registerPermissionAutoAcceptRoutes(app, permissionAutoAcceptRuntime); + registerMessageQueueRoutes(app, messageQueueRuntime); registerOpenCodeRoutes(app, { crypto, diff --git a/packages/web/server/lib/opencode/managed-plugin-config.js b/packages/web/server/lib/opencode/managed-plugin-config.js new file mode 100644 index 00000000..fc5cc8a1 --- /dev/null +++ b/packages/web/server/lib/opencode/managed-plugin-config.js @@ -0,0 +1,35 @@ +import { parse as parseJsonc } from 'jsonc-parser'; + +const isJsonObject = (value) => value !== null && value !== undefined && Object.getPrototypeOf(value) === Object.prototype; + +/** + * Append a managed OpenChamber plugin to the `plugin` list of an + * `OPENCODE_CONFIG_CONTENT` value. + * + * Existing entries are preserved; an earlier entry for the same URL is dropped + * so a restart never registers the same plugin twice. Every managed plugin + * (agent tools, system prompt optimizer, MCP reconnect) goes through this one + * merge so they compose in any order. + * + * @param {string | undefined} rawConfig the current `OPENCODE_CONFIG_CONTENT`, JSONC or unset + * @param {string} pluginUrl `file://` URL of the materialized plugin + * @param {string} purpose short noun phrase for the error message, e.g. "managed tool" + * @returns {string} the merged config as JSON + */ +export const appendManagedPlugin = (rawConfig, pluginUrl, purpose) => { + const errors = []; + const text = (rawConfig ?? '').trim(); + const parsed = text ? parseJsonc(text, errors, { allowTrailingComma: true }) : {}; + if (errors.length > 0 || !isJsonObject(parsed)) { + throw new Error(`OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its ${purpose}`); + } + if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) { + throw new Error(`OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its ${purpose}`); + } + const configured = Array.isArray(parsed.plugin) ? parsed.plugin : []; + parsed.plugin = [ + ...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)), + pluginUrl, + ]; + return JSON.stringify(parsed); +}; diff --git a/packages/web/server/lib/opencode/providers.js b/packages/web/server/lib/opencode/providers.js index 42600165..46663c09 100644 --- a/packages/web/server/lib/opencode/providers.js +++ b/packages/web/server/lib/opencode/providers.js @@ -152,6 +152,37 @@ function validateCustomProviderConfig(providerId, config, options = {}) { return { ok: true, value: { providerId, config: normalized } }; } +function mergeCustomProviderConfig(existingValue, normalizedConfig) { + const existing = isPlainObject(existingValue) ? existingValue : {}; + const existingOptions = isPlainObject(existing.options) ? existing.options : {}; + const normalizedOptions = isPlainObject(normalizedConfig.options) ? normalizedConfig.options : {}; + const mergedOptions = { ...existingOptions, ...normalizedOptions }; + if (!Object.prototype.hasOwnProperty.call(normalizedOptions, 'headers')) { + delete mergedOptions.headers; + } + + const existingModels = isPlainObject(existing.models) ? existing.models : {}; + const normalizedModels = isPlainObject(normalizedConfig.models) ? normalizedConfig.models : {}; + const mergedModels = Object.fromEntries( + Object.entries(normalizedModels).map(([modelId, normalizedModel]) => { + const existingModel = isPlainObject(existingModels[modelId]) ? existingModels[modelId] : {}; + const nextModel = isPlainObject(normalizedModel) ? normalizedModel : {}; + return [modelId, { ...existingModel, ...nextModel }]; + }), + ); + + const merged = { + ...existing, + ...normalizedConfig, + options: mergedOptions, + models: mergedModels, + }; + if (!Object.prototype.hasOwnProperty.call(normalizedConfig, 'env')) { + delete merged.env; + } + return merged; +} + /** * Persist (create or update) a custom provider block in OpenCode user/project/custom config. * Does not write secrets — API keys remain in auth.json via the OpenCode auth API. @@ -183,8 +214,19 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use const targetConfig = getConfigForPath(layers, targetPath); const providerConfig = isPlainObject(targetConfig.provider) ? { ...targetConfig.provider } : {}; - providerConfig[validated.value.providerId] = validated.value.config; + const providersAlias = isPlainObject(targetConfig.providers) ? { ...targetConfig.providers } : {}; + const existingProvider = providerConfig[validated.value.providerId] ?? providersAlias[validated.value.providerId]; + const mergedConfig = mergeCustomProviderConfig(existingProvider, validated.value.config); + providerConfig[validated.value.providerId] = mergedConfig; targetConfig.provider = providerConfig; + if (Object.prototype.hasOwnProperty.call(providersAlias, validated.value.providerId)) { + delete providersAlias[validated.value.providerId]; + if (Object.keys(providersAlias).length === 0) { + delete targetConfig.providers; + } else { + targetConfig.providers = providersAlias; + } + } if (Array.isArray(targetConfig.disabled_providers)) { targetConfig.disabled_providers = targetConfig.disabled_providers.filter( @@ -198,7 +240,7 @@ function upsertProviderConfig(providerId, config, workingDirectory, scope = 'use return { providerId: validated.value.providerId, path: writePath, - config: validated.value.config, + config: mergedConfig, }; } diff --git a/packages/web/server/lib/opencode/providers.test.js b/packages/web/server/lib/opencode/providers.test.js index 9ce3715a..35078f05 100644 --- a/packages/web/server/lib/opencode/providers.test.js +++ b/packages/web/server/lib/opencode/providers.test.js @@ -162,6 +162,110 @@ describe('custom provider config persistence', () => { expect(written.disabled_providers).toEqual(['other']); }); + test('upsertProviderConfig preserves unmanaged provider and model metadata', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + provider: { + 'campus-llm': { + npm: '@ai-sdk/openai-compatible', + name: 'Old', + customProviderField: { owner: 'user' }, + env: ['OLD_KEY'], + options: { + baseURL: 'https://old.example.edu/v1', + headers: { 'X-Old': '1' }, + timeout: 45_000, + }, + models: { + retained: { + name: 'Old retained name', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + removed: { + name: 'Remove me', + reasoning: true, + }, + }, + }, + }, + }); + + upsertProviderConfig('campus-llm', { + name: 'Campus LLM', + options: { baseURL: 'https://new.example.edu/v1' }, + models: { + retained: { name: 'Retained model' }, + added: { name: 'Added model' }, + }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath).provider['campus-llm']; + expect(written).toEqual({ + npm: '@ai-sdk/openai-compatible', + name: 'Campus LLM', + customProviderField: { owner: 'user' }, + options: { + baseURL: 'https://new.example.edu/v1', + timeout: 45_000, + }, + models: { + retained: { + name: 'Retained model', + reasoning: true, + attachment: true, + tool_call: true, + modalities: { input: ['text', 'image', 'pdf'], output: ['text'] }, + limit: { context: 1_050_000, input: 922_000, output: 128_000 }, + options: { instructions: 'Keep this instruction' }, + variants: { + low: { reasoningEffort: 'low' }, + high: { reasoningEffort: 'high' }, + }, + customModelField: { source: 'manual' }, + }, + added: { name: 'Added model' }, + }, + }); + }); + + test('upsertProviderConfig preserves metadata while migrating the legacy providers alias', () => { + const configPath = path.join(projectDir, 'opencode.json'); + writeJson(configPath, { + providers: { + legacy: { + name: 'Legacy provider', + options: { baseURL: 'https://old.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Old model', reasoning: true } }, + }, + }, + }); + + upsertProviderConfig('legacy', { + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1' }, + models: { model: { name: 'Updated model' } }, + }, projectDir, 'project', { hasStoredAuth: true }); + + const written = readJson(configPath); + expect(written.providers).toBeUndefined(); + expect(written.provider.legacy).toEqual({ + npm: '@ai-sdk/openai-compatible', + name: 'Updated provider', + options: { baseURL: 'https://new.example.com/v1', timeout: 30_000 }, + models: { model: { name: 'Updated model', reasoning: true } }, + }); + }); + test('upsert then remove restores absence', () => { upsertProviderConfig('temp-provider', { name: 'Temp', diff --git a/packages/web/server/lib/opencode/server-startup-runtime.js b/packages/web/server/lib/opencode/server-startup-runtime.js index cd945951..824650d3 100644 --- a/packages/web/server/lib/opencode/server-startup-runtime.js +++ b/packages/web/server/lib/opencode/server-startup-runtime.js @@ -138,7 +138,7 @@ export const createServerStartupRuntime = (dependencies) => { // Cover every signal a shell or dev harness may use to stop/restart us, so // the managed OpenCode child is always torn down gracefully instead of // orphaned: SIGINT/SIGQUIT (Ctrl+C/Ctrl+\), SIGTERM (kill/default), SIGHUP - // (terminal close), SIGUSR2 (nodemon restart for `dev:server:watch`). + // (terminal close), SIGUSR2 (nodemon restart outside Windows). process.on('SIGTERM', handleSignal); process.on('SIGINT', handleSignal); process.on('SIGQUIT', handleSignal); diff --git a/packages/web/server/lib/opencode/settings-runtime.js b/packages/web/server/lib/opencode/settings-runtime.js index bed2d240..2e6de0c4 100644 --- a/packages/web/server/lib/opencode/settings-runtime.js +++ b/packages/web/server/lib/opencode/settings-runtime.js @@ -692,8 +692,8 @@ export const createSettingsRuntime = (deps) => { return { settings, changed: false }; } - const defaultLight = 'flexoki-light'; - const defaultDark = 'flexoki-dark'; + const defaultLight = 'openchamber-light'; + const defaultDark = 'openchamber-dark'; let nextLightThemeId = hasLight ? settings.lightThemeId : undefined; let nextDarkThemeId = hasDark ? settings.darkThemeId : undefined; diff --git a/packages/web/server/lib/opencode/settings-runtime.test.js b/packages/web/server/lib/opencode/settings-runtime.test.js index 46a84827..95ff3c69 100644 --- a/packages/web/server/lib/opencode/settings-runtime.test.js +++ b/packages/web/server/lib/opencode/settings-runtime.test.js @@ -39,6 +39,35 @@ const createRuntime = async () => { }; describe('settings runtime', () => { + it('uses OpenChamber themes when a new install has no theme preferences', async () => { + const { runtime, cleanup } = await createRuntime(); + try { + await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({ + lightThemeId: 'openchamber-light', + darkThemeId: 'openchamber-dark', + }); + } finally { + await cleanup(); + } + }); + + it('preserves existing theme preferences during theme migration', async () => { + const { runtime, settingsFilePath, cleanup } = await createRuntime(); + try { + await fsPromises.writeFile(settingsFilePath, JSON.stringify({ + lightThemeId: 'flexoki-light', + darkThemeId: 'flexoki-dark', + }), 'utf8'); + + await expect(runtime.readSettingsFromDiskMigrated()).resolves.toMatchObject({ + lightThemeId: 'flexoki-light', + darkThemeId: 'flexoki-dark', + }); + } finally { + await cleanup(); + } + }); + it('round-trips shared sidebar preferences through settings.json', async () => { const { runtime, settingsFilePath, cleanup } = await createRuntime(); const preferences = { diff --git a/packages/web/server/lib/opencode/shutdown-runtime.js b/packages/web/server/lib/opencode/shutdown-runtime.js index 63b555a2..16404a6f 100644 --- a/packages/web/server/lib/opencode/shutdown-runtime.js +++ b/packages/web/server/lib/opencode/shutdown-runtime.js @@ -11,6 +11,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { sessionAssistRuntime, sessionGoalRuntime, contextObligatoryRuntime, + messageQueueRuntime, scheduledTasksRuntime, getHealthCheckInterval, clearHealthCheckInterval, @@ -47,6 +48,7 @@ export const createGracefulShutdownRuntime = (dependencies) => { sessionAssistRuntime?.stop?.(); sessionGoalRuntime?.stop?.(); contextObligatoryRuntime?.stop?.(); + messageQueueRuntime?.stop?.(); scheduledTasksRuntime?.stop?.(); const healthCheckInterval = getHealthCheckInterval(); diff --git a/packages/web/server/lib/system-prompt/runtime.js b/packages/web/server/lib/system-prompt/runtime.js index 3485d5a6..bd735c4d 100644 --- a/packages/web/server/lib/system-prompt/runtime.js +++ b/packages/web/server/lib/system-prompt/runtime.js @@ -1,5 +1,5 @@ -import { parse as parseJsonc } from 'jsonc-parser'; import { pathToFileURL } from 'node:url'; +import { appendManagedPlugin } from '../opencode/managed-plugin-config.js'; const PROVIDER_PROMPT_BOUNDARY = 'You are powered by the model named'; const MINIMAL_IDENTITY = 'You are OpenCode, a coding agent.'; @@ -33,25 +33,6 @@ export const OpenChamberSystemPromptPlugin = async () => ({ }) `; -const mergePluginConfig = (rawConfig, pluginUrl) => { - const errors = []; - const parsed = typeof rawConfig === 'string' && rawConfig.trim() - ? parseJsonc(rawConfig, errors, { allowTrailingComma: true }) - : {}; - if (errors.length > 0 || !parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { - throw new Error('OPENCODE_CONFIG_CONTENT must contain a valid JSON object before OpenChamber can inject its system prompt optimizer'); - } - if (parsed.plugin !== undefined && !Array.isArray(parsed.plugin)) { - throw new Error('OPENCODE_CONFIG_CONTENT plugin must be an array before OpenChamber can inject its system prompt optimizer'); - } - const configured = Array.isArray(parsed.plugin) ? parsed.plugin : []; - parsed.plugin = [ - ...configured.filter((value) => value !== pluginUrl && (!Array.isArray(value) || value[0] !== pluginUrl)), - pluginUrl, - ]; - return JSON.stringify(parsed); -}; - export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => { const pluginDirectory = path.join(dataDir, 'system-prompt'); const pluginPath = path.join(pluginDirectory, 'openchamber-system-prompt-plugin.js'); @@ -60,7 +41,7 @@ export const createSystemPromptRuntime = ({ fsPromises, path, dataDir }) => { await fsPromises.mkdir(pluginDirectory, { recursive: true }); await fsPromises.writeFile(pluginPath, createPluginSource(), { mode: 0o600 }); return { - OPENCODE_CONFIG_CONTENT: mergePluginConfig(rawConfig, pathToFileURL(pluginPath).href), + OPENCODE_CONFIG_CONTENT: appendManagedPlugin(rawConfig, pathToFileURL(pluginPath).href, 'system prompt optimizer'), }; }; diff --git a/scripts/bump-version.mjs b/scripts/bump-version.mjs index ff621862..f98c1d85 100755 --- a/scripts/bump-version.mjs +++ b/scripts/bump-version.mjs @@ -6,7 +6,9 @@ import { fileURLToPath } from 'url'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const ROOT = path.resolve(__dirname, '..'); -const PACKAGES = [ +// Every package.json a release bumps. `oc-dev create-release` imports this so +// the commit it suggests stages exactly these files. +export const RELEASE_PACKAGE_FILES = [ 'package.json', 'packages/ui/package.json', 'packages/web/package.json', @@ -14,23 +16,26 @@ const PACKAGES = [ 'packages/vscode/package.json', ]; -const newVersion = process.argv[2]; -if (!newVersion || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) { - console.error('Usage: node scripts/bump-version.mjs '); - console.error('Example: node scripts/bump-version.mjs 0.2.0'); - console.error('Example: node scripts/bump-version.mjs 0.2.0-beta.1'); - process.exit(1); +const isDirectRun = process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url); +if (isDirectRun) { + const newVersion = process.argv[2]; + if (!newVersion || !/^\d+\.\d+\.\d+(-[\w.]+)?$/.test(newVersion)) { + console.error('Usage: node scripts/bump-version.mjs '); + console.error('Example: node scripts/bump-version.mjs 0.2.0'); + console.error('Example: node scripts/bump-version.mjs 0.2.0-beta.1'); + process.exit(1); + } + + console.log(`Bumping version to ${newVersion}\n`); + + for (const pkgPath of RELEASE_PACKAGE_FILES) { + const fullPath = path.join(ROOT, pkgPath); + const pkg = JSON.parse(fs.readFileSync(fullPath, 'utf8')); + const oldVersion = pkg.version; + pkg.version = newVersion; + fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + '\n'); + console.log(` ${pkgPath}: ${oldVersion} -> ${newVersion}`); + } + + console.log('\nVersion bump complete. Review changes, then commit and tag.'); } - -console.log(`Bumping version to ${newVersion}\n`); - -for (const pkgPath of PACKAGES) { - const fullPath = path.join(ROOT, pkgPath); - const pkg = JSON.parse(fs.readFileSync(fullPath, 'utf8')); - const oldVersion = pkg.version; - pkg.version = newVersion; - fs.writeFileSync(fullPath, JSON.stringify(pkg, null, 2) + '\n'); - console.log(` ${pkgPath}: ${oldVersion} -> ${newVersion}`); -} - -console.log('\nVersion bump complete. Review changes, then commit and tag.'); diff --git a/scripts/dev-web-hmr.mjs b/scripts/dev-web-hmr.mjs index 76999a33..a897e4a8 100644 --- a/scripts/dev-web-hmr.mjs +++ b/scripts/dev-web-hmr.mjs @@ -5,18 +5,23 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; +import { resolveBunExecutable } from './lib/bun-executable.mjs'; + const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); const repoRoot = path.resolve(__dirname, '..'); const useDetachedChildren = process.platform === 'darwin'; const webRoot = path.join(repoRoot, 'packages/web'); +const bunExecutable = resolveBunExecutable(); + function run(label, command, args, env = {}, options = {}) { return spawn(command, args, { cwd: options.cwd || repoRoot, stdio: 'inherit', env: { ...process.env, ...env }, detached: useDetachedChildren, + windowsHide: true, }).on('error', (error) => { console.error(`[dev:web:hmr] Failed to start ${label}:`, error); }); @@ -112,15 +117,25 @@ function clearViteCache() { clearViteCache(); -const api = run('api', 'bun', ['run', '--cwd', 'packages/web', 'dev:server:watch'], { - OPENCHAMBER_PORT: backendPort, - // Dev backends share the relay identity with the production instance; never - // let them capture the machine's relay host on their own. - OPENCHAMBER_RELAY_HOST: process.env.OPENCHAMBER_RELAY_HOST || 'off', -}); +const api = run( + 'api', + bunExecutable, + [ + 'run', + '--cwd', + 'packages/web', + 'dev:server:watch', + ], + { + OPENCHAMBER_PORT: backendPort, + // Dev backends share the relay identity with the production instance; never + // let them capture the machine's relay host on their own. + OPENCHAMBER_RELAY_HOST: process.env.OPENCHAMBER_RELAY_HOST || 'off', + }, +); const vite = run( 'vite', - 'bun', + bunExecutable, ['x', 'vite', '--force', '--host', hmrHost, '--port', uiPort, '--strictPort'], { OPENCHAMBER_PORT: backendPort, diff --git a/scripts/generate-icon-sprite.mjs b/scripts/generate-icon-sprite.mjs index 1732625a..df00722e 100644 --- a/scripts/generate-icon-sprite.mjs +++ b/scripts/generate-icon-sprite.mjs @@ -22,16 +22,6 @@ const customIconData = new Map([ "openchamber", ``, ], - // Claude spark — official Anthropic mark (Simple Icons path), monochrome. - [ - "claude-code", - ``, - ], - // Cursor two-cursor mark — official (Simple Icons path), monochrome. - [ - "cursor", - ``, - ], // Command Code — corner squares + center square (official logo geometry). [ "command-code", diff --git a/scripts/lib/bun-executable.mjs b/scripts/lib/bun-executable.mjs new file mode 100644 index 00000000..bab3448f --- /dev/null +++ b/scripts/lib/bun-executable.mjs @@ -0,0 +1,59 @@ +import { spawnSync } from 'node:child_process'; +import { existsSync } from 'node:fs'; +import path from 'node:path'; + +const findWindowsExecutable = (name, env) => { + const result = spawnSync('where.exe', [name], { + encoding: 'utf8', + env, + windowsHide: true, + }); + if (result.error || result.status !== 0) { + return null; + } + return String(result.stdout || '') + .split(/\r?\n/) + .map((entry) => entry.trim()) + .find(Boolean) ?? null; +}; + +export function resolveBunExecutable(options = {}) { + const platform = options.platform ?? process.platform; + const env = options.env ?? process.env; + const fileExists = options.fileExists ?? existsSync; + const findOnPath = options.findOnPath ?? findWindowsExecutable; + const pathApi = platform === 'win32' ? path.win32 : path; + + const npmExecutable = env.npm_execpath?.trim(); + const npmExecutableName = platform === 'win32' ? 'bun.exe' : 'bun'; + if ( + npmExecutable && + pathApi.basename(npmExecutable).toLowerCase() === npmExecutableName && + fileExists(npmExecutable) + ) { + return npmExecutable; + } + + if (platform === 'win32') { + const directExecutable = findOnPath('bun.exe', env); + if (directExecutable) { + return directExecutable; + } + + const shim = findOnPath('bun.cmd', env); + if (shim) { + const executable = pathApi.resolve( + pathApi.dirname(shim), + 'node_modules', + 'bun', + 'bin', + 'bun.exe', + ); + if (fileExists(executable)) { + return executable; + } + } + } + + return 'bun'; +} diff --git a/scripts/lib/bun-executable.test.mjs b/scripts/lib/bun-executable.test.mjs new file mode 100644 index 00000000..8a0a5319 --- /dev/null +++ b/scripts/lib/bun-executable.test.mjs @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict'; +import path from 'node:path'; +import test from 'node:test'; + +import { resolveBunExecutable } from './bun-executable.mjs'; + +test('uses the Bun executable that launched the package script', () => { + const executable = '/opt/bun/bin/bun'; + assert.equal(resolveBunExecutable({ + platform: 'linux', + env: { npm_execpath: executable }, + fileExists: (candidate) => candidate === executable, + }), executable); +}); + +test('prefers bun.exe from PATH on Windows', () => { + const executable = 'C:\\Tools\\Bun\\bun.exe'; + assert.equal(resolveBunExecutable({ + platform: 'win32', + env: {}, + fileExists: () => false, + findOnPath: (name) => name === 'bun.exe' ? executable : null, + }), executable); +}); + +test('does not return an extensionless Windows npm shim', () => { + const shim = 'C:\\Users\\dev\\AppData\\Roaming\\npm\\bun'; + const executable = 'C:\\Tools\\Bun\\bun.exe'; + + assert.equal(resolveBunExecutable({ + platform: 'win32', + env: { npm_execpath: shim }, + fileExists: (candidate) => candidate === shim, + findOnPath: (name) => name === 'bun.exe' ? executable : null, + }), executable); +}); + +test('derives bun.exe when Windows PATH exposes only the npm bun.cmd shim', () => { + const shim = 'C:\\Users\\dev\\AppData\\Roaming\\npm\\bun.cmd'; + const executable = path.win32.resolve( + path.win32.dirname(shim), + 'node_modules', + 'bun', + 'bin', + 'bun.exe', + ); + const env = { Path: 'C:\\Users\\dev\\AppData\\Roaming\\npm' }; + const lookups = []; + + assert.equal(resolveBunExecutable({ + platform: 'win32', + env, + fileExists: (candidate) => candidate === executable, + findOnPath: (name, lookupEnv) => { + assert.equal(lookupEnv, env); + lookups.push(name); + return name === 'bun.cmd' ? shim : null; + }, + }), executable); + assert.deepEqual(lookups, ['bun.exe', 'bun.cmd']); +}); + +test('falls back to bun when no directly spawnable executable is available', () => { + assert.equal(resolveBunExecutable({ + platform: 'win32', + env: {}, + fileExists: () => false, + findOnPath: () => null, + }), 'bun'); +}); diff --git a/scripts/oc-dev.mjs b/scripts/oc-dev.mjs index a4346d4a..7062cb34 100755 --- a/scripts/oc-dev.mjs +++ b/scripts/oc-dev.mjs @@ -27,6 +27,7 @@ import os from 'node:os'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { cancel, intro, isCancel, log, outro, select, text } from '@clack/prompts'; +import { RELEASE_PACKAGE_FILES } from './bump-version.mjs'; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -173,16 +174,30 @@ function step(label, fn) { return result; } +const RELEASE_CHANGELOG_FILES = ['CHANGELOG.md', 'packages/vscode/CHANGELOG.md']; + +// Same check the release workflow runs before it publishes, so a missing +// section fails here in seconds instead of after the tag is pushed. +function assertChangelogSection(version) { + const changelogPath = path.join(repoRoot, 'CHANGELOG.md'); + if (!existsSync(changelogPath)) { + throw new Error('CHANGELOG.md not found; add it before releasing.'); + } + const sections = readFileSync(changelogPath, 'utf8').split(/^## /m); + if (!sections.some((section) => section.startsWith(`[${version}]`))) { + throw new Error(`CHANGELOG.md has no "## [${version}] - YYYY-MM-DD" section. Add it before releasing.`); + } +} + function printReleaseNextSteps(version) { log.success(`Release v${version} prepared locally`); - log.info('Next steps:'); - console.log(` git add -A`); + log.info('Next steps (only the release files are staged, unrelated changes stay out):'); + console.log(` git add ${[...RELEASE_PACKAGE_FILES, ...RELEASE_CHANGELOG_FILES].join(' ')}`); console.log(` git commit -m "release v${version}"`); console.log(` git tag v${version}`); - console.log(` git push origin main --tags`); + console.log(` git push origin main v${version}`); console.log(''); - console.log('This will trigger the GitHub Actions release workflow.'); - console.log(`Make sure CHANGELOG.md contains a section like "## [${version}] - YYYY-MM-DD" before pushing.`); + console.log('Pushing the tag is what starts the GitHub Actions release; pushing main alone does not.'); } function normalizeAction(action = '') { @@ -598,6 +613,7 @@ async function createRelease(options) { } } if (!/^\d+\.\d+\.\d+(-[a-zA-Z0-9.]+)?$/.test(version)) throw new Error('Invalid version format. Use semver, e.g. 1.4.7 or 1.4.7-beta.1'); + step('Checking changelog', () => assertChangelogSection(version)); step('Validating codebase', () => run('bun', ['run', 'release:prepare'])); step(`Bumping version to ${version}`, () => run('node', ['scripts/bump-version.mjs', version])); printReleaseNextSteps(version); diff --git a/scripts/run-isolated-tests.mjs b/scripts/run-isolated-tests.mjs index bfc24df2..b2a63b5b 100644 --- a/scripts/run-isolated-tests.mjs +++ b/scripts/run-isolated-tests.mjs @@ -19,9 +19,12 @@ import { spawn } from 'node:child_process'; import { readdirSync, readFileSync, statSync } from 'node:fs'; import path from 'node:path'; +import { resolveBunExecutable } from './lib/bun-executable.mjs'; + const TEST_FILE = /\.(test|spec)\.(js|cjs|mjs|jsx|ts|tsx)$/; const SKIP_DIRS = new Set(['node_modules', 'dist', 'dist-bundle', 'build', 'out', '.git', 'ios', 'android']); const MAX_PARALLEL = 4; +const bunExecutable = resolveBunExecutable(); const collect = (root, found = []) => { for (const entry of readdirSync(root, { withFileTypes: true })) { @@ -44,7 +47,7 @@ const resolveCommand = (file) => { // implements. Node's ESM loader cannot resolve the extensionless local // specifiers these files use (`./sseProxy`), so it never ran them at all. if (isTypeScript || /from\s+['"]bun:test['"]/.test(source)) { - return { label: 'bun', command: 'bun', args: ['test', file] }; + return { label: 'bun', command: bunExecutable, args: ['test', file] }; } if (/from\s+['"]node:test['"]/.test(source)) { return { label: 'node', command: 'node', args: ['--test', file] }; @@ -53,7 +56,10 @@ const resolveCommand = (file) => { }; const run = ({ command, args }) => new Promise((resolve) => { - const child = spawn(command, args, { stdio: ['ignore', 'pipe', 'pipe'] }); + const child = spawn(command, args, { + stdio: ['ignore', 'pipe', 'pipe'], + windowsHide: true, + }); let output = ''; child.stdout.on('data', (chunk) => { output += chunk; }); child.stderr.on('data', (chunk) => { output += chunk; });