Merge pull request 'chore: bring upstream v1.20.0 into custom' (#2) from release/v1.20.0 into custom

This commit is contained in:
2026-08-28 22:05:23 -04:00
281 changed files with 10504 additions and 2180 deletions
+3 -3
View File
@@ -24,13 +24,13 @@ jobs:
- uses: actions/stale@eb5cf3af3ac0a1aa4c9c45633dd1ae542a27a899 # v10.3.0
with:
repo-token: ${{ steps.app-token.outputs.token }}
days-before-stale: 60
days-before-stale: 28
days-before-close: 7
stale-issue-label: stale
stale-pr-label: stale
stale-issue-message: >
This issue has been automatically marked as stale because it has not had
any activity in the last 60 days. It will be closed in 7 days if no
any activity in the last 28 days. It will be closed in 7 days if no
further activity occurs.
close-issue-message: >
This issue has been automatically closed because it has been stale for
@@ -38,7 +38,7 @@ jobs:
reopen the issue.
stale-pr-message: >
This pull request has been automatically marked as stale because it has
not had any activity in the last 60 days. It will be closed in 7 days
not had any activity in the last 28 days. It will be closed in 7 days
if no further activity occurs.
close-pr-message: >
This pull request has been automatically closed because it has been
+34
View File
@@ -4,7 +4,41 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
## [1.20.0] - 2026-08-23
- **Session: /btw side questions.** Type `/btw` followed by your question to ask something off-topic in a temporary session forked from the current conversation, so it inherits the full context but leaves the chat itself untouched. The answer streams into a panel above the composer, which talks to that session while the panel is open; you can collapse it to a slim header bar, keep it as a full session, or discard it. The temporary session stays out of the sidebar and session lists until you keep it (thanks to @jaygupta17).
- **Chat sessions:** start chats without choosing a project. They live in their own Chats section, rather than inheriting a project's repository and worktree context.
- **Desktop/Remote instances:** adding an SSH connection now starts from the hosts in your SSH config instead of a blank command field. Ports, install method and passwords moved behind Advanced settings, and each connection shows Connected, Connecting, or Needs attention with the failure text and a button that resolves it.
- Desktop/Remote instances: connecting to a remote machine now works when bun, OpenChamber or the opencode CLI live in your home directory rather than on the system path. Installing no longer fails with a permission error, and a missing opencode CLI is now reported before the connection starts instead of as a stack trace.
- Desktop/Remote instances: a managed remote server can now also be published to the remote machine's own network, so other devices there reach it without the SSH tunnel. It requires a UI password, and stays private to the tunnel otherwise.
- Desktop/Remote instances: disconnecting from a connection set to not keep the server running now actually stops that remote server.
- Skills catalog: browse curated GitHub skill collections in a card-based catalog with cross-source search, skill counts, stars, recent updates, and links back to each skill's repository.
- Diff: the context-panel diff can now show every change on the current branch against its base branch. OpenChamber detects the base when Git knows it, or lets you choose one once when it does not.
- Dictation: speech is now transcribed after you stop recording. The composer shows a live waveform and timer, and long recordings split at pauses instead of cutting words.
- Settings: the project selector on Providers, Agents, MCP, Commands and Skills now only changes what those pages show. It used to switch the whole app, so opening another project's configuration moved your chat, session list and file tree with it.
- Settings/Projects: a project can now pin a thinking level next to its model, for models that offer levels. Both sit in one Defaults for new chats group, laid out like the Sessions defaults.
- Settings/General: changing the default model, variant or agent no longer repoints an open chat that already carries a model you picked for it. Chats following the default still switch immediately.
- Settings/Providers: the provider you select no longer jumps to a different one on its own. Changing the chat's model or agent, and background provider refreshes, used to move the settings selection with them.
- Settings/Integrations: the experimental page now only lists integrations that can be installed; unavailable and Coming soon entries were removed.
- Chat: file paths in messages now open from the session's project, even if you last browsed files in another project (thanks to @tomzx).
- Chat: app links such as `spotify://` now ask for confirmation before opening another app. You can trust an app link type on one device and manage trusted links in Settings.
- Files/Desktop: files opened from outside the workspace remain readable after their temporary access expires instead of failing until you reopen them (thanks to @pascalandr).
- Diff: creating an inline comment now opens the chat and focuses the composer for your follow-up.
- Chat: in the expanded composer, Enter now starts a new line and Cmd/Ctrl+Enter sends, so a long prompt is harder to send by accident.
- Providers: expanded support for custom providers.
- Small Model: summaries, goal audits, commit messages, and walkthroughs now support more providers.
- Git: generated commit messages now match the repository's recent commit style and language.
- Git: generating a pull request description now picks up the repository's own PR template when it has one, so the draft comes back in your project's sections and checklists instead of the built-in Summary/Why/Testing layout.
- Sidebar: switch between the full project list and a focused view of one project. Sessions created outside OpenChamber now also appear in the sidebar and Recent list without a page refresh (thanks to @tomzx).
- Chat: if OpenCode restarts while a response is still running, the chat now stops with an interrupted state and a notification to continue instead of hanging silently (thanks to @sum117).
- Chat: while a reply streams, the model status line under the last message now turns into the finished message's info row in place, instead of jumping when the reply completes.
- Chat: newly sent messages and syntax-highlighted code blocks no longer briefly flicker. Bash output can also grow with its content instead of being cut off.
- Chat: long user messages can be expanded even when their final layout finishes after they first appear.
- Chat: in a chat without a project, the work status card again steps aside when the context panel is open, instead of sitting next to it.
- Usage: Z.ai credit limits now appear alongside its other quota windows.
- Git: pull-request checks in Work status stay current as their status changes.
- UI: the default dialog close button is easier to click or tap (thanks to @rockinrimmer).
- Desktop/Windows: the close button now aligns correctly with the rest of the window chrome.
## [1.19.0] - 2026-08-19
+11 -1
View File
@@ -204,7 +204,7 @@ state why it remains valid. If there is genuinely no user-visible change, say
so and provide a concrete reason; deleting the evidence section is not an
exemption.
### Review Enforcement
### Review enforcement
The automated reviewer performs one unified review of correctness, repository
guidance compliance, pull request quality, and evidence. It independently
@@ -238,6 +238,16 @@ verify a trustworthy result, in which case it applies `review:automation-failed`
Each completed review creates a new comment tied to its reviewed HEAD so the
conversation remains chronological. Previous review comments are not rewritten.
### Keeping PRs active
Stale PRs add review load and make it hard to tell what's still being worked on, so the stale bot keeps the open list current. A PR with no activity for 28 days is automatically labeled `stale`, and closed 7 days later if it stays inactive. To keep a PR open:
- Push updates or respond to review feedback
- Leave a comment if you're waiting on a reviewer
- Add the `pinned`, `security`, or `help wanted` label to exempt a long-running PR from the stale bot
Reopening a closed PR is fine if it becomes relevant again.
## Project Structure
```
+5 -5
View File
@@ -30,7 +30,7 @@
"@heroui/theme": "^2.4.23",
"@lezer/highlight": "^1.2.3",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18",
"@opencode-ai/sdk": "1.18.21",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
@@ -168,7 +168,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.18",
"@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0",
@@ -241,7 +241,7 @@
"version": "1.19.0",
"dependencies": {
"@openchamber/ui": "workspace:*",
"@opencode-ai/sdk": "1.18.18",
"@opencode-ai/sdk": "1.18.21",
"adm-zip": "^0.6.0",
"jsonc-parser": "^3.3.1",
"react": "^19.1.1",
@@ -268,7 +268,7 @@
"dependencies": {
"@clack/prompts": "^1.1.0",
"@octokit/rest": "^22.0.1",
"@opencode-ai/sdk": "1.18.18",
"@opencode-ai/sdk": "1.18.21",
"@simplewebauthn/server": "13.3.1",
"bun-pty": "^0.4.5",
"compression": "^1.8.1",
@@ -1003,7 +1003,7 @@
"@openchamber/web": ["@openchamber/web@workspace:packages/web"],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.18", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-zJlwXskIR47V1dkPJqeKBgq7nejG1uU8lJaGIGqbX3MWRCT8vKn0fEotbxuPCKnTdmWsDyNGNg9q1qIliDSMDA=="],
"@opencode-ai/sdk": ["@opencode-ai/sdk@1.18.21", "", { "dependencies": { "cross-spawn": "7.0.6" } }, "sha512-k6iHQ5C8wOPglk+LgFyYnst168cGMQYumgpbVoeXJ+iC1AtvwD5zmjuF8CxMze/y9G1K2bOeO6p9yRvA7eHZLA=="],
"@oxlint/binding-android-arm-eabi": ["@oxlint/binding-android-arm-eabi@1.78.0", "", { "os": "android", "cpu": "arm" }, "sha512-Bu819lmAfZMUHErrpe0cEWj3iaefuUODHSU8+UbXy67V/r7/7f4K3FL0NmbD85E+wiFLDYuhP8Zlv0XnVeXshw=="],
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "openchamber-monorepo",
"version": "1.19.0",
"version": "1.20.0",
"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.18",
"@opencode-ai/sdk": "1.18.21",
"@radix-ui/react-collapsible": "^1.1.12",
"@radix-ui/react-dialog": "^1.1.15",
"@radix-ui/react-dropdown-menu": "^2.1.16",
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: Integrationen
description: Nutze dein Claude-, Command-Code- oder Cursor-Abo als Provider.
description: Nutze dein Claude- oder Cursor-Abo als Provider.
---
# 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**.
> **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.
Verfügbare Integrationen:
- **Claude Code** — dein Claude Pro- oder Max-Plan, ohne API-Keys
- **Command Code** — dein Command-Code-Plan
- **Cursor** — die Modell-Limits deines Cursor-Plans
## Integration installieren
@@ -29,19 +30,10 @@ Claude Code nutzt deinen Claude Pro- oder Max-Plan — ohne API-Keys und ohne se
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.
Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Command Code und Cursor brauchen ihre CLIs nicht.
Claude Code ist die einzige Integration hier, die ihre Provider-CLI installiert und angemeldet benötigt. Cursor braucht seine CLI nicht.
**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.
## Command Code
Command Code nutzt deinen Command-Code-Plan.
1. Installiere die Integration (siehe oben).
2. Wähle **Set up** — es öffnet sich eine Browserseite. Genehmige den Zugriff und kehre zu OpenChamber zurück.
Auf einem Headless-Server oder in CI setze stattdessen die Umgebungsvariable `COMMAND_CODE_API_KEY`, anstatt dich im Browser anzumelden.
## Cursor
Cursor macht die Modelle deines Cursor-Plans in OpenChamber nutzbar.
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: Integraciones
description: Usa tu suscripción de Claude, Command Code o Cursor como proveedor.
description: Usa tu suscripción de Claude o Cursor como proveedor.
---
# 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**.
> **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.
Integraciones disponibles:
- **Claude Code** — tu plan Claude Pro o Max, sin claves de API
- **Command Code** — tu plan de Command Code
- **Cursor** — los límites de modelos de tu plan de Cursor
## Instalar una integración
@@ -29,19 +30,10 @@ Claude Code usa tu plan Claude Pro o Max — sin claves de API y sin una app de
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.
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. Command Code y Cursor no requieren sus CLIs.
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.
**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.
## Command Code
Command Code usa tu plan de Command Code.
1. Instala la integración (arriba).
2. Elige **Set up** — se abre una página en el navegador. Autoriza el acceso y vuelve a OpenChamber.
En una máquina sin interfaz gráfica o en CI, define la variable de entorno `COMMAND_CODE_API_KEY` en lugar de iniciar sesión en el navegador.
## Cursor
Cursor hace disponibles en OpenChamber los modelos incluidos en tu plan de Cursor.
+1 -1
View File
@@ -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, Command Code o Cursor como proveedor
- [Integraciones](/es/integrations/) — usa una suscripción de Claude o Cursor como proveedor
- [Servidores MCP](/es/mcp/) — añade herramientas extra para los agentes
- [Uso y cuotas](/es/usage/) — controla cuánto has consumido
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: Intégrations
description: Utilise ton abonnement Claude, Command Code ou Cursor comme fournisseur.
description: Utilise ton abonnement Claude ou Cursor comme fournisseur.
---
# 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**.
> **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.
Intégrations disponibles :
- **Claude Code** — ton plan Claude Pro ou Max, sans clés API
- **Command Code** — ton plan Command Code
- **Cursor** — les limites de modèles de ton plan Cursor
## Installer une intégration
@@ -29,19 +30,10 @@ Claude Code utilise ton plan Claude Pro ou Max — sans clés API et sans applic
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.
Claude Code est la seule intégration ici qui exige que la CLI de son fournisseur soit installée et connectée. Command Code et Cursor n'exigent pas leurs CLIs.
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.
**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.
## Command Code
Command Code utilise ton plan Command Code.
1. Installe l'intégration (ci-dessus).
2. Choisis **Set up** — une page s'ouvre dans le navigateur. Autorise l'accès, puis reviens dans OpenChamber.
Sur une machine sans interface graphique ou en CI, définis la variable d'environnement `COMMAND_CODE_API_KEY` au lieu de te connecter via le navigateur.
## Cursor
Cursor rend disponibles dans OpenChamber les modèles inclus dans ton plan Cursor.
+1 -1
View File
@@ -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, Command Code ou Cursor comme fournisseur
- [Intégrations](/integrations/) — utiliser un abonnement Claude ou Cursor comme fournisseur
- [Serveurs MCP](/mcp/) — ajouter des outils supplémentaires aux agents
- [Utilisation et quotas](/usage/) — suivre votre consommation
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: Integrations
description: Use your Claude, Command Code, or Cursor subscription as a provider.
description: Use your Claude or Cursor subscription as a provider.
---
# 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**.
> **Experimental feature.** We aim to respect provider policies, but account restrictions and suspensions remain each provider's decision. Use integrations at your own risk.
Available integrations:
- **Claude Code** — your Claude Pro or Max plan, no API keys
- **Command Code** — your Command Code plan
- **Cursor** — the model limits of your Cursor plan
## Install an integration
@@ -29,19 +30,10 @@ Claude Code uses your Claude Pro or Max plan — no API keys and no separate Cla
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.
Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Command Code and Cursor do not require their CLIs.
Claude Code is the only integration here that requires its provider CLI to be installed and signed in. Cursor does not require its CLI.
**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.
## Command Code
Command Code uses your Command Code plan.
1. Install the integration (above).
2. Choose **Set up** — a browser page opens. Approve access, then return to OpenChamber.
On a headless machine or in CI, set the `COMMAND_CODE_API_KEY` environment variable instead of signing in in the browser.
## Cursor
Cursor makes the models included in your Cursor plan available in OpenChamber.
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: 統合機能
description: Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う。
description: Claude または Cursor のサブスクリプションをプロバイダーとして使う。
---
# 統合機能
統合機能(インテグレーション)は、すでに持っているサブスクリプションを使って OpenChamber にプロバイダーを追加する小さなプラグインです。**Settings → Integrations** で管理します。
> **実験的な機能。** プロバイダーの方針を尊重するよう努めていますが、アカウントの制限や停止は各プロバイダーの判断に委ねられます。自己責任で連携を使用してください。
利用できる統合機能:
- **Claude Code** — Claude Pro または Max プラン、API キー不要
- **Command Code** — Command Code のプラン
- **Cursor** — Cursor プランのモデル利用枠
## 統合機能をインストールする
@@ -29,19 +30,10 @@ Claude Code は Claude Pro または Max プランを使います — API キー
1. 統合機能をインストールします(上記)。
2. **Set up** を選んでサインインします。Claude Code CLI がまだない場合は、セットアップがまずインストールを提案し、その後サインインします。
Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Command Code と Cursor は CLI を必要としません。
Claude Code は、ここで唯一プロバイダーの CLI のインストールとサインインを必要とする統合機能です。Cursor は CLI を必要としません。
**Claude アカウントが守られる仕組み:** この統合機能は Anthropic の公式 Claude Agent SDK と、インストール済みの Claude Code CLI を使用します。OAuth の乗っ取り、ブラウザートークンの抽出や再生、未対応クライアントへの偽装、Anthropic の認証フローの回避は一切行いません。Anthropic がサポートする正規のアクセス経路を使うため、トークン乗っ取りや不正な認証の回避につきもののアカウント停止リスクはありません。
## Command Code
Command Code は Command Code のプランを使います。
1. 統合機能をインストールします(上記)。
2. **Set up** を選ぶとブラウザーでページが開きます。アクセスを許可して OpenChamber に戻ります。
画面のないサーバーや CI では、ブラウザーでサインインする代わりに環境変数 `COMMAND_CODE_API_KEY` を設定してください。
## Cursor
Cursor は Cursor プランに含まれるモデルを OpenChamber で使えるようにします。
+1 -1
View File
@@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ
## 関連
- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う
- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う
- [MCP サーバー](/mcp/) — エージェントに追加ツールを加える
- [使用量とクォータ](/usage/) — 使った量を追跡する
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: 통합 기능
description: Claude, Command Code 또는 Cursor 구독을 공급자로 사용하세요.
description: Claude 또는 Cursor 구독을 공급자로 사용하세요.
---
# 통합 기능
통합 기능(인테그레이션)은 이미 가지고 있는 구독을 사용해 OpenChamber에 공급자를 추가하는 작은 플러그인입니다. **Settings → Integrations**에서 관리합니다.
> **실험 단계 기능.** 프로바이더 정책을 존중하려 노력하지만, 계정 제한과 정지는 각 프로바이더의 결정입니다. 본인의 책임 아래 통합 기능을 사용하세요.
사용 가능한 통합 기능:
- **Claude Code** — Claude Pro 또는 Max 플랜, API 키 불필요
- **Command Code** — Command Code 플랜
- **Cursor** — Cursor 플랜의 모델 한도
## 통합 기능 설치
@@ -29,19 +30,10 @@ Claude Code는 Claude Pro 또는 Max 플랜을 사용합니다 — API 키도
1. 통합 기능을 설치합니다(위 참고).
2. **Set up**를 선택하고 로그인합니다. Claude Code CLI가 아직 없으면 설정에서 먼저 설치를 제안한 뒤 로그인을 진행합니다.
Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Command Code와 Cursor는 CLI가 필요 없습니다.
Claude Code는 여기에서 유일하게 공급자 CLI 설치와 로그인을 필요로 하는 통합 기능입니다. Cursor는 CLI가 필요 없습니다.
**Claude 계정이 안전하게 유지되는 방식:** 이 통합 기능은 Anthropic의 공식 Claude Agent SDK와 설치된 Claude Code CLI를 사용합니다. OAuth 탈취, 브라우저 토큰 추출·재사용, 지원되지 않는 클라이언트로의 위장, Anthropic 인증 우회를 하지 않습니다. Anthropic이 지원하는 정상 경로를 사용하므로 토큰 탈취나 비인가 인증 우회에 따른 계정 정지 위험이 없습니다.
## Command Code
Command Code는 Command Code 플랜을 사용합니다.
1. 통합 기능을 설치합니다(위 참고).
2. **Set up**를 선택하면 브라우저에서 페이지가 열립니다. 접근을 승인한 뒤 OpenChamber로 돌아옵니다.
화면이 없는 서버나 CI 환경에서는 브라우저 로그인 대신 `COMMAND_CODE_API_KEY` 환경 변수를 설정하세요.
## Cursor
Cursor는 Cursor 플랜에 포함된 모델을 OpenChamber에서 사용할 수 있게 합니다.
+1 -1
View File
@@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가
## 관련 항목
- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용
- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용
- [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다
- [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: Integracje
description: Używaj subskrypcji Claude, Command Code lub Cursor jako dostawcy.
description: Używaj subskrypcji Claude lub Cursor jako dostawcy.
---
# 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**.
> **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.
Dostępne integracje:
- **Claude Code** — Twój plan Claude Pro lub Max, bez kluczy API
- **Command Code** — Twój plan Command Code
- **Cursor** — limity modeli z Twojego planu Cursor
## Instalacja integracji
@@ -29,19 +30,10 @@ Claude Code korzysta z Twojego planu Claude Pro lub Max — bez kluczy API i bez
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.
Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Command Code i Cursor nie wymaga swoich CLI.
Claude Code jest jedyną integracją tutaj, która wymaga zainstalowanego i zalogowanego CLI swojego dostawcy. Cursor nie wymaga swojego CLI.
**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.
## Command Code
Command Code korzysta z Twojego planu Command Code.
1. Zainstaluj integrację (patrz wyżej).
2. Wybierz **Set up** — w przeglądarce otworzy się strona. Zatwierdź dostęp i wróć do OpenChamber.
Na maszynie bez interfejsu graficznego lub w CI ustaw zmienną środowiskową `COMMAND_CODE_API_KEY` zamiast logowania w przeglądarce.
## Cursor
Cursor udostępnia w OpenChamber modele zawarte w Twoim planie Cursor.
+1 -1
View File
@@ -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, Command Code lub Cursor jako dostawcy
- [Integracje](/pl/integrations/) — używaj subskrypcji Claude lub Cursor jako dostawcy
- [Serwery MCP](/pl/mcp/) — dodaj agentom dodatkowe narzędzia
- [Zużycie i limity](/pl/usage/) — śledź, ile już wykorzystałeś
+1 -1
View File
@@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit
## Related
- [Integrations](/integrations/) — use a Claude, Command Code, or Cursor subscription as a provider
- [Integrations](/integrations/) — use a Claude or Cursor subscription as a provider
- [MCP Servers](/mcp/) — add extra tools for agents
- [Usage & Quotas](/usage/) — track how much you've used
@@ -1,16 +1,17 @@
---
title: Integrações
description: Use sua assinatura Claude, Command Code ou Cursor como provedor.
description: Use sua assinatura Claude ou Cursor como provedor.
---
# 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**.
> **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.
Integrações disponíveis:
- **Claude Code** — seu plano Claude Pro ou Max, sem chaves de API
- **Command Code** — seu plano Command Code
- **Cursor** — os limites de modelos do seu plano Cursor
## Instalar uma integração
@@ -29,19 +30,10 @@ O Claude Code usa seu plano Claude Pro ou Max — sem chaves de API e sem um app
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.
O Claude Code é a única integração aqui que exige que a CLI do provedor esteja instalada e autenticada. Command Code e Cursor não exigem suas CLIs.
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.
**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.
## Command Code
O Command Code usa seu plano Command Code.
1. Instale a integração (acima).
2. Escolha **Set up** — uma página abre no navegador. Autorize o acesso e volte ao OpenChamber.
Em uma máquina sem interface gráfica ou em CI, defina a variável de ambiente `COMMAND_CODE_API_KEY` em vez de fazer login pelo navegador.
## Cursor
O Cursor torna disponíveis no OpenChamber os modelos incluídos no seu plano Cursor.
@@ -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, Command Code ou Cursor como provedor
- [Integrações](/pt-br/integrations/) — use uma assinatura Claude ou Cursor como provedor
- [Servidores MCP](/pt-br/mcp/) — adicione ferramentas extras para os agentes
- [Uso e Cotas](/pt-br/usage/) — acompanhe quanto você já usou
+4 -12
View File
@@ -1,16 +1,17 @@
---
title: Інтеграції
description: Використовуйте підписки Claude, Command Code або Cursor як провайдерів.
description: Використовуйте підписки Claude або Cursor як провайдерів.
---
# Інтеграції
Інтеграція — це невеликий плагін, що додає провайдера до OpenChamber на основі підписки, яка в вас уже є. Керувати ними можна в **Settings → Integrations**.
> **Експериментальна функція.** Ми прагнемо дотримуватися політик провайдерів, але обмеження та блокування облікових записів залишаються рішенням кожного провайдера. Використовуйте інтеграції на власний ризик.
Доступні інтеграції:
- **Claude Code** — ваша підписка Claude Pro або Max, без API-ключів
- **Command Code** — ваша підписка Command Code
- **Cursor** — ліміти моделей вашої підписки Cursor
## Встановлення інтеграції
@@ -29,19 +30,10 @@ Claude Code використовує вашу підписку Claude Pro або
1. Встановіть інтеграцію (вище).
2. Натисніть **Set up** і увійдіть. Якщо у вас ще немає Claude Code CLI, програма встановлення спершу запропонує його встановити, а потім виконає вхід.
Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Command Code і Cursor їхні CLI не потрібні.
Claude Code — єдина інтеграція тут, яка вимагає встановленого та залогіненого CLI свого провайдера. Для Cursor CLI не потрібен.
**Як захищається ваш обліковий запис Claude:** ця інтеграція використовує офіційний Claude Agent SDK від Anthropic і ваш встановлений Claude Code CLI. Вона не перехоплює OAuth, не витягує й не відтворює браузерні токени, не видає себе за непідтримуваний клієнт і не обходить процес автентифікації Anthropic. Усе працює через підтримуваний Anthropic шлях доступу, тож інтеграція не несе ризику блокування облікового запису, пов'язаного з перехопленням токенів або несанкціонованими способами автентифікації.
## Command Code
Command Code використовує вашу підписку Command Code.
1. Встановіть інтеграцію (вище).
2. Натисніть **Set up** — відкриється сторінка в браузері. Підтвердьте доступ і поверніться до OpenChamber.
На сервері без графічного інтерфейсу або в CI замість входу через браузер задайте змінну середовища `COMMAND_CODE_API_KEY`.
## Cursor
Cursor робить доступними в OpenChamber моделі, що входять у вашу підписку Cursor.
+1 -1
View File
@@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте
## Пов'язане
- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів
- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів
- [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти
- [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили
@@ -1,16 +1,17 @@
---
title: 集成
description: 将你的 Claude、Command Code 或 Cursor 订阅用作提供商。
description: 将你的 Claude 或 Cursor 订阅用作提供商。
---
# 集成
集成是一个小型插件,它使用你已有的订阅为 OpenChamber 添加一个提供商。你可以在 **Settings → Integrations** 中管理它们。
> **实验性功能。**我们力求遵守提供商的政策,但帐户限制和暂停仍由各提供商决定。请自行承担使用集成的风险。
可用的集成:
- **Claude Code** — 你的 Claude Pro 或 Max 套餐,无需 API 密钥
- **Command Code** — 你的 Command Code 套餐
- **Cursor** — 你的 Cursor 套餐的模型额度
## 安装集成
@@ -29,19 +30,10 @@ Claude Code 使用你的 Claude Pro 或 Max 套餐 — 无需 API 密钥,也
1. 安装集成(见上文)。
2. 选择 **Set up** 并登录。如果你还没有 Claude Code CLI,安装向导会先提供安装,然后再登录。
Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Command Code 和 Cursor 不需要它们的 CLI。
Claude Code 是这里唯一要求安装并登录其提供商 CLI 的集成。Cursor 不需要 CLI。
**你的 Claude 账户如何受到保护:** 此集成使用 Anthropic 官方的 Claude Agent SDK 和你已安装的 Claude Code CLI。它不会劫持 OAuth,不会提取或重放浏览器令牌,不会冒充不受支持的客户端,也不会绕过 Anthropic 的身份验证。它始终运行在 Anthropic 支持的访问路径上,因此不会带来与令牌劫持或未授权身份验证变通手段相关的封号风险。
## Command Code
Command Code 使用你的 Command Code 套餐。
1. 安装集成(见上文)。
2. 选择 **Set up** — 浏览器中会打开一个页面。授权访问,然后返回 OpenChamber。
在没有图形界面的服务器或 CI 环境中,请设置环境变量 `COMMAND_CODE_API_KEY` 来代替浏览器登录。
## Cursor
Cursor 让你的 Cursor 套餐中包含的模型可以在 OpenChamber 中使用。
@@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。
## 相关内容
- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商
- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商
- [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具
- [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量
+1
View File
@@ -143,6 +143,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
## Native Features Owned Here
- Floating Mini Chat windows.
- New Mini Chat windows default to the managed Chats target. Explicit project/worktree drafts retain their target, existing managed chat sessions reopen in their own directory, and the compact header omits project/branch metadata for Chats. Opening a managed draft back in the main window preserves that target.
- Multiple native windows.
- Native notifications.
- User-confirmed local folder selection. The shared UI supplies the requested directory as the picker `defaultPath`; confirmation is required before filesystem access is retried.
+10 -1
View File
@@ -11,6 +11,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
import { promisify } from 'node:util';
import updaterPkg from 'electron-updater';
import { ElectronSshManager } from './ssh-manager.mjs';
import { replaceFileWithRetry } from './windows-file-replace.mjs';
import { createTrayController } from './tray.mjs';
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
@@ -560,10 +561,15 @@ const writeJsonFile = async (filePath, data) => {
// Atomic: write to a temp file then rename. Readers never see a partial
// JSON file that could parse-error and get coerced to {}.
const tmp = `${filePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 });
if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600);
await fsp.rename(tmp, filePath);
await replaceFileWithRetry(tmp, filePath);
if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600);
} catch (error) {
await fsp.rm(tmp, { force: true }).catch(() => {});
throw error;
}
};
const readSettingsRoot = () => {
@@ -2465,6 +2471,9 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
browserWindow.__ocRuntimeConfig = { apiBaseUrl: desktopApiBaseUrl, clientToken: desktopClientToken, requestHeaders: desktopRequestHeaders };
browserWindow.__ocInitScript = buildInitScript(desktopLocalOrigin, state.bootOutcome, desktopApiBaseUrl, desktopClientToken, desktopRequestHeaders);
browserWindow.__ocTitleBarOverlayEnabled = titleBarOverlayEnabled;
browserWindow.on('app-command', (event, command) => {
if (command === 'browser-backward') event.preventDefault();
});
if (useSaved && saved.maximized) {
browserWindow.maximize();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/electron",
"version": "1.19.0",
"version": "1.20.0",
"private": true,
"description": "Electron desktop runtime for OpenChamber",
"author": "OpenChamber",
+135 -46
View File
@@ -5,9 +5,30 @@ import os from 'node:os';
import path from 'node:path';
import { spawn } from 'node:child_process';
import { replaceFileWithRetry } from './windows-file-replace.mjs';
const LOCAL_HOST_ID = 'local';
const DEFAULT_CONNECTION_TIMEOUT_SEC = 60;
const DEFAULT_LOCAL_BIND_HOST = '127.0.0.1';
// Global npm prefixes are root-owned on most distributions, so `npm install -g`
// fails with EACCES for a normal SSH user. Everything we install goes to a
// prefix inside the user's home instead.
const REMOTE_USER_PREFIX = '$HOME/.openchamber/npm-global';
const REMOTE_BUN_CANDIDATE = '"${BUN_INSTALL:-$HOME/.bun}/bin/bun"';
// The opencode CLI usually installs into the user's home, which an SSH login
// shell does not have on PATH. The remote server only looks at OPENCODE_BINARY
// and PATH, so resolve the CLI here and hand it over explicitly.
const REMOTE_OPENCODE_CANDIDATES = [
'"$HOME/.opencode/bin/opencode"',
'"${BUN_INSTALL:-$HOME/.bun}/bin/opencode"',
'"$HOME/.local/bin/opencode"',
'"$HOME/.openchamber/npm-global/bin/opencode"',
];
const REMOTE_PATH_PREFIX = '$HOME/.opencode/bin:${BUN_INSTALL:-$HOME/.bun}/bin:$HOME/.local/bin:$HOME/.openchamber/npm-global/bin';
const REMOTE_BIN_CANDIDATES = [
'"$HOME/.openchamber/npm-global/bin/openchamber"',
'"${BUN_INSTALL:-$HOME/.bun}/bin/openchamber"',
];
const DEFAULT_CONTROL_PERSIST_SEC = 300;
const DEFAULT_READY_TIMEOUT_SEC = 30;
const DEFAULT_RECONNECT_MAX_ATTEMPTS = 5;
@@ -77,10 +98,15 @@ const writeJsonRoot = async (settingsFilePath, root) => {
await fsp.mkdir(path.dirname(settingsFilePath), { recursive: true });
// Atomic write: concurrent readers (main.mjs, web server) would otherwise
// see partial JSON and readJsonRoot()'s catch would silently coerce to {},
// causing the next read-modify-write to wipe the entire settings file.
// causing the next read-modify-write wipe the entire settings file.
const tmp = `${settingsFilePath}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
try {
await fsp.writeFile(tmp, JSON.stringify(root, null, 2));
await fsp.rename(tmp, settingsFilePath);
await replaceFileWithRetry(tmp, settingsFilePath);
} catch (error) {
await fsp.rm(tmp, { force: true }).catch(() => {});
throw error;
}
};
const defaultTrue = () => true;
@@ -578,6 +604,7 @@ export class ElectronSshManager {
localUrl: null,
localPort: null,
remotePort: null,
remoteBinPath: null,
startedByUs: false,
retryAttempt: 0,
requiresUserAction: false,
@@ -804,9 +831,10 @@ export class ElectronSshManager {
mode: instance?.remoteOpenchamber?.mode === 'external' ? 'external' : 'managed',
keepRunning: instance?.remoteOpenchamber?.keepRunning !== false,
...(Number.isFinite(instance?.remoteOpenchamber?.preferredPort) ? { preferredPort: Number(instance.remoteOpenchamber.preferredPort) } : {}),
installMethod: ['npm', 'bun', 'download_release', 'upload_bundle'].includes(instance?.remoteOpenchamber?.installMethod)
installMethod: ['auto', 'npm', 'bun'].includes(instance?.remoteOpenchamber?.installMethod)
? instance.remoteOpenchamber.installMethod
: 'bun',
: 'auto',
bindHost: instance?.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1',
uploadBundleOverSsh: Boolean(instance?.remoteOpenchamber?.uploadBundleOverSsh),
},
localForward: {
@@ -981,38 +1009,77 @@ export class ElectronSshManager {
return secret?.enabled && typeof secret.value === 'string' && secret.value.trim() ? secret.value.trim() : null;
}
async remoteCommandExists(parsed, controlPath, commandName) {
try {
const output = await this.runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
return output.trim() === 'yes';
} catch {
return false;
}
}
// A login shell over SSH does not source the user's interactive rc files, so
// tools installed into a home directory (bun above all) are missing from PATH
// even when they exist. Look at their known install locations too.
async resolveRemoteTool(parsed, controlPath, commandName, extraCandidates = []) {
const candidateList = [...extraCandidates, `"$(command -v ${commandName} 2>/dev/null)"`].join(' ');
const script = [
`for candidate in ${candidateList}; do`,
' [ -n "$candidate" ] || continue;',
' [ -x "$candidate" ] || continue;',
` printf '%s' "$candidate";`,
' exit 0;',
'done',
].join(' ');
async currentRemoteOpenChamberVersion(parsed, controlPath) {
try {
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
return parseVersionToken(output);
const output = await this.runRemoteCommand(parsed, controlPath, script);
return output.trim() || null;
} catch {
return null;
}
}
async installOpenChamberManaged(parsed, controlPath, version, preferred) {
const hasBun = await this.remoteCommandExists(parsed, controlPath, 'bun');
const hasNpm = await this.remoteCommandExists(parsed, controlPath, 'npm');
const commands = [];
// Every place OpenChamber may live on the remote host, with the version each
// one reports. Installs land in the user prefix while an older copy can still
// sit on PATH, so the caller picks by version instead of trusting PATH order.
async remoteOpenChamberCandidates(parsed, controlPath) {
const script = [
`for candidate in ${REMOTE_BIN_CANDIDATES.join(' ')} "$(command -v openchamber 2>/dev/null)"; do`,
' [ -n "$candidate" ] || continue;',
' [ -x "$candidate" ] || continue;',
` printf '%s\t%s\n' "$candidate" "$("$candidate" --version 2>/dev/null | head -n 1)";`,
'done',
].join(' ');
if (preferred === 'bun') {
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
} else if (preferred === 'npm') {
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
let output = '';
try {
output = await this.runRemoteCommand(parsed, controlPath, script);
} catch {
return [];
}
const candidates = [];
const seen = new Set();
for (const line of output.split(/\r?\n/)) {
const [binPath, versionRaw] = line.split('\t');
const trimmed = (binPath || '').trim();
if (!trimmed || seen.has(trimmed)) continue;
seen.add(trimmed);
candidates.push({ binPath: trimmed, version: parseVersionToken(versionRaw || '') });
}
return candidates;
}
async installOpenChamberManaged(parsed, controlPath, version, preferred) {
const bunPath = await this.resolveRemoteTool(parsed, controlPath, 'bun', [REMOTE_BUN_CANDIDATE]);
const npmPath = await this.resolveRemoteTool(parsed, controlPath, 'npm');
// bun's global install already targets ~/.bun; npm is pinned to a prefix in
// the user's home so it never touches the root-owned global directory.
const bunCommand = bunPath ? `${shellQuote(bunPath)} add -g @openchamber/web@${version}` : null;
const npmCommand = npmPath
? `mkdir -p "${REMOTE_USER_PREFIX}" && ${shellQuote(npmPath)} install -g --prefix "${REMOTE_USER_PREFIX}" @openchamber/web@${version}`
: null;
const commands = [];
if (preferred === 'npm') {
if (npmCommand) commands.push(npmCommand);
if (bunCommand) commands.push(bunCommand);
} else {
if (hasBun) commands.push(`bun add -g @openchamber/web@${version}`);
if (hasNpm) commands.push(`npm install -g @openchamber/web@${version}`);
if (bunCommand) commands.push(bunCommand);
if (npmCommand) commands.push(npmCommand);
}
if (commands.length === 0) {
@@ -1072,24 +1139,35 @@ export class ElectronSshManager {
}
}
async startRemoteServerManaged(parsed, controlPath, instance, desiredPort) {
let envPrefix = 'OPENCHAMBER_RUNTIME=ssh-remote';
async startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binPath) {
const opencodePath = await this.resolveRemoteTool(parsed, controlPath, 'opencode', REMOTE_OPENCODE_CANDIDATES);
if (!opencodePath) {
throw new Error('The opencode CLI is not installed on the remote machine. Install it there, then connect again');
}
const secret = this.configuredOpenChamberPassword(instance);
const remoteBindHost = instance.remoteOpenchamber?.bindHost === '0.0.0.0' ? '0.0.0.0' : '127.0.0.1';
// Binding the remote server to every interface publishes its UI to the
// remote machine's whole network, so it may not run without a password.
if (remoteBindHost === '0.0.0.0' && !secret) {
throw new Error('Exposing the remote server to its network requires a UI password');
}
let envPrefix = `PATH="${REMOTE_PATH_PREFIX}:$PATH" OPENCODE_BINARY=${shellQuote(opencodePath)} OPENCHAMBER_RUNTIME=ssh-remote`;
if (secret) {
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
}
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} ${shellQuote(binPath)} serve --hostname ${remoteBindHost} --port ${desiredPort}`);
const port = output.split(/\s+/).map((token) => Number.parseInt(token, 10)).find((value) => Number.isFinite(value));
return port || desiredPort;
}
async stopRemoteServerBestEffort(parsed, controlPath, remotePort) {
// `openchamber stop` owns the daemon lifecycle. The HTTP shutdown route sits
// behind UI authentication, so it cannot stop a password-protected server.
async stopRemoteServerBestEffort(parsed, controlPath, remotePort, remoteBinPath) {
if (!remoteBinPath) return;
try {
await this.runRemoteCommand(
parsed,
controlPath,
`if command -v curl >/dev/null 2>&1; then curl -fsS -X POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; elif command -v wget >/dev/null 2>&1; then wget -qO- --method=POST http://127.0.0.1:${remotePort}/api/system/shutdown >/dev/null 2>&1 || true; fi`,
);
await this.runRemoteCommand(parsed, controlPath, `${shellQuote(remoteBinPath)} stop --port ${remotePort}`);
} catch {
}
}
@@ -1143,17 +1221,27 @@ export class ElectronSshManager {
const port = instance.remoteOpenchamber.preferredPort;
this.setStatus(instance.id, 'server_detecting', 'Probing external OpenChamber server', null, null, port, false, 0, false);
await this.probeRemoteSystemInfo(parsed, controlPath, port, this.configuredOpenChamberPassword(instance));
return { remotePort: port, startedByUs: false };
return { remotePort: port, startedByUs: false, remoteBinPath: null };
}
this.setStatus(instance.id, 'remote_probe', 'Checking remote OpenChamber installation');
const installedVersion = await this.currentRemoteOpenChamberVersion(parsed, controlPath);
if (!installedVersion) {
const installed = await this.remoteOpenChamberCandidates(parsed, controlPath);
let binary = installed.find((candidate) => candidate.version === this.appVersion) || null;
if (!binary) {
const existing = installed[0] || null;
if (existing) {
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${existing.version || 'unknown'} to ${this.appVersion}`);
} else {
this.setStatus(instance.id, 'installing', 'Installing OpenChamber on remote host');
}
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
} else if (installedVersion !== this.appVersion) {
this.setStatus(instance.id, 'updating', `Updating remote OpenChamber from ${installedVersion} to ${this.appVersion}`);
await this.installOpenChamberManaged(parsed, controlPath, this.appVersion, instance.remoteOpenchamber.installMethod);
const afterInstall = await this.remoteOpenChamberCandidates(parsed, controlPath);
binary = afterInstall.find((candidate) => candidate.version === this.appVersion) || afterInstall[0] || existing;
if (!binary) {
throw new Error('OpenChamber was installed on the remote host but no openchamber binary could be found');
}
}
this.setStatus(instance.id, 'server_detecting', 'Detecting managed OpenChamber server');
@@ -1165,13 +1253,13 @@ export class ElectronSshManager {
if (!remotePort) {
this.setStatus(instance.id, 'server_starting', 'Starting managed OpenChamber server');
const desiredPort = instance.remoteOpenchamber.preferredPort || randomPortCandidate(instance.id);
remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort);
remotePort = await this.startRemoteServerManaged(parsed, controlPath, instance, desiredPort, binary.binPath);
startedByUs = true;
}
if (!(await this.remoteServerRunning(parsed, controlPath, remotePort, this.configuredOpenChamberPassword(instance)))) {
throw new Error('Managed OpenChamber server failed to become reachable');
}
return { remotePort, startedByUs };
return { remotePort, startedByUs, remoteBinPath: binary.binPath };
}
async disconnectInternal(id, reportIdle) {
@@ -1186,7 +1274,7 @@ export class ElectronSshManager {
if (session) {
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort, session.remoteBinPath);
}
await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
const auth = this.sshAuth.get(session.parsed);
@@ -1262,9 +1350,10 @@ export class ElectronSshManager {
throw new Error(`Unsupported remote OS: ${remoteOs}`);
}
const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath);
const { remotePort, startedByUs, remoteBinPath } = await this.ensureRemoteServer(instance, parsed, controlPath);
session.remotePort = remotePort;
session.startedByUs = startedByUs;
session.remoteBinPath = remoteBinPath;
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
+157
View File
@@ -289,4 +289,161 @@ describe('ElectronSshManager', () => {
});
expect(settings.desktopHosts).toEqual([{ id: 'ssh-1', label: 'SSH Host', url: localUrl, apiUrl: localUrl, clientToken: 'ssh-client-token' }]);
});
test('installs OpenChamber into a home-owned npm prefix instead of the root-owned global one', async () => {
const commands = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async (_parsed, _controlPath, name) => (name === 'npm' ? '/usr/bin/npm' : null);
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
commands.push(script);
return '';
};
await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
expect(commands).toHaveLength(1);
expect(commands[0]).toContain('--prefix "$HOME/.openchamber/npm-global"');
expect(commands[0]).not.toMatch(/npm install -g @openchamber/);
});
test('lists every remote OpenChamber binary with its reported version', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.runRemoteCommand = async () => [
'/home/pi/.openchamber/npm-global/bin/openchamber\t1.2.3',
'/usr/bin/openchamber\t0.9.0',
'',
].join('\n');
const candidates = await manager.remoteOpenChamberCandidates({ destination: 'user@example.test', args: [] }, '/tmp/control.sock');
expect(candidates).toEqual([
{ binPath: '/home/pi/.openchamber/npm-global/bin/openchamber', version: '1.2.3' },
{ binPath: '/usr/bin/openchamber', version: '0.9.0' },
]);
});
test('starts the resolved OpenChamber binary rather than whatever PATH exposes', async () => {
let started = '';
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
started = script;
return '4321\n';
};
const instance = { id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } };
const port = await manager.startRemoteServerManaged(
{ destination: 'user@example.test', args: [] },
'/tmp/control.sock',
instance,
4321,
'/home/pi/.openchamber/npm-global/bin/openchamber',
);
expect(port).toBe(4321);
expect(started).toContain("'/home/pi/.openchamber/npm-global/bin/openchamber' serve");
expect(started).toContain("OPENCODE_BINARY='/home/pi/.opencode/bin/opencode'");
expect(started).toContain('$HOME/.opencode/bin:');
});
test('refuses to start when the remote machine has no opencode CLI', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async () => null;
manager.runRemoteCommand = async () => {
throw new Error('should not start the server without a CLI');
};
await expect(manager.startRemoteServerManaged(
{ destination: 'user@example.test', args: [] },
'/tmp/control.sock',
{ id: 'ssh-1', auth: {}, remoteOpenchamber: { mode: 'managed' } },
4321,
'/home/pi/.bun/bin/openchamber',
)).rejects.toThrow(/opencode CLI is not installed/);
});
test('prefers a bun that only exists in the home directory over npm', async () => {
const commands = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
// A login shell over SSH does not put ~/.bun/bin on PATH.
manager.resolveRemoteTool = async (_parsed, _controlPath, name) =>
(name === 'bun' ? '/home/pi/.bun/bin/bun' : '/usr/bin/npm');
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
commands.push(script);
return '';
};
await manager.installOpenChamberManaged({ destination: 'user@example.test', args: [] }, '/tmp/control.sock', '1.2.3', 'auto');
expect(commands).toEqual(["'/home/pi/.bun/bin/bun' add -g @openchamber/web@1.2.3"]);
});
test('stops a remote server it started through the CLI, not the authenticated HTTP route', async () => {
const scripts = [];
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
scripts.push(script);
return '';
};
await manager.stopRemoteServerBestEffort(
{ destination: 'user@example.test', args: [] },
'/tmp/control.sock',
41777,
'/home/pi/.bun/bin/openchamber',
);
expect(scripts).toEqual(["'/home/pi/.bun/bin/openchamber' stop --port 41777"]);
});
test('publishes the remote server to its network only with a UI password', async () => {
const manager = new ElectronSshManager({
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
appVersion: '1.2.3',
emit: () => undefined,
});
manager.resolveRemoteTool = async () => '/home/pi/.opencode/bin/opencode';
let started = '';
manager.runRemoteCommand = async (_parsed, _controlPath, script) => {
started = script;
return '4321\n';
};
const parsed = { destination: 'user@example.test', args: [] };
const exposed = {
id: 'ssh-1',
auth: {},
remoteOpenchamber: { mode: 'managed', bindHost: '0.0.0.0' },
};
await expect(manager.startRemoteServerManaged(parsed, '/tmp/control.sock', exposed, 4321, '/bin/openchamber'))
.rejects.toThrow(/requires a UI password/);
const secured = {
...exposed,
auth: { openchamberPassword: { enabled: true, value: 'remote-secret', store: 'settings' } },
};
await manager.startRemoteServerManaged(parsed, '/tmp/control.sock', secured, 4321, '/bin/openchamber');
expect(started).toContain('--hostname 0.0.0.0');
});
});
@@ -0,0 +1,28 @@
import fsp from 'node:fs/promises';
const WINDOWS_RETRY_DELAYS_MS = [50, 100, 200, 400, 800, 1_000, 1_000];
const sleep = (milliseconds) => new Promise((resolve) => setTimeout(resolve, milliseconds));
const isTransientWindowsFileError = (error, platform) => {
if (platform !== 'win32') return false;
const code = error?.code;
return code === 'EPERM' || code === 'EACCES' || code === 'EBUSY';
};
export const replaceFileWithRetry = async (source, target, options = {}) => {
const platform = options.platform ?? process.platform;
const rename = options.rename ?? fsp.rename;
const wait = options.wait ?? sleep;
for (let attempt = 0; ; attempt += 1) {
try {
await rename(source, target);
return;
} catch (error) {
const delay = WINDOWS_RETRY_DELAYS_MS[attempt];
if (delay === undefined || !isTransientWindowsFileError(error, platform)) throw error;
await wait(delay);
}
}
};
@@ -0,0 +1,81 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { replaceFileWithRetry } from './windows-file-replace.mjs';
const fileError = (code = 'EPERM') => Object.assign(new Error(code), { code });
test('retries transient Windows rename failures until replacement succeeds', async () => {
const delays = [];
let attempts = 0;
await replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'win32',
rename: async () => {
attempts += 1;
if (attempts < 4) throw fileError();
},
wait: async (delay) => delays.push(delay),
});
assert.equal(attempts, 4);
assert.deepEqual(delays, [50, 100, 200]);
});
test('does not retry rename errors that are not transient Windows locks', async () => {
let attempts = 0;
const error = fileError('ENOENT');
await assert.rejects(
replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'win32',
rename: async () => {
attempts += 1;
throw error;
},
wait: async () => assert.fail('unexpected wait'),
}),
error,
);
assert.equal(attempts, 1);
});
test('does not retry transient error codes outside Windows', async () => {
let attempts = 0;
const error = fileError();
await assert.rejects(
replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'linux',
rename: async () => {
attempts += 1;
throw error;
},
wait: async () => assert.fail('unexpected wait'),
}),
error,
);
assert.equal(attempts, 1);
});
test('returns the final Windows lock error after the retry window', async () => {
const delays = [];
let attempts = 0;
await assert.rejects(
replaceFileWithRetry('settings.tmp', 'settings.json', {
platform: 'win32',
rename: async () => {
attempts += 1;
throw fileError();
},
wait: async (delay) => delays.push(delay),
}),
{ code: 'EPERM' },
);
assert.equal(attempts, 8);
assert.deepEqual(delays, [50, 100, 200, 400, 800, 1_000, 1_000]);
});
+2 -2
View File
@@ -1,6 +1,6 @@
{
"name": "@openchamber/ui",
"version": "1.19.0",
"version": "1.20.0",
"private": true,
"type": "module",
"main": "src/main.tsx",
@@ -44,7 +44,7 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@lezer/highlight": "^1.2.3",
"@opencode-ai/sdk": "1.18.18",
"@opencode-ai/sdk": "1.18.21",
"@pierre/diffs": "1.3.0-beta.6",
"@replit/codemirror-vim": "^6.4.0",
"@simplewebauthn/browser": "13.3.0",
+11 -8
View File
@@ -1,6 +1,7 @@
import React from 'react';
import { MainLayout } from '@/components/layout/MainLayout';
import { ChatView } from '@/components/views/ChatView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { FireworksProvider } from '@/contexts/FireworksContext';
import { Toaster } from '@/components/ui/sonner';
import { Button } from '@/components/ui/button';
@@ -11,6 +12,7 @@ import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { useMenuActions } from '@/hooks/useMenuActions';
import { useSessionStatusBootstrap } from '@/hooks/useSessionStatusBootstrap';
import { useTraySync } from '@/hooks/useTraySync';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWebNotificationStream } from '@/hooks/useWebNotificationStream';
@@ -33,7 +35,6 @@ import type { RecoveryVariant } from '@/components/onboarding/DesktopConnectionR
import { useSessionUIStore } from '@/sync/session-ui-store';
import { markSessionViewed } from '@/sync/notification-store';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { opencodeClient } from '@/lib/opencode/client';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { getRuntimeKey, subscribeRuntimeEndpointChanged } from '@/lib/runtime-switch';
@@ -639,12 +640,9 @@ function App({ apis }: AppProps) {
React.useEffect(() => {
if (typeof window === 'undefined') return;
const onOpenMiniChat = () => {
const currentDir = useDirectoryStore.getState().currentDirectory;
const { activeProjectId, projects } = useProjectsStore.getState();
const activeProject = projects.find((p) => p.id === activeProjectId) ?? null;
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: currentDir || activeProject?.path || '',
projectId: activeProject?.id ?? null,
directory: '',
projectId: null,
});
};
window.addEventListener('openchamber:open-mini-chat', onOpenMiniChat);
@@ -676,11 +674,13 @@ function App({ apis }: AppProps) {
const projectId = typeof detail?.projectId === 'string' && detail.projectId.trim().length > 0
? detail.projectId.trim()
: null;
const hasProjectTarget = Boolean(directory || projectId);
useUIStore.getState().setActiveMainTab('chat');
useUIStore.getState().setSessionSwitcherOpen(false);
useSessionUIStore.getState().openNewSessionDraft({
selectedProjectId: projectId,
directoryOverride: directory,
target: hasProjectTarget ? 'project' : 'chat',
selectedProjectId: hasProjectTarget ? projectId : null,
directoryOverride: hasProjectTarget ? directory : null,
preserveDirectoryOverride: Boolean(directory),
});
};
@@ -721,6 +721,7 @@ function App({ apis }: AppProps) {
useMenuActions(handleToggleMemoryDebug);
useTraySync();
useGlobalSessionsPolling(!embeddedSessionChat);
useSessionStatusBootstrap({ enabled: embeddedBackgroundWorkEnabled });
@@ -908,6 +909,7 @@ function App({ apis }: AppProps) {
isVSCodeRuntime={isVSCodeRuntime}
embeddedBackgroundWorkEnabled={embeddedBackgroundWorkEnabled}
/>
<AppLinkConfirmDialog />
</div>
</TooltipProvider>
</RuntimeAPIProvider>
@@ -951,6 +953,7 @@ function App({ apis }: AppProps) {
<OpenCodeUpdateToast />
<MainLayout />
<Toaster />
<AppLinkConfirmDialog />
{!isBootShell && (
<>
<ConfigUpdateOverlay />
+13 -7
View File
@@ -5,6 +5,7 @@ import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
import { TooltipProvider } from '@/components/ui/tooltip';
import { Toaster } from '@/components/ui/sonner';
import { MiniChatLayout } from '@/components/mini-chat/MiniChatLayout';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
@@ -25,6 +26,7 @@ import {
worktreeMapsEqual,
} from '@/lib/worktrees/worktreeManager';
import type { WorktreeMetadata } from '@/types/worktree';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
@@ -153,9 +155,9 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
const sessionId = typeof detail?.sessionId === 'string' ? detail.sessionId.trim() : '';
if (!sessionId) return;
if (useSessionUIStore.getState().currentSessionId === sessionId) return;
const directory = typeof detail?.directory === 'string' && detail.directory.trim().length > 0
? detail.directory.trim()
: (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory ?? null;
const sessionDirectory = (sessions.find((entry) => entry.id === sessionId) as { directory?: string | null } | undefined)?.directory?.trim();
const directory = sessionDirectory
|| (typeof detail?.directory === 'string' && detail.directory.trim().length > 0 ? detail.directory.trim() : null);
void sync.ensureSessionRenderable(sessionId);
setCurrentSession(sessionId, directory);
sessionBootstrappedRef.current = true;
@@ -166,9 +168,11 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
React.useEffect(() => {
if (config.mode !== 'draft' || draftOpen || currentSessionId) return;
const hasProjectTarget = Boolean(config.projectId || config.directory);
openNewSessionDraft({
selectedProjectId: config.projectId,
directoryOverride: config.directory,
target: hasProjectTarget ? 'project' : 'chat',
selectedProjectId: hasProjectTarget ? config.projectId : CHAT_DRAFT_PROJECT_ID,
directoryOverride: hasProjectTarget ? config.directory : null,
preserveDirectoryOverride: Boolean(config.directory),
});
}, [config, currentSessionId, draftOpen, openNewSessionDraft]);
@@ -278,10 +282,11 @@ const MiniChatPresencePublisher: React.FC = () => {
const useSessionUnavailable = (config: MiniChatConfig): boolean => {
const sessions = useSessions();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => state.newSessionDraft.open);
const [timedOut, setTimedOut] = React.useState(false);
React.useEffect(() => {
if (config.mode !== 'session' || !config.sessionId || currentSessionId === config.sessionId) {
if (draftOpen || config.mode !== 'session' || !config.sessionId || currentSessionId) {
setTimedOut(false);
return;
}
@@ -291,7 +296,7 @@ const useSessionUnavailable = (config: MiniChatConfig): boolean => {
}
const timeout = window.setTimeout(() => setTimedOut(true), 5000);
return () => window.clearTimeout(timeout);
}, [config.mode, config.sessionId, currentSessionId, sessions]);
}, [config.mode, config.sessionId, currentSessionId, draftOpen, sessions]);
return timedOut;
};
@@ -321,6 +326,7 @@ export function ElectronMiniChatApp({ apis }: ElectronMiniChatAppProps) {
<TooltipProvider delayDuration={300} skipDelayDuration={150}>
<div className="h-full text-foreground bg-background">
<ElectronMiniChatContent config={config} />
<AppLinkConfirmDialog />
<Toaster />
</div>
</TooltipProvider>
+2
View File
@@ -9,6 +9,7 @@ import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
import { ChatView } from '@/components/views/ChatView';
import { PlanView } from '@/components/views/PlanView';
import { SettingsView } from '@/components/views/SettingsView';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { RuntimeAPIProvider } from '@/contexts/RuntimeAPIProvider';
import { registerRuntimeAPIs } from '@/contexts/runtimeAPIRegistry';
@@ -1258,6 +1259,7 @@ export function MobileApp({ apis }: MobileAppProps) {
switchRuntimeEndpoint({ apiBaseUrl: '', clientToken: null, runtimeKey: 'mobile-disconnected' });
setConnectionEpoch((value) => value + 1);
}} />
<AppLinkConfirmDialog />
<Toaster position="top-center" offset="calc(var(--oc-safe-area-top, 0px) + 16px)" />
{isInitialized ? <ConfigUpdateOverlay /> : null}
</div>
+5
View File
@@ -8,8 +8,10 @@ import { Toaster } from '@/components/ui/sonner';
import { ConfigUpdateOverlay } from '@/components/ui/ConfigUpdateOverlay';
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
import { OpenCodeUpdateToast } from '@/components/update/OpenCodeUpdateToast';
import { AppLinkConfirmDialog } from '@/components/chat/AppLinkConfirmDialog';
import { VSCodeLayout } from '@/components/layout/VSCodeLayout';
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
import { useGlobalSessionsPolling } from '@/hooks/useGlobalSessionsPolling';
import { useRouter } from '@/hooks/useRouter';
import { useWindowTitle } from '@/hooks/useWindowTitle';
import { opencodeClient } from '@/lib/opencode/client';
@@ -56,6 +58,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
usePushVisibilityBeacon({ enabled: true });
useWindowTitle();
useRouter();
useGlobalSessionsPolling(panelType !== 'agentManager');
React.useEffect(() => {
document.documentElement.classList.toggle('wide-chat-layout', wideChatLayoutEnabled);
@@ -108,6 +111,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<AgentManagerView />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
</div>
@@ -127,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
<div className="h-full text-foreground bg-background">
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
<VSCodeLayout />
<AppLinkConfirmDialog />
<OpenCodeUpdateToast />
<Toaster position="top-center" />
<ConfigUpdateOverlay />
@@ -0,0 +1,46 @@
import React from 'react';
import { beforeEach, describe, expect, mock, test } from 'bun:test';
import { renderToStaticMarkup } from 'react-dom/server';
import { I18nProvider } from '@/lib/i18n';
mock.module('@/components/ui/dialog', () => ({
Dialog: ({ children }: React.PropsWithChildren) => <>{children}</>,
DialogContent: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogDescription: ({ children }: React.PropsWithChildren) => <p>{children}</p>,
DialogFooter: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogHeader: ({ children }: React.PropsWithChildren) => <div>{children}</div>,
DialogTitle: ({ children }: React.PropsWithChildren) => <h2>{children}</h2>,
}));
const { AppLinkConfirmDialog } = await import('./AppLinkConfirmDialog');
const {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} = await import('./appLinkConfirmation');
describe('AppLinkConfirmDialog', () => {
beforeEach(() => {
if (getAppLinkConfirmationSnapshot()) {
settleAppLinkConfirmation('cancel');
}
});
test('keeps cancel visible and focused beside both open choices', () => {
void openAppLinkWithConfirmation('obsidian://open?vault=Notebook');
const markup = renderToStaticMarkup(
<I18nProvider>
<AppLinkConfirmDialog />
</I18nProvider>,
);
expect(markup).toContain('>Cancel</button>');
expect(markup).toContain('autofocus=""');
expect(markup).toContain('>Open once</button>');
expect(markup).toContain('>Trust and open</button>');
settleAppLinkConfirmation('cancel');
});
});
@@ -0,0 +1,77 @@
import * as React from 'react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useI18n } from '@/lib/i18n';
import { getUrlScheme } from '@/lib/url';
import {
getAppLinkConfirmationSnapshot,
settleAppLinkConfirmation,
subscribeAppLinkConfirmation,
type AppLinkConfirmationChoice,
} from './appLinkConfirmation';
/**
* App-level dialog confirming application deep links (obsidian://, vscode://,
* ...) rendered in chat markdown before the OS is asked to open them.
* Dismissing via the close button, Escape, or the backdrop cancels the open.
*/
export const AppLinkConfirmDialog = () => {
const { t } = useI18n();
const request = React.useSyncExternalStore(
subscribeAppLinkConfirmation,
getAppLinkConfirmationSnapshot,
getAppLinkConfirmationSnapshot,
);
const url = request?.url ?? '';
const scheme = getUrlScheme(url) ?? '';
const settle = React.useCallback((choice: AppLinkConfirmationChoice) => {
settleAppLinkConfirmation(choice);
}, []);
return (
<Dialog
open={Boolean(request)}
onOpenChange={(open: boolean) => {
if (!open) {
settle('cancel');
}
}}
>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('chat.appLink.confirm.title')}</DialogTitle>
<DialogDescription>
{scheme
? t('chat.appLink.confirm.description', { scheme: `${scheme}://` })
: t('chat.appLink.confirm.descriptionPlain')}
</DialogDescription>
</DialogHeader>
<div className="rounded-lg bg-[var(--surface-muted)] px-3 py-2 text-[13px] leading-relaxed break-all text-[var(--surface-foreground)]">
{url}
</div>
<DialogFooter>
<Button variant="ghost" autoFocus onClick={() => settle('cancel')}>
{t('chat.appLink.confirm.cancel')}
</Button>
<Button variant="outline" onClick={() => settle('trust')}>
{t('chat.appLink.confirm.trustAndOpen')}
</Button>
<Button variant="default" onClick={() => settle('open')}>
{t('chat.appLink.confirm.open')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
};
+126 -113
View File
@@ -60,11 +60,14 @@ import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shel
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
const IDLE_SESSION_STATUS = { type: 'idle' as const };
const CHAT_FORCE_SCROLL_BOTTOM_EVENT = 'openchamber:chat-force-scroll-bottom';
const DEFAULT_RETRY_MESSAGE = 'Quota limit reached. Retrying automatically.';
const DRAFT_EXIT_DURATION_MS = 120;
const COMPOSER_MOVE_DURATION_MS = 180;
const CHAT_SCROLL_STYLE = {
overflowAnchor: 'none',
overscrollBehavior: 'contain',
@@ -502,19 +505,24 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
);
};
const DraftWelcome: React.FC = () => {
const DraftWelcome: React.FC<{ exiting?: boolean }> = ({ exiting = false }) => {
const { t } = useI18n();
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
const projectLabel = useProjectsStore(React.useCallback((state) => {
if (draftTarget === 'chat') return null;
const projectId = selectedProjectId ?? state.activeProjectId;
const project = (projectId
? state.projects.find((candidate) => candidate.id === projectId)
: null) ?? state.projects[0] ?? null;
return project ? getProjectDisplayLabel(project) : null;
}, [selectedProjectId]));
}, [draftTarget, selectedProjectId]));
return (
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
<div className={cn(
'oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
exiting && 'pointer-events-none opacity-0',
)}>
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
{renderDraftTitle(
projectLabel
@@ -558,6 +566,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// Session UI state
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
const currentSessionDirectory = useSessionUIStore((s) => s.currentSessionDirectory);
const materializedDraftSessionId = useSessionUIStore((s) => s.materializedDraftSessionId);
const clearMaterializedDraftSession = useSessionUIStore((s) => s.clearMaterializedDraftSession);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -731,12 +741,15 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
const isVSCode = isVSCodeRuntime();
const chatSurfaceMode = useChatSurfaceMode();
const draftOpen = Boolean(newSessionDraft?.open);
const isManagedChatContext = draftOpen
? newSessionDraft?.target === 'chat'
: isChatDirectoryPath(effectiveSessionDirectory);
// A draft can target another project or a pending worktree before it has a
// session. Keep the panel on that same directory so its project, MCP, and
// usage readouts describe where the draft will run rather than the project
// the user came from.
const workStatusDirectory = draftOpen
? newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory
? (isManagedChatContext ? null : newSessionDraft?.bootstrapPendingDirectory ?? newSessionDraft?.directoryOverride ?? effectiveSessionDirectory)
: effectiveSessionDirectory;
const initError = useGlobalSyncStore((s) => s.error);
// Despite the historical name, this now covers mobile too: the mobile
@@ -748,7 +761,6 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
// row that holds both columns, so its width never depends on the panel's
// own visibility.
const { rowRef: workStatusRowRef, visible: workStatusVisible, fits: workStatusFits } = useWorkStatusVisibility({
directory: workStatusDirectory,
isMobile,
isVSCode,
});
@@ -1091,11 +1103,75 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
void ensureSessionRenderable(currentSessionId);
}, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]);
const composerSlotRef = React.useRef<HTMLDivElement | null>(null);
const previousComposerRectRef = React.useRef<DOMRect | null>(null);
const previousDraftOpenRef = React.useRef(draftOpen);
const previousDraftLayoutVisibleRef = React.useRef(draftOpen);
const [draftExitAnimating, setDraftExitAnimating] = React.useState(false);
const shouldAnimateDraftTransition = Boolean(
currentSessionId && materializedDraftSessionId === currentSessionId,
);
const draftPresentationExiting = draftExitAnimating
|| (previousDraftOpenRef.current && !draftOpen && shouldAnimateDraftTransition);
const draftLayoutVisible = draftOpen || draftPresentationExiting;
React.useLayoutEffect(() => {
if (draftOpen) {
setDraftExitAnimating(false);
return;
}
if (!previousDraftOpenRef.current || !shouldAnimateDraftTransition) return;
setDraftExitAnimating(true);
const timeoutId = window.setTimeout(() => setDraftExitAnimating(false), DRAFT_EXIT_DURATION_MS);
return () => window.clearTimeout(timeoutId);
}, [draftOpen, shouldAnimateDraftTransition]);
React.useLayoutEffect(() => {
previousDraftOpenRef.current = draftOpen;
}, [draftOpen]);
React.useLayoutEffect(() => {
const composerSlot = composerSlotRef.current;
if (!composerSlot) return;
const composerEditor = composerSlot.querySelector('[data-testid="chat-input"]');
const currentRect = composerEditor?.getBoundingClientRect() ?? composerSlot.getBoundingClientRect();
const previousRect = previousComposerRectRef.current;
const leftDraftLayout = previousDraftLayoutVisibleRef.current
&& !draftLayoutVisible
&& Boolean(currentSessionId);
const reduceMotion = window.matchMedia?.('(prefers-reduced-motion: reduce)').matches ?? false;
const shouldMoveComposer = leftDraftLayout && shouldAnimateDraftTransition;
if (shouldMoveComposer && previousRect && !reduceMotion && !useCompactDraftLayout && !isDesktopExpandedInput) {
const deltaX = previousRect.left - currentRect.left;
const deltaY = previousRect.top - currentRect.top;
composerSlot.animate(
[
{ transform: `translate(${deltaX}px, ${deltaY}px)` },
{ transform: 'translate(0, 0)' },
],
{ duration: COMPOSER_MOVE_DURATION_MS, easing: 'cubic-bezier(0.22, 1, 0.36, 1)' },
);
}
previousComposerRectRef.current = currentRect;
previousDraftLayoutVisibleRef.current = draftLayoutVisible;
if (leftDraftLayout && currentSessionId) {
clearMaterializedDraftSession(currentSessionId);
}
}, [
clearMaterializedDraftSession,
currentSessionId,
draftLayoutVisible,
isDesktopExpandedInput,
shouldAnimateDraftTransition,
useCompactDraftLayout,
]);
if (!currentSessionId && !draftOpen) {
// With auto-open, the draft welcome opens on the next tick (effect below),
// so the empty state is only ever transient here — render a neutral
// background instead of flashing the logo / "start a new chat" on refresh.
// Keep the empty state when there's nothing to auto-open or an init error to show.
// The auto-open effect runs on the next tick. Use a neutral background
// until then instead of flashing the standard empty state.
if (autoOpenDraft && !initError) {
return <div className="flex h-full flex-col bg-background" />;
}
@@ -1106,55 +1182,17 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
);
}
if (!currentSessionId && draftOpen) {
return (
// No transform on this root: it would become the containing block for
// the fullscreen composer's position:fixed visual-viewport pinning in
// mobile browsers (see ChatInput's composerFormRef effect).
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col bg-background">
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
<div
className={cn(
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 bg-background'
: useCompactDraftLayout
? 'bg-background px-0'
: 'flex-1 items-center justify-center bg-background px-0 pb-[6vh]'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
{workStatusOverlayMountable ? (
<WorkStatusPanel
overlay
visible={showWorkStatusOverlay}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
{workStatusPanelMountable ? (
<WorkStatusPanel
visible={showWorkStatusPanel}
sessionId={null}
directory={workStatusDirectory ?? null}
/>
) : null}
</div>
);
}
if (!currentSessionId) {
const sessionSurface = (() => {
if (draftOpen || draftPresentationExiting) {
if (!useCompactDraftLayout || isDesktopExpandedInput) {
return null;
}
return <DraftWelcome exiting={draftPresentationExiting} />;
}
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
if (sessionMessageLoadState.status === 'error') {
return (
<div data-composer-bound className="relative flex h-full flex-col bg-background">
{returnToParentButton}
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
<div className="max-w-sm text-center">
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
@@ -1167,21 +1205,14 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
</Button>
</div>
</div>
<div className="relative z-10 bg-background">
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
return (
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
)}
aria-hidden={isDesktopExpandedInput}
>
@@ -1192,20 +1223,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
<div className="chat-message-column">
<div className="space-y-2.5 px-4 py-3">
<div className="space-y-1.5">
{item.toolRows.map((row) => {
return (
{item.toolRows.map((row) => (
<div key={`${item.id}-${row.id}`} className="flex items-center gap-2">
<Skeleton className="h-3.5 w-3.5 rounded-full flex-shrink-0" />
<Skeleton className="h-3.5 w-3.5 shrink-0 rounded-full" />
<Skeleton className={cn('h-4 rounded-md', row.titleWidth)} />
<Skeleton className={cn('h-4 rounded-md', row.detailWidth)} />
</div>
);
})}
))}
</div>
<div className="space-y-1.5 pt-1">
<Skeleton className={cn('h-4 rounded-md', item.textWidths[0])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[1])} />
<Skeleton className={cn('h-4 rounded-md', item.textWidths[2])} />
{item.textWidths.map((width, index) => (
<Skeleton key={`${item.id}-text-${index}`} className={cn('h-4 rounded-md', width)} />
))}
</div>
</div>
</div>
@@ -1214,62 +1243,25 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
</div>
</div>
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
);
}
if (sessionMessages.length === 0 && !sessionIsWorking) {
return (
// No transform here either — same fixed-positioning constraint as the
// draft branch above.
<div data-composer-bound className="relative flex flex-col h-full bg-background">
{returnToParentButton}
<div
className={cn(
'relative min-h-0',
isDesktopExpandedInput
? 'absolute inset-0 opacity-0 pointer-events-none'
: 'flex-1'
isDesktopExpandedInput ? 'pointer-events-none absolute inset-0 opacity-0' : 'flex-1',
)}
aria-hidden={isDesktopExpandedInput}
>
{!isDesktopExpandedInput ? (
<div className="absolute inset-0 flex items-center justify-center">
<ChatEmptyState />
</div>
) : null}
</div>
<div
className={cn(
'relative z-10',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: 'bg-background'
)}
>
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
</div>
</div>
/>
);
}
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
<ChatViewport
currentSessionId={currentSessionId}
currentSessionKey={currentSessionKey ?? currentSessionId}
currentSessionId={currentSessionId ?? ''}
currentSessionKey={currentSessionKey ?? currentSessionId ?? ''}
isDesktopExpandedInput={isDesktopExpandedInput}
isMobile={isMobile}
stickyUserHeader={stickyUserHeader}
@@ -1300,22 +1292,41 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
isLoadingOlderPrompts={timelineController.isLoadingOlder}
onLoadEarlierPrompts={handleLoadOlderClick}
/>
);
})();
return (
<div ref={workStatusRowRef} className="flex h-full min-h-0 bg-background">
<div data-composer-bound className="relative flex min-w-0 flex-1 flex-col h-full bg-background">
{returnToParentButton}
{sessionSurface}
<div
ref={composerSlotRef}
className={cn(
'relative z-10',
'relative z-10 flex min-h-0',
isDesktopExpandedInput
? 'flex-1 min-h-0 bg-background'
: draftLayoutVisible && !useCompactDraftLayout
? 'flex-1 items-center justify-center bg-background pb-[6vh]'
: 'bg-background'
)}
>
{!isDesktopExpandedInput && sessionMessages.length > 0 && (
{!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && (
<ScrollToBottomButton
visible={timelineController.showScrollToBottom}
onClick={navigation.resumeToLatest}
/>
)}
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput active={active} scrollToBottom={scrollToBottomOnSend} />}
{promptReadOnly ? (
<ReadOnlyPromptBanner />
) : (
<ChatInput
active={active}
scrollToBottom={scrollToBottomOnSend}
draftPresentationExiting={draftPresentationExiting}
/>
)}
</div>
{/* Inside the chat column, not beside it: as a row sibling it took
@@ -1327,6 +1338,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
visible={showWorkStatusOverlay}
sessionId={currentSessionId ?? null}
directory={workStatusDirectory ?? null}
repositoryEnabled={!isManagedChatContext}
/>
) : null}
@@ -1349,6 +1361,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({
visible={showWorkStatusPanel}
sessionId={currentSessionId ?? null}
directory={workStatusDirectory ?? null}
repositoryEnabled={!isManagedChatContext}
/>
) : null}
</div>
+130 -29
View File
@@ -34,6 +34,9 @@ import {
type ChatDraftSnapshot,
} from '@/lib/chatDraftPersistence';
import { ReviewFlowDialog, type ReviewFlowExecution } from '@/components/session/ReviewFlowDialog';
import { BtwPanel } from './btw/BtwPanel';
import { useBtwPanelState } from './btw/useBtwPanelState';
import { destroyBtwSession, startBtwSession, type BtwSessionRef } from '@/lib/btw';
import { AttachedFilesList, AttachedVSCodeFileChips, ActiveEditorFileSuggestion } from './FileAttachment';
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
import type { ToolPopupContent } from './message/types';
@@ -51,7 +54,7 @@ import { PendingChangesBar } from './PendingChangesBar';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import { MobileAgentButton } from './MobileAgentButton';
import { MobileModelButton } from './MobileModelButton';
import { useCurrentSessionActivity } from '@/hooks/useSessionActivity';
import { useCurrentSessionActivity, useSessionActivity } from '@/hooks/useSessionActivity';
import { toast } from '@/components/ui';
// useMessageStore removed — messages now come from sync system
import { isVSCodeRuntime } from '@/lib/desktop';
@@ -229,6 +232,7 @@ interface ChatInputProps {
onOpenSettings?: () => void;
scrollToBottom?: () => void;
active?: boolean;
draftPresentationExiting?: boolean;
}
const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity | null => {
@@ -242,7 +246,12 @@ const resolveChatDraftIdentity = (sessionId: string | null): ChatDraftIdentity |
return createChatDraftIdentity(getRuntimeKey(), directory, sessionId);
};
const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollToBottom, active = true }) => {
const ChatInputComponent: React.FC<ChatInputProps> = ({
onOpenSettings,
scrollToBottom,
active = true,
draftPresentationExiting = false,
}) => {
const { t } = useI18n();
// Track if we restored a draft on mount (for text selection)
const initialDraftRef = React.useRef<string | null>(null);
@@ -314,6 +323,20 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const currentSessionDirectoryForSync = useSessionUIStore(
React.useCallback((s) => currentSessionId ? s.getDirectoryForSession(currentSessionId) : null, [currentSessionId]),
);
// btw mode: the CURRENT session's metadata links an active btw fork and
// the panel is expanded, so this composer's sends route to the fork
// instead of the main session. Collapsed keeps the fork alive (chip stays
// visible) while the composer talks to the main session again.
const btwPanel = useBtwPanelState(currentSessionId, currentSessionDirectoryForSync ?? currentDirectory ?? undefined);
const btwSessionId = btwPanel.btwSessionId;
const btwDirectory = btwPanel.btwDirectory;
const btwSessionRef = React.useMemo<BtwSessionRef | null>(
() => (currentSessionId && btwSessionId && btwDirectory
? { parentSessionId: currentSessionId, btwSessionId, directory: btwDirectory }
: null),
[btwDirectory, btwSessionId, currentSessionId],
);
const isBtwActive = Boolean(btwSessionRef) && !btwPanel.collapsed;
const activeRuntimeKey = getRuntimeKey();
const chatDraftIdentity = React.useMemo(
() => createChatDraftIdentity(
@@ -331,6 +354,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const setNewSessionDraftTarget = useSessionUIStore((s) => s.setNewSessionDraftTarget);
const setDraftPermissionAutoAcceptEnabled = useSessionUIStore((s) => s.setDraftPermissionAutoAcceptEnabled);
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
const prepareChatDraftDirectory = useSessionUIStore((s) => s.prepareChatDraftDirectory);
const abortPromptSessionId = useSessionUIStore((s) => s.abortPromptSessionId);
const clearAbortPrompt = useSessionUIStore((s) => s.clearAbortPrompt);
const attachedFiles = useInputStore((s) => s.attachedFiles);
@@ -341,6 +365,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const pendingPresetSubmit = useInputStore((s) => s.pendingPresetSubmit);
const setPendingInputText = useInputStore((s) => s.setPendingInputText);
const pendingInputText = useInputStore((s) => s.pendingInputText);
React.useEffect(() => {
if (!newSessionDraftOpen || newSessionDraft.target !== 'chat' || message.trim().length === 0) return;
void prepareChatDraftDirectory();
}, [message, newSessionDraft.target, newSessionDraftOpen, prepareChatDraftDirectory]);
const consumePendingSyntheticParts = useInputStore((s) => s.consumePendingSyntheticParts);
const acknowledgeSessionAbort = useSessionUIStore((s) => s.acknowledgeSessionAbort);
const abortCurrentOperation = React.useCallback(
@@ -557,7 +586,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const availableSkills = useSkillsStore((s) => s.skills);
const knownSlashNames = React.useMemo(() => {
const names = new Set<string>([
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
'init', 'review', 'undo', 'redo', 'timeline', 'compact', 'btw', 'summary', 'workspace-review', 'plan-feature', 'craft-goal', 'schedule-task', 'catch-up', 'debug', 'weigh', 'explore',
]);
if (!isMobile && !isVSCodeRuntime()) names.add('handoff-review');
for (const command of availableCommands) names.add(command.name.toLowerCase());
@@ -832,8 +861,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
prevNewSessionDraftOpenRef.current = newSessionDraftOpen;
}, [newSessionDraftOpen, isMobile]);
// Session activity for queue availability and controls
const { phase: sessionPhase } = useCurrentSessionActivity();
// Session activity for queue availability and controls. In btw mode the
// composer controls the temporary fork, so the stop button and send-button
// state follow the FORK's activity; the queue affordance stays tied to the
// main session (queued messages always belong to the main chat).
const { phase: currentSessionPhase } = useCurrentSessionActivity();
const { phase: btwSessionPhase } = useSessionActivity(btwSessionId, btwDirectory ?? undefined);
const sessionPhase = isBtwActive ? btwSessionPhase : currentSessionPhase;
const autoReviewRunning = useAutoReviewStore(React.useCallback((state) => {
if (!currentSessionId) return false;
const run = state.runsByOriginalSessionID[currentSessionId];
@@ -1043,12 +1077,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// queued-message auto-send hook delivers it as the next turn once the
// 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) {
if (currentSessionId && !queuedOnly && autoReviewRunning && !isBtwActive) {
handleQueueMessage();
return;
}
if (currentSessionId && !queuedOnly) {
// btw mode: the child fork's blocking prompts are answered inside the
// panel; the composer send goes straight to the fork (routeMessage
// queues if the fork's own turn is busy).
if (currentSessionId && !queuedOnly && !isBtwActive) {
// Sending is authoritative for blocking prompts: deny pending
// permissions and dismiss open questions for the session subtree,
// then queue the message once if either was open. The deny/clear
@@ -1068,17 +1105,24 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
}
const sendMessageOptions: {
let sendMessageOptions: {
target?: NonNullable<typeof capturedTarget>;
sessionId?: string;
directory?: string;
draftSnapshot?: NonNullable<typeof capturedDraftSnapshot>;
delivery?: 'steer';
} | undefined = (capturedTarget || capturedDraftSnapshot || delivery)
? {
...(capturedTarget ? { target: capturedTarget } : {}),
...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}),
...(delivery ? { delivery } : {}),
} | undefined;
if (isBtwActive && btwSessionId && btwDirectory) {
sendMessageOptions = {
sessionId: btwSessionId,
directory: btwDirectory,
};
} else if (capturedTarget || capturedDraftSnapshot || delivery) {
sendMessageOptions = {};
if (capturedTarget) sendMessageOptions.target = capturedTarget;
if (capturedDraftSnapshot) sendMessageOptions.draftSnapshot = capturedDraftSnapshot;
}
: undefined;
if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery;
const preparedDocumentMentions = new Map<string, AttachedFile[]>();
const reservedFilenames = new Set([
@@ -1219,6 +1263,40 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
return;
}
if (commandName === 'btw' && currentSessionId) {
const question = argument.trim();
if (!question) {
toast.error(t('chat.btw.toast.emptyArgument'));
return;
}
const targetDirectory = useSessionUIStore.getState().getDirectoryForSession(currentSessionId)
|| currentDirectory
|| null;
if (!targetDirectory) {
toast.error(t('chat.btw.toast.createFailed'));
return;
}
try {
// A new btw replaces this session's current one: destroy
// the previous fork first so forks never accumulate.
if (btwSessionRef) {
await destroyBtwSession(btwSessionRef);
}
await startBtwSession({
parentSessionId: currentSessionId,
question,
directory: targetDirectory,
providerID: providerIdToSend,
modelID: modelIdToSend,
agent: agentNameToSend,
variant: variantToSend,
});
scrollToBottom?.();
} catch (error) {
toast.error(getSubmitErrorMessage(error, t('chat.btw.toast.createFailed')));
}
return;
}
// The rest render a visible prompt plus synthetic instructions and
// send them as one message.
@@ -1254,7 +1332,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}
const currentSessionDirectory = capturedTarget?.directory ?? currentDirectory;
const shouldAddResponseStyle = newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false);
// btw mode: the fork already carries the question plus full history,
// so the response-style instruction never applies there.
const shouldAddResponseStyle = !isBtwActive && (newSessionDraftOpen || (currentSessionId ? !hasUserMessages(currentSessionId, currentSessionDirectory) : false));
if (shouldAddResponseStyle) {
const responseStyleInstruction = await fetchResponseStyleInstruction().catch(() => null);
if (responseStyleInstruction) {
@@ -1424,7 +1504,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
// Primary action for send/queue button — respects selected follow-up behavior
const handlePrimaryAction = React.useCallback(() => {
const inputSnapshot = getCurrentInputSnapshot();
const canQueue = inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
const canQueue = !isBtwActive && inputMode === 'normal' && inputSnapshot.hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue' && canQueue) {
handleQueueMessage();
} else if (followUpBehavior === 'steer' && canQueue) {
@@ -1432,7 +1512,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
} else {
void handleSubmitRef.current();
}
}, [inputMode, getCurrentInputSnapshot, currentSessionId, sessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage]);
}, [inputMode, getCurrentInputSnapshot, currentSessionId, currentSessionPhase, autoReviewRunning, followUpBehavior, handleQueueMessage, isBtwActive]);
// Draft welcome presets: submit immediately.
const submitPresetPrompt = React.useCallback((text: string, type: 'command' | 'skill') => {
@@ -1636,15 +1716,19 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
return;
}
// Handle Enter/Ctrl+Enter based on selected follow-up behavior.
if (e.key === 'Enter' && !e.shiftKey && (!isMobile || e.ctrlKey || e.metaKey)) {
// Handle Enter/Ctrl+Enter based on selected follow-up behavior. On
// mobile, and in desktop focus mode, plain Enter writes a newline and
// only Cmd/Ctrl+Enter sends: both are surfaces for composing long
// prompts, where an accidental send costs more than an extra keypress.
const requiresModifierToSend = isMobile || isDesktopExpanded;
if (e.key === 'Enter' && !e.shiftKey && (!requiresModifierToSend || e.ctrlKey || e.metaKey)) {
e.preventDefault();
const isCtrlEnter = e.ctrlKey || e.metaKey;
// Queueing / steering only works when there's an existing busy
// session (or an active auto-review run).
const canQueue = inputMode === 'normal' && hasContent && currentSessionId && (sessionPhase !== 'idle' || autoReviewRunning);
const canQueue = !isBtwActive && inputMode === 'normal' && hasContent && currentSessionId && (currentSessionPhase !== 'idle' || autoReviewRunning);
if (followUpBehavior === 'queue') {
if (isCtrlEnter || !canQueue) {
@@ -1694,8 +1778,11 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
clearAbortPrompt();
startAbortIndicator();
void abortCurrentOperation(currentSessionId || undefined);
}, [abortCurrentOperation, clearAbortPrompt, currentSessionId, startAbortIndicator]);
// btw mode: the stop button stops the fork's turn, not the main
// session's.
const abortTarget = isBtwActive && btwSessionId ? btwSessionId : currentSessionId;
void abortCurrentOperation(abortTarget || undefined);
}, [abortCurrentOperation, btwSessionId, clearAbortPrompt, currentSessionId, isBtwActive, startAbortIndicator]);
const handleCycleAgent = React.useCallback((direction: 1 | -1 = 1) => {
const nextAgentName = getCycledPrimaryAgentName(agents, currentAgentName, direction);
@@ -2392,6 +2479,15 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
const chatSurfaceMode = useChatSurfaceMode();
const isMiniChatSurface = chatSurfaceMode === 'mini-chat';
const showDesktopDraftPresentation = (newSessionDraftOpen || draftPresentationExiting)
&& !isDesktopExpanded
&& !isMobile
&& !isVSCode
&& !isMiniChatSurface;
const draftPresentationClassName = cn(
'transition-opacity duration-[120ms] ease-out motion-reduce:transition-none',
draftPresentationExiting && 'pointer-events-none opacity-0',
);
const hasPendingChanges = React.useMemo(() => {
if (isMiniChatSurface) {
@@ -2405,7 +2501,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
React.useEffect(() => {
if (!showDraftTargetSelectors || !selectedDraftProject || !selectedDraftDirectory) {
if (!showDraftTargetSelectors || !selectedDraftProject || selectedDraftProject.kind === 'chat' || !selectedDraftDirectory) {
return;
}
if (newSessionDraft?.pendingWorktreeRequestId || newSessionDraft?.bootstrapPendingDirectory || newSessionDraft?.preserveDirectoryOverride) {
@@ -2569,8 +2665,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
)}
style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined}
>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
<div className="chat-input-column mb-7 text-center">
{showDesktopDraftPresentation ? (
<div className={cn('chat-input-column mb-7 text-center', draftPresentationClassName)}>
<h1 className="text-balance text-2xl font-normal tracking-tight text-foreground md:text-3xl">
{renderDraftTitle(
draftProjectLabel
@@ -2645,7 +2741,8 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
? null
: <PendingChangesBar />}
/>
{!isMobile && showDraftTargetSelectors && selectedDraftProject ? (
{!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? (
<div className={draftPresentationClassName}>
<DraftTargetSelectors
projects={draftProjects}
selectedProject={selectedDraftProject}
@@ -2660,6 +2757,7 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
onDirectoryChange={handleDraftDirectoryChange}
theme={currentTheme}
/>
</div>
) : null}
{isMobile && showDraftTargetSelectors && selectedDraftProject ? (
<MobileDraftTargetTriggers
@@ -2826,7 +2924,9 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
}}
onFocus={mobileShell.onEditorFocus}
onBlur={mobileShell.onEditorBlur}
placeholder={currentSessionId || newSessionDraftOpen
placeholder={isBtwActive
? t('chat.btw.mainComposerPlaceholder')
: currentSessionId || newSessionDraftOpen
? inputMode === 'shell'
? t('chat.chatInput.placeholder.shell')
: t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat')
@@ -2923,12 +3023,13 @@ const ChatInputComponent: React.FC<ChatInputProps> = ({ onOpenSettings, scrollTo
/>
) : null}
</div>
{newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? (
{showDesktopDraftPresentation ? (
<DraftPresetChips
onSubmit={(starter) => submitPresetPrompt(starter.submitText, starter.ref.type)}
className="chat-input-column mt-4"
className={cn('chat-input-column mt-4', draftPresentationClassName)}
/>
) : null}
{currentSessionId ? <BtwPanel parentSessionId={currentSessionId} panel={btwPanel} /> : null}
</form>
{/* Issue Picker Dialog */}
@@ -12,6 +12,7 @@ import { useSelectionStore } from '@/sync/selection-store';
import { useDeviceInfo } from '@/lib/device';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { useChatSurfaceMode } from './useChatSurfaceMode';
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
import MessageBody from './message/MessageBody';
@@ -202,6 +203,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
const messageRole = React.useMemo(() => deriveMessageRole(message.info), [message.info]);
const isUser = messageRole.isUser;
const chatSurfaceMode = useChatSurfaceMode();
const useExternalUserActionsRow = isUser && (isMobile || !stickyUserHeader);
const showStickyInlineHoverRow = isUser && !isMobile && stickyUserHeader && !useExternalUserActionsRow;
@@ -1044,7 +1046,10 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
respectReducedMotion
>
<div className={cn('relative flex justify-end', !isMobile ? 'group/user-shell' : undefined)}>
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined)}>
{/* peek: the action row under the bubble is suppressed, so
reserve its gap to the next message here, OUTSIDE the
bubble background. */}
<div className={cn('max-w-[85%]', showStickyInlineHoverRow ? 'pb-5' : undefined, chatSurfaceMode === 'peek' ? 'pb-3' : undefined)}>
<div
style={{
backgroundColor: 'var(--chat-user-message-bg)',
@@ -11,6 +11,7 @@ import { useUIStore } from '@/stores/useUIStore';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type CommandSource = 'openchamber' | 'opencode' | 'skill';
@@ -84,7 +85,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const keyboardNavigationRef = React.useRef(false);
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const ignoreClickRef = React.useRef(false);
const pointerStartRef = React.useRef<{ x: number; y: number } | null>(null);
const pointerMovedRef = React.useRef(false);
@@ -152,6 +153,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -226,6 +231,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
: []
),
{ id: 'openchamber:compact', name: 'compact', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.compactDescription'), isBuiltIn: true },
...(hasSession
? [{ id: 'openchamber:btw', name: 'btw', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.btwDescription'), isOpenChamber: true }]
: []
),
...(hasSession
? [{ id: 'openchamber:summary', name: 'summary', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.summaryDescription'), isOpenChamber: true }]
: []
@@ -376,6 +385,7 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
const isSystem = command.isBuiltIn;
const isOpenChamberBadge = command.isOpenChamber;
return (
<AutocompleteRowTooltip description={command.description} active={!isMobile && index === selectedIndex}>
<div
key={command.id}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -471,13 +481,9 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
</span>
)}
</div>
{command.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{command.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{commands.length === 0 && (
+28 -12
View File
@@ -3,17 +3,29 @@ import { cn } from '@/lib/utils';
import { getLanguageFromExtension } from '@/lib/toolHelpers';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import { getMarkdownSyntaxVars } from '@/components/chat/markdown/markdownSyntaxVars';
import { useWorkerHighlightedLines } from '@/components/code/useWorkerHighlightedLines';
import {
useWorkerHighlightedLines,
type WorkerHighlightedLinesResult,
} from '@/components/code/useWorkerHighlightedLines';
import { parseDiffToUnified } from './message/toolRenderers';
// One highlighted line: swaps in worker-tokenized inner HTML when ready, falls
// back to plain text while loading or on failure.
const CodeLineContent: React.FC<{ content: string; html: string | undefined }> = ({ content, html }) =>
html !== undefined ? (
<span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />
) : (
<span className="whitespace-pre-wrap break-all">{content}</span>
);
// Keep the line's layout stable while a cold worker request finishes. Plain
// text appears only if highlighting fails, avoiding a visible color flash.
interface CodeLineContentProps {
content: string;
html: string | undefined;
status: WorkerHighlightedLinesResult['status'];
}
const CodeLineContent: React.FC<CodeLineContentProps> = ({ content, html, status }) => {
if (status === 'ready' && html !== undefined) {
return <span className="whitespace-pre-wrap break-all" dangerouslySetInnerHTML={{ __html: html }} />;
}
if (status === 'loading') {
return <span aria-hidden className="invisible whitespace-pre-wrap break-all">{content}</span>;
}
return <span className="whitespace-pre-wrap break-all">{content}</span>;
};
interface DiffPreviewProps {
diff: string;
@@ -44,7 +56,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
<div>
{hunk.lines.map((line, lineIdx) => {
const html = highlighted?.[lineCursor];
const html = highlighted.lines?.[lineCursor];
lineCursor += 1;
return (
<div
@@ -67,7 +79,7 @@ export const DiffPreview: React.FC<DiffPreviewProps> = ({ diff, filePath }) => {
{line.lineNumber || ''}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line.content} html={html} />
<CodeLineContent content={line.content} html={html} status={highlighted.status} />
</div>
</div>
);
@@ -106,7 +118,11 @@ export const WritePreview: React.FC<WritePreviewProps> = ({ content, filePath })
{lineIdx + 1}
</span>
<div className="flex-1 min-w-0">
<CodeLineContent content={line || ' '} html={highlighted?.[lineIdx]} />
<CodeLineContent
content={line || ' '}
html={highlighted.lines?.[lineIdx]}
status={highlighted.status}
/>
</div>
</div>
))}
@@ -14,6 +14,7 @@ import { useFilesViewShowGitignored } from '@/lib/filesViewShowGitignored';
import { useI18n } from '@/lib/i18n';
import { useUIStore } from '@/stores/useUIStore';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
type FileInfo = ProjectFileSearchHit;
type AgentInfo = {
@@ -80,7 +81,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
const measureRefs = React.useRef<(HTMLSpanElement | null)[]>([]);
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true);
const normalizedSearchQuery = (searchQuery ?? '').trim();
const recentFiles = React.useMemo(() => {
if (!projectRoot || !projectTabs) {
@@ -458,6 +459,7 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
{visibleAgents.map((agent, index) => {
const isSelected = selectedIndex === index;
return (
<AutocompleteRowTooltip description={agent.description} active={!isMobile && isSelected}>
<div
key={`agent-${agent.name}`}
ref={(el) => { itemRefs.current[index] = el; }}
@@ -470,11 +472,9 @@ export const FileMentionAutocomplete = React.forwardRef<FileMentionHandle, FileM
>
<div className="min-w-0 flex-1">
<div className="font-semibold truncate">@{agent.name}</div>
{agent.description && !isMobile ? (
<div className="typography-meta text-muted-foreground truncate">{agent.description}</div>
) : null}
</div>
</div>
</AutocompleteRowTooltip>
);
})}
{visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && (
@@ -4,11 +4,12 @@ import { renderMermaidASCII, renderMermaidSVG } from 'beautiful-mermaid';
import type { Part } from '@opencode-ai/sdk/v2';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { isExternalHttpUrl, openExternalUrl } from '@/lib/url';
import { openExternalUrl } from '@/lib/url';
import { useOptionalThemeSystem } from '@/contexts/useThemeSystem';
import { getDefaultTheme } from '@/lib/theme/themes';
import type { Theme } from '@/types/theme';
import { openAppLinkWithConfirmation } from './appLinkConfirmation';
import { attachAppLinkInteractions } from './appLinkInteractions';
import type { ToolPopupContent } from './message/types';
import { FadeInOnReveal } from './message/FadeInOnReveal';
import { useUIStore } from '@/stores/useUIStore';
@@ -42,6 +43,7 @@ import {
parseFileReference,
type ParsedFileReference,
} from './fileReferenceParser';
import { fileReferenceExists } from './fileReferenceStat';
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
const useCurrentMermaidTheme = () => {
@@ -55,7 +57,7 @@ const useCurrentMermaidTheme = () => {
: fallbackLight);
};
const useExternalLinkInteractions = ({
const useLinkInteractions = ({
containerRef,
enabled,
}: {
@@ -63,48 +65,16 @@ const useExternalLinkInteractions = ({
enabled?: boolean;
}) => {
React.useEffect(() => {
if (enabled === false) {
return;
}
const container = containerRef.current;
if (!container) {
return;
}
const handleClick = (event: MouseEvent) => {
if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) {
return;
}
const target = event.target;
if (!(target instanceof Element)) {
return;
}
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) {
return;
}
if (anchor.getAttribute('data-openchamber-file-link') === 'true') {
return;
}
const href = anchor.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) {
return;
}
event.preventDefault();
event.stopPropagation();
void openExternalUrl(href);
};
container.addEventListener('click', handleClick);
return () => {
container.removeEventListener('click', handleClick);
};
return attachAppLinkInteractions(container, {
allowExternalHttp: enabled !== false,
openAppLink: (href) => void openAppLinkWithConfirmation(href),
openExternalHttp: (href) => void openExternalUrl(href),
});
}, [containerRef, enabled]);
};
@@ -151,19 +121,9 @@ const CODE_BLOCK_PATH_SCANNED_ATTR = 'data-openchamber-block-paths-scanned';
// output. The regex is defined in `./fileReferenceParser`; the inline-code
// pipeline reads full text content rather than using this regex.
const MAX_BLOCK_CODE_SCAN_LENGTH = 200_000;
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_LINK_LIMIT = 80;
const VSCODE_FILE_REFERENCE_LINK_LIMIT = 40;
const FILE_REFERENCE_ANNOTATION_DELAY_MS = 160;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
const getFileReferenceLinkLimit = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_LINK_LIMIT : FILE_REFERENCE_LINK_LIMIT
@@ -361,61 +321,6 @@ const getResolvedReference = (rawValue: string, effectiveDirectory: string): (Pa
};
};
const fileReferenceExists = (resolvedPath: string): Promise<boolean> => {
const normalizedPath = normalizePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cached = FILE_REFERENCE_STAT_CACHE.get(normalizedPath);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(normalizedPath);
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(normalizedPath, request);
return request;
};
const getContextDirectory = (effectiveDirectory: string, resolvedPath: string): string => {
return effectiveDirectory || getDirectoryForFilePath(effectiveDirectory, resolvedPath);
};
@@ -521,7 +426,7 @@ const useFileReferenceInteractions = ({
&& !isFilePathWithinDirectory(resolved.resolvedPath, effectiveDirectory);
const existsPromise = canGrantOutsideFile
? Promise.resolve(true)
: fileReferenceExists(resolved.resolvedPath);
: fileReferenceExists(resolved.resolvedPath, effectiveDirectory);
void existsPromise.then((exists) => {
if (cancelled || !exists || !container.contains(candidate)) {
@@ -1034,7 +939,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences && !isStreaming,
});
useExternalLinkInteractions({ containerRef });
useLinkInteractions({ containerRef });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
@@ -1085,6 +990,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
content: string;
className?: string;
variant?: MarkdownVariant;
// App links remain confirmed even where ordinary HTTP link handling is off.
disableLinkSafety?: boolean;
stripFrontmatter?: boolean;
onShowPopup?: (content: ToolPopupContent) => void;
@@ -1126,7 +1032,7 @@ const SimpleMarkdownRendererImpl: React.FC<{
preferRuntimeEditor: runtime.isVSCode,
enabled: enableFileReferences,
});
useExternalLinkInteractions({ containerRef, enabled: !disableLinkSafety });
useLinkInteractions({ containerRef, enabled: !disableLinkSafety });
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
const ctx = useDecorateContext(currentTheme, false, undefined, mermaidControls);
@@ -57,6 +57,28 @@ type MobileVariantTarget = { providerId: string; modelId: string };
const buildModelRefKey = (providerID: string, modelID: string) => `${providerID}:${modelID}`;
const MAX_INLINE_MOBILE_VARIANT_OPTIONS = 6;
const AgentDescriptionTooltip: React.FC<{
description?: string;
children: React.ReactElement;
}> = ({ description, children }) => {
if (!description) {
return children;
}
return (
<Tooltip delayDuration={450}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<span className="typography-meta text-muted-foreground">{description}</span>
</TooltipContent>
</Tooltip>
);
};
const asPermissionRuleset = (value: unknown): PermissionRule[] | null => {
if (!Array.isArray(value)) {
return null;
@@ -893,25 +915,29 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
? useSelectionStore.getState().getSessionAgentSelection(currentSessionId)
: null;
if (savedAgentName) {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
const savedModel = getAgentModelForSession(currentSessionId, savedAgentName);
if (savedModel) {
const result = tryApplyModelSelection(savedModel.providerId, savedModel.modelId, savedAgentName);
if (result === 'applied') {
if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
return 'waiting';
}
} else if (currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
}
if (savedSessionModel) {
const result = tryApplyModelSelection(savedSessionModel.providerId, savedSessionModel.modelId, savedAgentName || currentAgentName || undefined);
if (result === 'applied') {
if (savedAgentName && currentAgentName !== savedAgentName) {
setAgent(savedAgentName);
}
return 'resolved';
}
if (result === 'provider-missing') {
@@ -925,16 +951,15 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
continue;
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
if (currentAgentName !== agent.name) {
setAgent(agent.name);
}
const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current;
if (!existingSelection) {
saveSessionAgentSelection(currentSessionId, agent.name);
}
const result = tryApplyModelSelection(selection.providerId, selection.modelId, agent.name);
if (result === 'applied') {
return 'resolved';
}
if (result === 'provider-missing') {
@@ -2316,6 +2341,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent
side="top"
className="w-[min(380px,calc(100vw-2rem))] p-0 flex flex-col"
align="end"
alignOffset={-40}
@@ -2618,7 +2644,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(180px,calc(100vw-2rem))]">
<DropdownMenuLabel className="typography-ui-header font-semibold text-foreground">{t('chat.modelControls.thinking')}</DropdownMenuLabel>
<DropdownMenuItem className="typography-meta" onSelect={() => handleVariantSelect(undefined)}>
<div className="flex items-center justify-between gap-2 w-full min-w-0">
@@ -2708,7 +2734,7 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
</DropdownMenuTrigger>
</TooltipTrigger>
<DropdownMenuContent align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<DropdownMenuContent side="top" align="end" alignOffset={-40} className="w-[min(280px,calc(100vw-2rem))] p-0 flex flex-col">
<div className="p-2 border-b border-border/40">
<div className="relative">
<Icon name="search" className="absolute left-2.5 top-1/2 -translate-y-1/2 size-3.5 text-muted-foreground" />
@@ -2746,12 +2772,11 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
</div>
) : (
sortedAndFilteredAgents.map((agent) => (
<AgentDescriptionTooltip key={agent.name} description={agent.description}>
<DropdownMenuItem
key={agent.name}
className="typography-meta"
onSelect={() => handleAgentChange(agent.name)}
>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-1.5">
<div className={cn(
'h-1 w-1 rounded-full agent-dot',
@@ -2759,13 +2784,8 @@ export const ModelControls: React.FC<ModelControlsProps> = ({
)} />
<span className="font-medium">{capitalizeAgentName(agent.name)}</span>
</div>
{agent.description && (
<span className="typography-meta text-muted-foreground max-w-[200px] ml-2.5 break-words">
{agent.description}
</span>
)}
</div>
</DropdownMenuItem>
</AgentDescriptionTooltip>
))
)}
</div>
@@ -4,6 +4,7 @@ import { useSkillsStore } from '@/stores/useSkillsStore';
import { useUIStore } from '@/stores/useUIStore';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
import { AutocompleteRowTooltip } from './composer/ui/AutocompleteRowTooltip';
interface SkillInfo {
name: string;
@@ -31,7 +32,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
}, ref) => {
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const keyboardNavigationRef = React.useRef(false);
@@ -126,6 +127,7 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
const isProject = skill.scope === 'project';
const source = skill.source || 'opencode';
return (
<AutocompleteRowTooltip description={skill.description} active={!isMobile && index === selectedIndex}>
<div
key={`${skill.name}-${skill.scope}`}
ref={(el) => {
@@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef<SkillAutocompleteHandle, Skill
{source}
</span>
</div>
{skill.description && !isMobile && (
<div className="typography-meta text-muted-foreground mt-0.5 truncate">
{skill.description}
</div>
)}
</div>
</div>
</AutocompleteRowTooltip>
);
};
@@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef<SnippetAutocompleteHandle, S
const { t } = useI18n();
const containerRef = React.useRef<HTMLDivElement | null>(null);
const isMobile = useUIStore((state) => state.isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, isMobile);
const mobileMaxHeight = useMobileAutocompleteMaxHeight(containerRef, true, 240);
const [selectedIndex, setSelectedIndex] = React.useState(0);
const selectedIndexRef = React.useRef(0);
const [filteredSnippets, setFilteredSnippets] = React.useState<Snippet[]>([]);
+11 -5
View File
@@ -306,13 +306,19 @@ export const StatusRow: React.FC<StatusRowProps> = ({
return (
<div
// Mobile: breathing room between the last message and the agent status
// line — without it the "<model> is running…" row sits flush against
// the message above.
className={cn("mb-1", isMobile && "mt-2", !hasLeftAccessory && "chat-column")}
// This row must land exactly where the assistant turn footer (mt-2
// inside the message) appears when the turn completes. Measured against
// the live DOM: the gap ABOVE already matches (message pb-2 = footer
// mt-2 = 8px), but the chat is bottom-anchored and the finished message
// carries ~12px more structure BELOW its footer than this row has — so
// the swap used to lift the line up. mb-6 (24px) reserves that space
// under this row instead (verified: row top 636 == footer top 636).
className={cn("mb-6", !hasLeftAccessory && "chat-column")}
style={STATUS_ROW_CONTAINER_STYLE}
>
<div className={cn("flex items-center justify-between py-0.5 gap-2 h-[1.2rem]", hasLeftAccessory && "px-0.5")}>
{/* h-8 matches the turn footer's real row height: its h-8 action
buttons define the footer line, with the meta text centered in it. */}
<div className={cn("flex items-center justify-between gap-2 h-8", hasLeftAccessory && "px-0.5")}>
{/* Left: Abort status | Working placeholder | leftAccessory */}
<div className={cn("flex-1 flex items-center min-w-0 gap-2", hasLeftAccessory ? "pl-1.5" : "overflow-x-hidden")}>
{showAssistantStatus && showAbortStatus ? (
@@ -226,20 +226,23 @@ describe('issue #2903 busy embedded subagent status-line-only', () => {
expect(chatContainerSource).toContain('void ensureSessionRenderable(currentSessionId);');
});
test('empty+busy branch skips empty state so StatusRowContainer can stand alone', () => {
test('the empty and idle branch leaves the status row to the busy path', () => {
// A busy session with no messages yet must fall through to the viewport so
// StatusRowContainer is the only thing on screen. The idle branch returns
// before it and must not render one of its own. The empty state itself no
// longer lives here: the draft surface owns it since the draft transition
// animation landed.
expect(chatContainerSource).toContain('if (sessionMessages.length === 0 && !sessionIsWorking)');
expect(chatContainerSource).toContain('<ChatEmptyState');
expect(chatContainerSource).toContain('<StatusRowContainer />');
const emptyBusyGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyStateReturn = chatContainerSource.indexOf(emptyBusyGuard);
expect(emptyStateReturn).toBeGreaterThan(-1);
const emptyStateBlock = chatContainerSource.slice(
emptyStateReturn,
emptyStateReturn + 1600,
const emptyIdleGuard = 'if (sessionMessages.length === 0 && !sessionIsWorking)';
const emptyIdleReturn = chatContainerSource.indexOf(emptyIdleGuard);
expect(emptyIdleReturn).toBeGreaterThan(-1);
const emptyIdleBlock = chatContainerSource.slice(
emptyIdleReturn,
emptyIdleReturn + 1600,
);
expect(emptyStateBlock).toContain('<ChatEmptyState');
expect(emptyStateBlock).not.toContain('<StatusRowContainer />');
expect(emptyIdleBlock).not.toContain('<StatusRowContainer />');
});
test('visibility handshake remains as defense-in-depth for background work', () => {
@@ -0,0 +1,33 @@
/**
* Regression coverage for https://github.com/openchamber/openchamber/issues/3036.
*
* Restoring persisted agent/model pairs used to switch agents before checking
* whether each model still existed. Several stale pairs could therefore keep
* changing the active agent on every effect pass until React hit its nested
* update limit. The API error belongs in the assistant message; an invalid
* persisted pair must not mutate the current selection while it is rendered.
*/
import { describe, expect, test } from 'bun:test';
import { readFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
const __dirname = dirname(fileURLToPath(import.meta.url));
const modelControlsSource = readFileSync(join(__dirname, '..', 'ModelControls.tsx'), 'utf-8');
describe('issue #3036 stale persisted models', () => {
test('changes the agent only after its persisted model is accepted', () => {
const candidateLoop = modelControlsSource.slice(
modelControlsSource.indexOf('for (const agent of agents)'),
modelControlsSource.indexOf("return 'continue';"),
);
const applyIndex = candidateLoop.indexOf('const result = tryApplyModelSelection');
const acceptedIndex = candidateLoop.indexOf("if (result === 'applied')");
const setAgentIndex = candidateLoop.indexOf('setAgent(agent.name)');
expect(applyIndex).toBeGreaterThanOrEqual(0);
expect(acceptedIndex).toBeGreaterThan(applyIndex);
expect(setAgentIndex).toBeGreaterThan(acceptedIndex);
});
});
@@ -0,0 +1,67 @@
import { beforeEach, describe, expect, test } from 'bun:test';
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import {
getAppLinkConfirmationSnapshot,
openAppLinkWithConfirmation,
settleAppLinkConfirmation,
} from './appLinkConfirmation';
describe('app link confirmation', () => {
beforeEach(() => {
useAppLinkTrustStore.setState({ trustedSchemes: [] });
const pending = getAppLinkConfirmationSnapshot();
if (pending) {
settleAppLinkConfirmation('cancel');
}
});
test('opens trusted schemes without asking', async () => {
useAppLinkTrustStore.getState().trustScheme('obsidian');
await openAppLinkWithConfirmation('obsidian://open?vault=Notebook&file=notes');
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('obsidian')).toBe(true);
});
test('asks once and trusts the scheme when the user chooses trust', async () => {
const pending = openAppLinkWithConfirmation('linear://issue/ABC-1');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://issue/ABC-1');
settleAppLinkConfirmation('trust');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('linear')).toBe(true);
});
test('cancel opens nothing and keeps the scheme untrusted', async () => {
const pending = openAppLinkWithConfirmation('notion://note/xyz');
settleAppLinkConfirmation('cancel');
await pending;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
expect(useAppLinkTrustStore.getState().isSchemeTrusted('notion')).toBe(false);
});
test('a newer request cancels the pending one', async () => {
const first = openAppLinkWithConfirmation('obsidian://open?vault=a');
const firstChoice = first.then(
() => 'settled',
() => 'settled',
);
const second = openAppLinkWithConfirmation('linear://open/1');
expect(await firstChoice).toBe('settled');
expect(getAppLinkConfirmationSnapshot()?.url).toBe('linear://open/1');
settleAppLinkConfirmation('open');
await second;
expect(getAppLinkConfirmationSnapshot()).toBeNull();
});
});
@@ -0,0 +1,71 @@
import { useAppLinkTrustStore } from '@/stores/appLinkTrustStore';
import { getUrlScheme, openConfirmedAppLinkUrl } from '@/lib/url';
export type AppLinkConfirmationChoice = 'open' | 'trust' | 'cancel';
type PendingAppLinkRequest = {
url: string;
resolve: (choice: AppLinkConfirmationChoice) => void;
};
let pendingRequest: PendingAppLinkRequest | null = null;
const listeners = new Set<() => void>();
const emitChange = (): void => {
for (const listener of listeners) {
listener();
}
};
const getSnapshot = (): PendingAppLinkRequest | null => pendingRequest;
const subscribe = (listener: () => void): (() => void) => {
listeners.add(listener);
return () => {
listeners.delete(listener);
};
};
/**
* Ask the user (via the app-level confirmation dialog) whether an application
* deep link may be opened. Resolves immediately when the scheme was trusted
* earlier. Only one request is active at a time; a new request cancels the
* pending one.
*/
export const openAppLinkWithConfirmation = (url: string): Promise<void> => {
const scheme = getUrlScheme(url);
if (!scheme) {
return Promise.resolve();
}
const trustStore = useAppLinkTrustStore.getState();
if (trustStore.isSchemeTrusted(scheme)) {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
if (pendingRequest) {
pendingRequest.resolve('cancel');
}
return new Promise<AppLinkConfirmationChoice>((resolve) => {
pendingRequest = { url, resolve };
emitChange();
}).then((choice) => {
if (choice === 'trust') {
useAppLinkTrustStore.getState().trustScheme(scheme);
}
if (choice === 'open' || choice === 'trust') {
return openConfirmedAppLinkUrl(url).then(() => undefined);
}
});
};
export const settleAppLinkConfirmation = (choice: AppLinkConfirmationChoice): void => {
const request = pendingRequest;
pendingRequest = null;
emitChange();
request?.resolve(choice);
};
export const subscribeAppLinkConfirmation = subscribe;
export const getAppLinkConfirmationSnapshot = getSnapshot;
@@ -0,0 +1,93 @@
import { describe, expect, test } from 'bun:test';
import { attachAppLinkInteractions } from './appLinkInteractions';
const TestElement = class Element {};
const TestHTMLAnchorElement = class HTMLAnchorElement extends TestElement {};
Object.assign(globalThis, { Element: TestElement, HTMLAnchorElement: TestHTMLAnchorElement });
class TestAnchor extends HTMLAnchorElement {
constructor(private readonly rawHref: string) {
super();
}
getAttribute(name: string): string | null {
return name === 'href' ? this.rawHref : null;
}
closest(): TestAnchor {
return this;
}
}
class TestContainer {
listeners = new Map<string, EventListener>();
addEventListener(name: string, listener: (event: MouseEvent) => void): void {
// SAFETY: dispatch constructs every mouse field read by the production listener.
this.listeners.set(name, (event) => listener(event as MouseEvent));
}
removeEventListener(name: string, listener: (event: MouseEvent) => void): void {
void listener;
this.listeners.delete(name);
}
dispatch(name: string, href: string, init: Partial<MouseEvent> = {}): Event {
const event = new Event(name, { cancelable: true });
Object.defineProperties(event, {
target: { value: new TestAnchor(href) },
button: { value: init.button ?? 0 },
metaKey: { value: init.metaKey ?? false },
ctrlKey: { value: init.ctrlKey ?? false },
altKey: { value: init.altKey ?? false },
shiftKey: { value: init.shiftKey ?? false },
});
this.listeners.get(name)?.(event);
return event;
}
}
const setup = (allowExternalHttp = true) => {
const container = new TestContainer();
const appLinks: string[] = [];
const httpLinks: string[] = [];
const cleanup = attachAppLinkInteractions(container, {
allowExternalHttp,
openAppLink: (url) => appLinks.push(url),
openExternalHttp: (url) => httpLinks.push(url),
});
return { container, appLinks, httpLinks, cleanup };
};
describe('app link interactions', () => {
test('confirms plain, modifier, and middle-click activations', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('click', href).defaultPrevented).toBe(true);
expect(container.dispatch('click', href, { metaKey: true }).defaultPrevented).toBe(true);
expect(container.dispatch('auxclick', href, { button: 1 }).defaultPrevented).toBe(true);
expect(appLinks).toEqual([href, href, href]);
});
test('blocks drag activation without opening immediately', () => {
const { container, appLinks } = setup();
const href = 'obsidian://open?vault=Notes';
expect(container.dispatch('dragstart', href).defaultPrevented).toBe(true);
expect(appLinks).toEqual([]);
});
test('keeps HTTP modifier behavior and the disabled HTTP path unchanged', () => {
const enabled = setup();
const disabled = setup(false);
const href = 'https://example.com';
expect(enabled.container.dispatch('click', href, { ctrlKey: true }).defaultPrevented).toBe(false);
expect(enabled.container.dispatch('click', href).defaultPrevented).toBe(true);
expect(disabled.container.dispatch('click', href).defaultPrevented).toBe(false);
expect(enabled.httpLinks).toEqual([href]);
expect(disabled.httpLinks).toEqual([]);
});
});
@@ -0,0 +1,75 @@
import { isAppLinkUrl, isExternalHttpUrl } from '@/lib/url';
type AppLinkInteractionOptions = {
allowExternalHttp: boolean;
openAppLink: (url: string) => void;
openExternalHttp: (url: string) => void;
};
type LinkInteractionContainer = {
addEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
removeEventListener: (type: string, listener: (event: MouseEvent) => void) => void;
};
const findLink = (event: MouseEvent | DragEvent): HTMLAnchorElement | null => {
const target = event.target;
if (!(target instanceof Element)) return null;
const anchor = target.closest('a[href]');
if (!(anchor instanceof HTMLAnchorElement)) return null;
if (anchor.getAttribute('data-openchamber-file-link') === 'true') return null;
return anchor;
};
const interceptAppLink = (
event: MouseEvent | DragEvent,
openAppLink?: (url: string) => void,
): boolean => {
if (event.defaultPrevented) return false;
const anchor = findLink(event);
const href = anchor?.getAttribute('href') ?? '';
if (!isAppLinkUrl(href)) return false;
event.preventDefault();
event.stopPropagation();
openAppLink?.(href);
return true;
};
const isPlainPrimaryClick = (event: MouseEvent): boolean => (
event.button === 0
&& !event.metaKey
&& !event.ctrlKey
&& !event.altKey
&& !event.shiftKey
);
export const attachAppLinkInteractions = (
container: LinkInteractionContainer,
options: AppLinkInteractionOptions,
): (() => void) => {
const handleClick = (event: MouseEvent) => {
if (interceptAppLink(event, options.openAppLink)) return;
if (!options.allowExternalHttp || event.defaultPrevented || !isPlainPrimaryClick(event)) return;
const href = findLink(event)?.getAttribute('href') ?? '';
if (!isExternalHttpUrl(href)) return;
event.preventDefault();
event.stopPropagation();
options.openExternalHttp(href);
};
const handleAuxClick = (event: MouseEvent) => {
if (event.button === 1) interceptAppLink(event, options.openAppLink);
};
const blockAlternateAppLinkActivation = (event: MouseEvent | DragEvent) => {
interceptAppLink(event);
};
container.addEventListener('click', handleClick);
container.addEventListener('auxclick', handleAuxClick);
container.addEventListener('dragstart', blockAlternateAppLinkActivation);
return () => {
container.removeEventListener('click', handleClick);
container.removeEventListener('auxclick', handleAuxClick);
container.removeEventListener('dragstart', blockAlternateAppLinkActivation);
};
};
@@ -0,0 +1,481 @@
import React from 'react';
import type { Message, Part } from '@opencode-ai/sdk/v2';
import { useI18n } from '@/lib/i18n';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
import { Button } from '@/components/ui/button';
import { Icon } from '@/components/icon/Icon';
import { useBtwStore } from '@/stores/useBtwStore';
import { useSync } from '@/sync/use-sync';
import {
useSessionMessageRecords,
useSessionRenderable,
useSessionStatus,
useScopedBlockingPermissions,
useScopedBlockingQuestions,
} from '@/sync/sync-context';
import { useStreamingStore } from '@/sync/streaming';
import { ScrollShadow } from '@/components/ui/ScrollShadow';
import { destroyBtwSession, filterBtwTailMessages, promoteBtwSession, type BtwSessionRef } from '@/lib/btw';
import type { BtwPanelState } from './useBtwPanelState';
import { ChatSurfaceProvider } from '../ChatSurfaceContext';
import { useMobileAutocompleteMaxHeight } from '../useMobileAutocompleteMaxHeight';
import ChatMessage from '../ChatMessage';
import { PermissionCard } from '../PermissionCard';
import { QuestionCard } from '../QuestionCard';
const IDLE_SESSION_STATUS = { type: 'idle' as const };
/** Stable no-op so ChatMessage memoization keeps working in the read-only peek. */
const NOOP_CONTENT_CHANGE = (): void => {};
/**
* The `/btw` peek panel.
*
* Rendered from inside the composer form, so the sheet docks exactly above
* the main composer (`absolute bottom-full` on the composer column) on both
* desktop and mobile the main composer IS the btw input, so nothing may
* cover it. Identity is derived from the parent session's metadata (see
* `useBtwPanelState`), so the panel belongs to one parent session only.
*
* Three exits: collapse (panel minimizes to the composer chip, the composer
* returns to the main session), promote (the fork becomes a normal session
* and the app navigates to it), destroy (the fork is deleted; the main
* conversation is never touched).
*/
export const BtwPanel: React.FC<{ parentSessionId: string; panel: BtwPanelState }> = ({
parentSessionId,
panel,
}) => {
const { t } = useI18n();
if (panel.btwSessionId && panel.btwDirectory) {
return (
<BtwSheet
sessionRef={{
parentSessionId,
btwSessionId: panel.btwSessionId,
directory: panel.btwDirectory,
}}
title={panel.btwSession?.title?.trim() || t('chat.btw.titleFallback')}
boundaryMessageID={panel.boundaryMessageID}
collapsed={panel.collapsed}
/>
);
}
if (panel.creating) {
return (
<BtwFrame title={t('chat.btw.titleFallback')}>
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
</BtwFrame>
);
}
return null;
};
const useBtwDestroy = (sessionRef: BtwSessionRef | null): (() => void) => {
const { t } = useI18n();
return React.useCallback(() => {
if (!sessionRef) return;
void destroyBtwSession(sessionRef).then((ok) => {
if (!ok) toast.error(t('chat.btw.toast.destroyFailed'));
});
}, [sessionRef, t]);
};
type BtwSessionData = {
messageRecords: Array<{ info: Message; parts: Part[] }>;
sessionIsWorking: boolean;
streamingMessageId: string | null;
activeStreamingPhase: 'streaming' | 'cooldown' | 'completed' | null;
sessionPermissions: ReturnType<typeof useScopedBlockingPermissions>;
sessionQuestions: ReturnType<typeof useScopedBlockingQuestions>;
isEmpty: boolean;
};
/**
* Live session data for the fork, all keyed by the fork's own ids. Only the
* fork's tail (messages after the inherited-history boundary) is shown.
*/
const useBtwSessionData = (
sessionId: string,
directory: string,
boundaryMessageID: string | null,
): BtwSessionData => {
const sync = useSync();
const renderable = useSessionRenderable(sessionId, directory);
React.useEffect(() => {
if (!renderable) {
void sync.ensureSessionRenderable(sessionId, false, directory);
}
}, [directory, renderable, sessionId, sync]);
const messageRecords = useSessionMessageRecords(sessionId, directory);
const status = useSessionStatus(sessionId, directory) ?? IDLE_SESSION_STATUS;
const streamingMessageId = useStreamingStore(
React.useCallback((s) => s.streamingMessageIds.get(sessionId) ?? null, [sessionId]),
);
const activeStreamingPhase = useStreamingStore(
React.useCallback(
(s) => (streamingMessageId ? s.messageStreamStates.get(streamingMessageId)?.phase ?? null : null),
[streamingMessageId],
),
);
const sessionPermissions = useScopedBlockingPermissions(sessionId, directory);
const sessionQuestions = useScopedBlockingQuestions(sessionId, directory);
const tailRecords = React.useMemo(
() => filterBtwTailMessages(messageRecords, boundaryMessageID),
[boundaryMessageID, messageRecords],
);
const sessionIsWorking = React.useMemo(() => {
if (sessionPermissions.length > 0 || sessionQuestions.length > 0) {
return false;
}
const statusType = status.type ?? 'idle';
if (statusType === 'busy' || statusType === 'retry') {
return true;
}
// SAFETY: reads only the optional `time.completed` field, which the
// SDK Message union does not expose uniformly; a missing value means
// the assistant turn has not completed.
const lastMessage = tailRecords[tailRecords.length - 1]?.info as (Message & { time?: { completed?: number } }) | undefined;
return Boolean(
lastMessage
&& lastMessage.role === 'assistant'
&& typeof lastMessage.time?.completed !== 'number',
);
}, [sessionPermissions.length, sessionQuestions.length, status.type, tailRecords]);
return {
messageRecords: tailRecords,
sessionIsWorking,
streamingMessageId,
activeStreamingPhase,
sessionPermissions,
sessionQuestions,
isEmpty: tailRecords.length === 0,
};
};
/** Esc collapses the sheet (never destroys) unless focus is in a text field. */
const useEscapeToCollapse = (onCollapse: () => void): void => {
React.useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key !== 'Escape') return;
// SAFETY: keydown targets are DOM elements (or null on window).
const target = event.target as HTMLElement | null;
if (target && (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable)) {
return;
}
onCollapse();
};
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [onCollapse]);
};
/**
* Stick-to-bottom auto-scroll. Streaming grows content inside one message
* without changing the record count, so following the tail needs a
* ResizeObserver on the content wrapper data-driven effects alone would
* stop following mid-stream.
*/
const useAutoScroll = (
bodyRef: React.RefObject<HTMLDivElement | null>,
contentRef: React.RefObject<HTMLDivElement | null>,
contentReady: boolean,
): ((event: React.UIEvent<HTMLDivElement>) => void) => {
const stickToBottomRef = React.useRef(true);
// `contentReady` is a dependency because the refs are only attached once
// the empty state gives way to the message list; an effect keyed on the
// refs alone would run against `null` and never re-attach the observer.
React.useEffect(() => {
if (!contentReady) return;
const content = contentRef.current;
const element = bodyRef.current;
if (element && stickToBottomRef.current) {
element.scrollTop = element.scrollHeight;
}
if (!content || typeof ResizeObserver === 'undefined') return;
const observer = new ResizeObserver(() => {
const body = bodyRef.current;
if (body && stickToBottomRef.current) {
body.scrollTop = body.scrollHeight;
}
});
observer.observe(content);
return () => observer.disconnect();
}, [bodyRef, contentReady, contentRef]);
return React.useCallback((event: React.UIEvent<HTMLDivElement>) => {
const element = event.currentTarget;
stickToBottomRef.current = element.scrollHeight - element.scrollTop - element.clientHeight < 80;
}, []);
};
const BtwFrame: React.FC<{
title: string;
actions?: React.ReactNode;
onTitleClick?: () => void;
titleClickLabel?: string;
collapsed?: boolean;
headerSpinner?: boolean;
children?: React.ReactNode;
}> = ({ title, actions, onTitleClick, titleClickLabel, collapsed, headerSpinner, children }) => (
<div
className="chat-input-column absolute bottom-full left-0 right-0 z-30 mb-3"
role="dialog"
aria-label="btw"
>
<div className="oc-glass-popover oc-glass-floating w-full overflow-hidden rounded-xl">
<div className="flex items-center gap-2 px-3 py-1.5">
{onTitleClick ? (
<button
type="button"
onClick={onTitleClick}
aria-label={titleClickLabel}
title={titleClickLabel}
className="flex min-w-0 items-center gap-2 text-left text-muted-foreground transition-colors hover:text-foreground"
>
{headerSpinner ? (
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin" />
) : (
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
)}
<span className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</span>
<Icon name={collapsed ? 'arrow-up-s' : 'arrow-down-s'} className="size-4 shrink-0" />
</button>
) : (
<span className="flex min-w-0 items-center gap-2 text-muted-foreground">
<Icon name="chat-ai-3" className="size-3.5 shrink-0" />
<h2 className="typography-ui-label min-w-0 truncate font-semibold">
{title}
</h2>
</span>
)}
<div className="min-w-0 flex-1" />
{actions}
</div>
{children ? (
<>
{children}
<div className="h-2" />
</>
) : null}
</div>
</div>
);
const BtwSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
collapsed: boolean;
}> = ({ sessionRef, title, boundaryMessageID, collapsed }) => {
const { t } = useI18n();
const handleDestroy = useBtwDestroy(sessionRef);
const setCollapsed = React.useCallback((next: boolean) => {
useBtwStore.getState().setPanelState(sessionRef.parentSessionId, { collapsed: next });
}, [sessionRef.parentSessionId]);
const handleToggleCollapsed = React.useCallback(() => setCollapsed(!collapsed), [collapsed, setCollapsed]);
const handleCollapse = React.useCallback(() => setCollapsed(true), [setCollapsed]);
const handlePromote = React.useCallback(() => {
void promoteBtwSession(sessionRef).catch(() => {
toast.error(t('chat.btw.toast.promoteFailed'));
});
}, [sessionRef, t]);
useEscapeToCollapse(handleCollapse);
const toggleLabel = collapsed ? t('chat.btw.expandAria') : t('chat.btw.collapseAria');
const headerButtonClass = 'size-7 rounded-lg text-muted-foreground transition-colors hover:text-foreground hover:!bg-transparent active:!bg-transparent';
const actions = (
<div className="flex shrink-0 items-center gap-0.5">
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handlePromote}
aria-label={t('chat.btw.promoteAria')}
title={t('chat.btw.promoteAria')}
>
<Icon name="external-link" className="size-3.5" />
</Button>
<Button
type="button"
variant="ghost"
size="icon"
className={headerButtonClass}
onClick={handleDestroy}
aria-label={t('chat.btw.destroyAria')}
title={t('chat.btw.destroyAria')}
>
<Icon name="close" className="size-4" />
</Button>
</div>
);
if (collapsed) {
return (
<BtwCollapsedStrip
sessionRef={sessionRef}
title={title}
actions={actions}
onExpand={handleToggleCollapsed}
expandLabel={toggleLabel}
/>
);
}
return (
<BtwExpandedSheet
sessionRef={sessionRef}
title={title}
boundaryMessageID={boundaryMessageID}
actions={actions}
onTitleClick={handleToggleCollapsed}
titleClickLabel={toggleLabel}
/>
);
};
/**
* Collapsed mode: only the header strip stays docked above the composer. The
* fork keeps running in the background; a spinner replaces the header icon
* while it is busy so activity stays visible without the message list.
*/
const BtwCollapsedStrip: React.FC<{
sessionRef: BtwSessionRef;
title: string;
actions: React.ReactNode;
onExpand: () => void;
expandLabel: string;
}> = ({ sessionRef, title, actions, onExpand, expandLabel }) => {
const status = useSessionStatus(sessionRef.btwSessionId, sessionRef.directory) ?? IDLE_SESSION_STATUS;
const isBusy = status.type === 'busy' || status.type === 'retry';
return (
<BtwFrame
title={title}
actions={actions}
onTitleClick={onExpand}
titleClickLabel={expandLabel}
collapsed
headerSpinner={isBusy}
/>
);
};
const BtwExpandedSheet: React.FC<{
sessionRef: BtwSessionRef;
title: string;
boundaryMessageID: string | null;
actions: React.ReactNode;
onTitleClick: () => void;
titleClickLabel: string;
}> = ({ sessionRef, title, boundaryMessageID, actions, onTitleClick, titleClickLabel }) => {
const data = useBtwSessionData(sessionRef.btwSessionId, sessionRef.directory, boundaryMessageID);
const bodyRef = React.useRef<HTMLDivElement | null>(null);
const contentRef = React.useRef<HTMLDivElement | null>(null);
const handleBodyScroll = useAutoScroll(bodyRef, contentRef, !data.isEmpty);
// With the on-screen keyboard open the composer (this panel's anchor)
// rises, and a vh-based cap would push the panel under the app header.
// Same protection as the composer autocomplete popups: clamp the scroll
// body to the space actually available above the anchor. The hook measures
// room for the scroll body itself, but the panel header and bottom spacer
// sit inside the same frame above/below it — reserve their height too.
const BTW_FRAME_CHROME_PX = 48;
const availableMaxHeight = useMobileAutocompleteMaxHeight(bodyRef, true, 520 + BTW_FRAME_CHROME_PX);
const mobileMaxHeight = availableMaxHeight !== undefined
? Math.max(120, availableMaxHeight - BTW_FRAME_CHROME_PX)
: undefined;
return (
<BtwFrame title={title} actions={actions} onTitleClick={onTitleClick} titleClickLabel={titleClickLabel} collapsed={false}>
<ChatSurfaceProvider mode="peek">
<BtwMessages
data={data}
bodyRef={bodyRef}
contentRef={contentRef}
onBodyScroll={handleBodyScroll}
maxHeight={mobileMaxHeight}
/>
</ChatSurfaceProvider>
</BtwFrame>
);
};
const BtwMessages: React.FC<{
data: BtwSessionData;
bodyRef: React.RefObject<HTMLDivElement | null>;
contentRef: React.RefObject<HTMLDivElement | null>;
onBodyScroll: (event: React.UIEvent<HTMLDivElement>) => void;
maxHeight?: number;
}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => {
const { t } = useI18n();
if (data.isEmpty) {
return (
<div className="flex items-center gap-2 px-4 py-4 text-sm text-muted-foreground">
<Icon name="loader-4" className="size-4 animate-spin" />
<span>{t('chat.btw.loading')}</span>
</div>
);
}
return (
<ScrollShadow
ref={bodyRef}
onScroll={onBodyScroll}
size={32}
data-scroll-shadow="true"
className="max-h-[min(55vh,520px)] min-h-0 overflow-y-auto px-3 py-1"
style={maxHeight !== undefined ? { maxHeight } : undefined}
>
<div ref={contentRef}>
{data.messageRecords.map((record, index) => (
<ChatMessage
key={record.info.id}
message={record}
previousMessage={data.messageRecords[index - 1]}
nextMessage={data.messageRecords[index + 1]}
onContentChange={NOOP_CONTENT_CHANGE}
isInActiveTurn={index === data.messageRecords.length - 1}
activeStreamingPhase={
record.info.id === data.streamingMessageId ? data.activeStreamingPhase : null
}
/>
))}
{data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? (
<div>
{data.sessionQuestions.map((question) => (
<QuestionCard key={question.id} question={question} />
))}
{data.sessionPermissions.map((permission) => (
<PermissionCard key={permission.id} permission={permission} />
))}
</div>
) : null}
{/* Always reserve this row so the content does not shift down
by a line when the indicator disappears. */}
<div
className={cn(
'flex items-center gap-2 px-1 py-2 text-xs text-muted-foreground',
!data.sessionIsWorking && 'invisible',
)}
aria-hidden={!data.sessionIsWorking}
>
<Icon name="loader-4" className="size-3.5 animate-spin" />
<span>{t('chat.btw.working')}</span>
</div>
</div>
</ScrollShadow>
);
};
@@ -0,0 +1,54 @@
import React from 'react';
import type { Session } from '@opencode-ai/sdk/v2';
import { useSession } from '@/sync/sync-context';
import { getBtwBoundaryMessageID, getBtwSessionID } from '@/lib/sessionBtwMetadata';
import { useBtwStore } from '@/stores/useBtwStore';
export type BtwPanelState = {
/** The active fork for this parent, or null when no panel should exist. */
btwSessionId: string | null;
btwSession: Session | null;
/** The fork's directory identity (may be canonicalized by the server). */
btwDirectory: string | null;
/** Last message id inherited from the parent; the panel shows what's after it. */
boundaryMessageID: string | null;
collapsed: boolean;
creating: boolean;
};
/**
* Derive the `/btw` panel identity for one parent session from authoritative
* session metadata (`openchamber.btwSessionID`), plus the transient UI state
* kept in `useBtwStore`. The panel exists only while the parent's link AND the
* fork itself are present in the live stores, so a fork deleted anywhere
* (sidebar, another client) makes the panel disappear without extra tracking.
*/
export function useBtwPanelState(
parentSessionId: string | null | undefined,
directory: string | undefined,
): BtwPanelState {
const parentSession = useSession(parentSessionId, directory);
const linkedBtwSessionId = getBtwSessionID(parentSession);
const btwSession = useSession(linkedBtwSessionId, directory) ?? null;
const uiState = useBtwStore(
React.useCallback(
(s) => (parentSessionId ? s.byParent[parentSessionId] : undefined),
[parentSessionId],
),
);
const destroying = Boolean(uiState?.destroying);
const btwSessionId = btwSession && !destroying ? linkedBtwSessionId : null;
return {
btwSessionId,
btwSession: btwSessionId ? btwSession : null,
// SAFETY: the SDK Session type omits the server's `directory` field; this
// widening only reads it, with the parent's directory as the fallback.
btwDirectory: btwSessionId
? ((btwSession as (Session & { directory?: string | null }) | null)?.directory ?? directory ?? null)
: null,
boundaryMessageID: btwSessionId ? getBtwBoundaryMessageID(btwSession) : null,
collapsed: Boolean(uiState?.collapsed),
creating: Boolean(uiState?.creating),
};
}
@@ -1,5 +1,11 @@
import React from 'react';
export type ChatSurfaceMode = 'default' | 'mini-chat';
/**
* 'mini-chat' is the browser-panel side chat (compact, no fork/plan actions).
* 'peek' is a read-only glance surface (the /btw panel): messages render with
* no per-message controls at all no user action row, no assistant action
* buttons, no turn footer.
*/
export type ChatSurfaceMode = 'default' | 'mini-chat' | 'peek';
export const ChatSurfaceContext = React.createContext<ChatSurfaceMode>('default');
@@ -7,6 +7,16 @@ everything between typing and sending.
own state and wires these modules together; it should not grow logic that
belongs to one of them.
`ChatContainer.tsx` keeps one `ChatInput` mounted while a new-session draft
becomes its first session. Draft-only UI first fades for 120ms while the editor
stays in place. The parent then moves the editor to its final session position
with a 180ms transform-only FLIP animation. Reduced-motion mode skips these
transitions. `session-ui-store.ts` marks sessions materialized from a submitted
draft, so selecting an existing session while a draft is open switches without
animation. Do not restore separate draft and session composer branches:
remounting the editor loses focus and interrupts the transition. Keep the
existing mobile fixed-position rules unchanged.
## Layers
| Directory | Owns |
@@ -23,6 +23,8 @@ import { useProjectsStore } from '@/stores/useProjectsStore';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { buildSessionTargetOptions } from '@/sync/session-worktree-contract';
import { normalizePath } from '../attachments/filePaths';
import { CHAT_DRAFT_PROJECT_ID } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
/** How long a cached branch list is served before it is refreshed. */
const BRANCHES_SWR_TTL_MS = 30_000;
@@ -35,6 +37,7 @@ export interface DraftTargetProject {
color?: string | null;
iconImage?: { mime: string; updatedAt: number; source: 'custom' | 'auto' } | null;
iconBackground?: string | null;
kind?: 'chat' | 'project';
}
/** A project's display name, falling back to its directory name. */
@@ -43,7 +46,15 @@ export function getProjectDisplayLabel(project: { label?: string; path: string }
}
export function useDraftTarget(enabled: boolean) {
const projects = useProjectsStore((state) => state.projects) as DraftTargetProject[];
const configuredProjects: readonly DraftTargetProject[] = useProjectsStore((state) => state.projects);
const { t } = useI18n();
const chatProject = React.useMemo<DraftTargetProject>(() => ({
id: CHAT_DRAFT_PROJECT_ID,
path: '',
label: t('layout.mainTab.chat'),
kind: 'chat',
}), [t]);
const projects = React.useMemo(() => [chatProject, ...configuredProjects], [chatProject, configuredProjects]);
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
@@ -53,6 +64,7 @@ export function useDraftTarget(enabled: boolean) {
const { git: runtimeGit } = useRuntimeAPIs();
const selectedDraftProject = React.useMemo(() => {
if (newSessionDraft?.target === 'chat') return chatProject;
const explicit = newSessionDraft?.selectedProjectId
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
: null;
@@ -67,14 +79,16 @@ export function useDraftTarget(enabled: boolean) {
return active;
}
return projects[0] ?? null;
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
return configuredProjects[0] ?? chatProject;
}, [activeProjectId, chatProject, configuredProjects, newSessionDraft?.selectedProjectId, newSessionDraft?.target, projects]);
const selectedDraftProjectPath = React.useMemo(
() => normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.path],
() => selectedDraftProject?.kind === 'chat' ? null : normalizePath(selectedDraftProject?.path ?? null),
[selectedDraftProject?.kind, selectedDraftProject?.path],
);
const draftProjectLabel = selectedDraftProject ? getProjectDisplayLabel(selectedDraftProject) : null;
const draftProjectLabel = selectedDraftProject && selectedDraftProject.kind !== 'chat'
? getProjectDisplayLabel(selectedDraftProject)
: null;
const selectedDraftProjectBranches = useGitBranches(selectedDraftProjectPath);
const selectedDraftProjectBranchesFetchedAt = useGitStore(
@@ -258,6 +272,10 @@ export function useDraftTarget(enabled: boolean) {
if (!project) {
return;
}
if (project.kind === 'chat') {
setNewSessionDraftTarget({ projectId: CHAT_DRAFT_PROJECT_ID, directoryOverride: null }, { force: true });
return;
}
if (activeProjectId !== projectId) {
setActiveProjectIdOnly(projectId);
}
@@ -0,0 +1,40 @@
import React from 'react';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
interface AutocompleteRowTooltipProps {
description?: string;
active: boolean;
children: React.ReactElement;
}
export function AutocompleteRowTooltip({ description, active, children }: AutocompleteRowTooltipProps) {
const [delayedActive, setDelayedActive] = React.useState(false);
React.useEffect(() => {
if (!active || !description) {
setDelayedActive(false);
return;
}
const timeout = window.setTimeout(() => setDelayedActive(true), 200);
return () => window.clearTimeout(timeout);
}, [active, description]);
if (!description) return children;
return (
<Tooltip delayDuration={0} open={active && delayedActive} onOpenChange={() => {}}>
<TooltipTrigger asChild>{children}</TooltipTrigger>
{active && delayedActive ? (
<TooltipContent
side="right"
sideOffset={8}
className="max-w-xs text-left transition-none data-[starting-style]:opacity-100 data-[starting-style]:scale-100 data-[ending-style]:opacity-100 data-[ending-style]:scale-100"
>
<p className="typography-meta whitespace-pre-wrap">{description}</p>
</TooltipContent>
) : null}
</Tooltip>
);
}
@@ -93,7 +93,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment
<Icon name="add-circle" className={cn(iconSizeClass, 'text-current')} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="start">
<DropdownMenuContent side="top" align="start">
<DropdownMenuItem
onSelect={() => {
requestAnimationFrame(handlePickLocalFiles);
@@ -57,7 +57,9 @@ const getProjectIconColor = (projectColor?: string | null): string | undefined =
function ProjectLabel({ project, theme }: { project: DraftTargetProject; theme: Theme }) {
const projectIconName = project.icon ? PROJECT_ICON_MAP[project.icon] : null;
const iconColor = getProjectIconColor(project.color);
const fallbackIcon = projectIconName ? (
const fallbackIcon = project.kind === 'chat' ? (
<Icon name="chat-4" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" />
) : projectIconName ? (
<Icon name={projectIconName} className="h-3.5 w-3.5 shrink-0" style={iconColor ? { color: iconColor } : undefined} />
) : (
<Icon name="folder" className="h-3.5 w-3.5 shrink-0 text-muted-foreground/80" style={iconColor ? { color: iconColor } : undefined} />
@@ -115,13 +117,15 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
className="h-7 min-w-0 w-fit max-w-[42vw] sm:max-w-[18rem] border-transparent bg-transparent px-1.5 hover:bg-transparent data-[popup-open]:bg-transparent"
>
<SelectValue>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
</SelectValue>
</SelectTrigger>
<SelectContent fitContent>
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} fitContent>
{projects.map((project) => (
<SelectItem key={project.id} value={project.id} className="max-w-[24rem] truncate">
{<ProjectLabel project={project} theme={theme} />}
<ProjectLabel project={project} theme={theme} />
</SelectItem>
))}
</SelectContent>
@@ -140,7 +144,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) {
{selectedBranchLabel ?? t('chat.chatInput.branch')}
</SelectValue>
</SelectTrigger>
<SelectContent className="w-max min-w-48">
<SelectContent side="top" collisionAvoidance={{ side: 'none' }} className="w-max min-w-48">
{projectRootBranchOption ? (
<SelectGroup>
<SelectLabel>{t('chat.chatInput.projectRoot')}</SelectLabel>
@@ -195,7 +199,9 @@ export function MobileDraftTargetTriggers(
className="inline-flex h-7 min-w-0 max-w-[42vw] flex-shrink cursor-pointer items-center gap-1 rounded-lg px-1.5 typography-micro font-medium text-foreground/80 hover:bg-[var(--interactive-hover)]"
onClick={() => onOpenPicker('project')}
>
{<ProjectLabel project={selectedProject} theme={theme} />}
{selectedProject.kind === 'chat'
? <span className="truncate">{t('chat.chatInput.chooseProject')}</span>
: <ProjectLabel project={selectedProject} theme={theme} />}
<Icon name="arrow-down-s" className="h-3.5 w-3.5 flex-shrink-0 text-muted-foreground" />
</button>
{showBranchSelector ? (
@@ -275,7 +281,7 @@ export function MobileDraftTargetSheets(
onOpenPickerChange(null);
}}
>
<span className="min-w-0 flex-1">{<ProjectLabel project={project} theme={theme} />}</span>
<span className="min-w-0 flex-1"><ProjectLabel project={project} theme={theme} /></span>
{project.id === selectedProject.id ? (
<Icon name="check" className="h-4 w-4 flex-shrink-0 text-muted-foreground" />
) : null}
@@ -0,0 +1,67 @@
import { afterEach, describe, expect, test } from 'bun:test';
import { fileReferenceExists } from './fileReferenceStat';
const originalFetch = globalThis.fetch;
const calls: Array<{ url: string; headers: Headers }> = [];
const stubFetchWith = (respond: () => Response) => {
calls.length = 0;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : input.toString();
const headers = new Headers(init?.headers);
calls.push({ url, headers });
return respond();
// SAFETY: the stub preserves the fetch signature; every caller in this
// file restores globalThis.fetch in afterEach.
}) as typeof fetch;
};
afterEach(() => {
globalThis.fetch = originalFetch;
});
describe('fileReferenceExists directory scoping (issue 3019)', () => {
test('sends the session directory on the stat probe', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-b/src/index.ts', isFile: true, size: 12 }), { status: 200 }));
const exists = await fileReferenceExists('/repo-b/src/index.ts', '/repo-b');
expect(exists).toBe(true);
expect(calls).toHaveLength(1);
expect(calls[0].url).toBe('/api/fs/stat?path=%2Frepo-b%2Fsrc%2Findex.ts&optional=true');
expect(calls[0].headers.get('x-opencode-directory')).toBe('/repo-b');
});
test('treats a workspace rejection under one directory as unknown under another directory', async () => {
// Directory A resolves the workspace on the server (the browsed
// lastDirectory), so the probe for a path under B is rejected with 400
// and resolves false. The same path probed under B itself must issue a
// fresh request rather than reuse A's cached rejection.
stubFetchWith(() => {
const directoryHint = calls[calls.length - 1]?.headers.get('x-opencode-directory') ?? null;
if (directoryHint !== '/repo-b') {
return new Response(JSON.stringify({ error: 'Path is outside of active workspace' }), { status: 400 });
}
return new Response(JSON.stringify({ path: '/repo-b/lib/main.ts', isFile: true, size: 12 }), { status: 200 });
});
const rejectedUnderA = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-a');
const acceptedUnderB = await fileReferenceExists('/repo-b/lib/main.ts', '/repo-b');
expect(rejectedUnderA).toBe(false);
expect(acceptedUnderB).toBe(true);
expect(calls).toHaveLength(2);
});
test('serves a repeated probe under the same directory from the cache', async () => {
stubFetchWith(() => new Response(JSON.stringify({ path: '/repo-c/lib.ts', isFile: true, size: 4 }), { status: 200 }));
await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
const warm = await fileReferenceExists('/repo-c/lib.ts', '/repo-c');
expect(warm).toBe(true);
expect(calls).toHaveLength(1);
});
});
@@ -0,0 +1,80 @@
import { isVSCodeRuntime } from '@/lib/desktop';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { normalizeReferencePath } from './fileReferenceParser';
const FILE_REFERENCE_STAT_CONCURRENCY = 4;
const FILE_REFERENCE_STAT_CACHE_MAX = 1000;
const VSCODE_FILE_REFERENCE_STAT_CACHE_MAX = 200;
const FILE_REFERENCE_STAT_CACHE = new Map<string, Promise<boolean>>();
let activeFileReferenceStatCount = 0;
const pendingFileReferenceStats: Array<() => void> = [];
const getFileReferenceStatCacheMax = (): number => (
isVSCodeRuntime() ? VSCODE_FILE_REFERENCE_STAT_CACHE_MAX : FILE_REFERENCE_STAT_CACHE_MAX
);
// NUL cannot occur in a real path, so a directory-qualified key cannot collide
// with a differently scoped entry.
const statCacheKey = (directory: string, normalizedPath: string): string => `${directory}\u0000${normalizedPath}`;
export const fileReferenceExists = (resolvedPath: string, effectiveDirectory: string): Promise<boolean> => {
const normalizedPath = normalizeReferencePath(resolvedPath);
if (!normalizedPath) {
return Promise.resolve(false);
}
const cacheKey = statCacheKey(effectiveDirectory, normalizedPath);
const cached = FILE_REFERENCE_STAT_CACHE.get(cacheKey);
if (cached) {
FILE_REFERENCE_STAT_CACHE.delete(cacheKey);
FILE_REFERENCE_STAT_CACHE.set(cacheKey, cached);
return cached;
}
const request = new Promise<boolean>((resolve) => {
const run = () => {
activeFileReferenceStatCount += 1;
void runtimeFetch(`/api/fs/stat?path=${encodeURIComponent(normalizedPath)}&optional=true`, {
method: 'GET',
cache: 'no-store',
// The stat route resolves the workspace from this header. Without it
// the server falls back to the browsed lastDirectory, which rejects
// session-local files with 400 whenever the two directories differ.
headers: effectiveDirectory ? { 'x-opencode-directory': effectiveDirectory } : undefined,
})
.then(async (response) => {
if (!response.ok) {
resolve(false);
return;
}
const payload = await response.json().catch(() => null) as { exists?: unknown } | null;
resolve(payload?.exists !== false);
})
.catch(() => resolve(false))
.finally(() => {
activeFileReferenceStatCount = Math.max(0, activeFileReferenceStatCount - 1);
pendingFileReferenceStats.shift()?.();
});
};
if (activeFileReferenceStatCount < FILE_REFERENCE_STAT_CONCURRENCY) {
run();
return;
}
pendingFileReferenceStats.push(run);
});
const maxCacheEntries = getFileReferenceStatCacheMax();
while (FILE_REFERENCE_STAT_CACHE.size >= maxCacheEntries) {
const oldest = FILE_REFERENCE_STAT_CACHE.keys().next().value;
if (typeof oldest !== 'string') {
break;
}
FILE_REFERENCE_STAT_CACHE.delete(oldest);
}
FILE_REFERENCE_STAT_CACHE.set(cacheKey, request);
return request;
};
@@ -167,6 +167,12 @@ export const highlightLinesInWorker = async (code: string, lang: string): Promis
return result?.type === 'highlightLines' ? result.lines : null;
};
/** Return an already-tokenized line result without scheduling a worker request. */
export const getCachedHighlightedLines = (code: string, lang: string): string[] | null => {
const cached = resultCache.get(cacheKeyFor('highlightLines', lang, code));
return cached?.type === 'highlightLines' ? cached.lines : null;
};
/**
* Tokenize `code` with the given resolved TextMate theme and return per-line
* styled runs with offsets for building CodeMirror decorations that match the
@@ -1,10 +1,43 @@
import { describe, expect, mock, test } from 'bun:test';
type SanitizeAttribute = {
attrName: string;
attrValue: string;
forceKeepAttr?: boolean;
};
class TestAnchorElement {
target = '';
setAttribute(name: string, value: string): void {
if (name === 'target') this.target = value;
}
}
const sanitizeHooks: {
uponSanitizeAttribute?: (node: unknown, data: SanitizeAttribute) => void;
afterSanitizeAttributes?: (node: unknown) => void;
} = {};
Object.assign(globalThis, {
window: {},
HTMLAnchorElement: TestAnchorElement,
});
mock.module('dompurify', () => ({
default: {
isSupported: true,
addHook: () => undefined,
sanitize: (html: string) => html,
addHook: (name: keyof typeof sanitizeHooks, hook: never) => {
sanitizeHooks[name] = hook;
},
sanitize: (html: string) => html.replace(/ href="([^"]*)"/g, (attribute, href: string) => {
const anchor = new TestAnchorElement();
const data: SanitizeAttribute = { attrName: 'href', attrValue: href };
sanitizeHooks.uponSanitizeAttribute?.(anchor, data);
sanitizeHooks.afterSanitizeAttributes?.(anchor);
return data.forceKeepAttr || /^(?:https?|mailto|tel):/i.test(href) ? attribute : '';
}),
},
}));
mock.module('./markdown-worker', () => ({
@@ -40,6 +73,21 @@ describe('markdown sanitization', () => {
expect(isLocalFileUrl('file://remote-host/share/report.html')).toBe(false);
expect(isLocalFileUrl('javascript:alert(1)')).toBe(false);
});
test('keeps app and local file links while stripping blocked schemes', () => {
const html = renderMarkdownSync([
'[app](obsidian://open?vault=Notebook)',
'[file](file:///workspace/notes.md)',
'[script](javascript:alert(1))',
'[diagnostic](ms-msdt:/id%20PCWDiagnostic)',
].join('\n\n'), 'inline');
expect(html).toContain('href="obsidian://open?vault=Notebook"');
expect(html).toContain('href="file:///workspace/notes.md"');
expect(html).not.toContain('href="javascript:alert(1)"');
expect(html).not.toContain('href="ms-msdt:/id%20PCWDiagnostic"');
});
});
describe('Markdown images', () => {
@@ -3,6 +3,7 @@ import remend from 'remend';
import katex from 'katex';
import DOMPurify from 'dompurify';
import { buildAgentMentionUrl, parseAgentHref, parseSkillHref } from '@/lib/messages/inlineMessageLinks';
import { isAppLinkUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import { contentFingerprint, HighlightResultCache, utf16Bytes } from './highlightResultCache';
import { highlightCodeInWorker } from './markdown-worker';
@@ -472,7 +473,10 @@ const ensureSanitizeHook = (): void => {
sanitizeHookInstalled = true;
DOMPurify.addHook('uponSanitizeAttribute', (node, data) => {
if (!(node instanceof HTMLAnchorElement) || data.attrName !== 'href') return;
if (isLocalFileUrl(data.attrValue)) data.forceKeepAttr = true;
// DOMPurify's default URI policy strips custom application schemes
// (obsidian://, vscode://, ...). Keep them for anchors; dangerous schemes
// stay excluded via isAppLinkUrl and clicks go through confirmation.
if (isLocalFileUrl(data.attrValue) || isAppLinkUrl(data.attrValue)) data.forceKeepAttr = true;
});
DOMPurify.addHook('afterSanitizeAttributes', (node) => {
if (!(node instanceof HTMLAnchorElement)) return;
@@ -544,7 +548,10 @@ export const __markdownBlockCacheSizesForTests = (): { full: number; live: numbe
live: liveBlockCache.size,
});
const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): Promise<string> => {
const parseBlock = async (
block: MarkdownBlock,
imageMode: MarkdownImageMode,
): Promise<string> => {
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = await Promise.resolve(parser.parse(block.src));
const withMath = renderMathExpressions(parsed);
@@ -561,7 +568,10 @@ const parseBlock = async (block: MarkdownBlock, imageMode: MarkdownImageMode): P
* is synchronous (marked is not configured `async`), so this never blocks on a
* worker round-trip.
*/
export const renderMarkdownSync = (text: string, imageMode: MarkdownImageMode = 'inline'): string => {
export const renderMarkdownSync = (
text: string,
imageMode: MarkdownImageMode = 'inline',
): string => {
if (!text) return '';
const parser = imageMode === 'label' ? imageLabelParser : inlineImageParser;
const parsed = parser.parse(text) as string;
@@ -567,7 +567,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
const formatted = formatTimestampForDisplay(messageCreatedAt, timeFormatPreference);
return formatted.length > 0 ? formatted : null;
}, [locale, messageCreatedAt, timeFormatPreference]);
const actionsBlock = ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
const actionsBlock = chatSurfaceMode !== 'peek' && ((canCopyMessage && hasCopyableText) || onRevert || effectiveOnFork || onToggleContextPin) && showUserActions ? (
<div className={cn(
'group/user-actions',
isMobile
@@ -726,10 +726,13 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
)}
style={useStickyScrollableUserContent ? { maxHeight: 'calc(var(--chat-scroll-height, 100dvh) * 0.4)' } : undefined}
>
{/* Positional keys, not part ids: the server echo of a just-sent
message swaps the optimistic part id, and id-based keys would
remount the text subtree (blank frame + height jump). */}
{userContentParts.map((part, index) => {
if (isSubtaskPart(part)) {
return (
<React.Fragment key={part.id ?? `user-subtask-${index}`}>
<React.Fragment key={`user-subtask-${index}`}>
<UserSubtaskPart part={part} />
</React.Fragment>
);
@@ -737,7 +740,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
if (isShellActionPart(part)) {
return (
<React.Fragment key={part.id ?? `user-shell-${index}`}>
<React.Fragment key={`user-shell-${index}`}>
<UserShellActionPart part={part} />
</React.Fragment>
);
@@ -752,7 +755,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi
}
}
return (
<React.Fragment key={part.id ?? `user-text-${index}`}>
<React.Fragment key={`user-text-${index}`}>
<UserTextPart
part={part}
messageId={messageId}
@@ -1702,8 +1705,9 @@ const AssistantMessageBody = React.memo(({
const shouldDeferSortedInlineText = isSortedRenderMode && !hasStopFinish;
const showErrorMessage = Boolean(errorMessage);
const errorIconName = errorVariant === 'info' ? 'information' : 'error-warning';
const shouldShowMessageActions = hasCopyableText;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage));
const isPeekSurface = chatSurfaceMode === 'peek';
const shouldShowMessageActions = hasCopyableText && !isPeekSurface;
const shouldShowTurnFooter = isLastAssistantInTurn && hasTextContent && (hasStopFinish || Boolean(errorMessage)) && !isPeekSurface;
const shouldRenderActionsInActivity = isSortedRenderMode;
const shouldShowStandaloneMessageActions = showSplitAssistantMessageActions && shouldShowMessageActions && !shouldShowTurnFooter && !shouldRenderActionsInActivity;
@@ -54,7 +54,8 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- Assistant markdown treats raw HTML as inert visible text. The final generated
HTML is sanitized as defense in depth, with script and style elements
forbidden, so message content cannot inject active DOM or application-wide
CSS into any runtime surface.
CSS into any runtime surface. Safe custom application links go through the
app-link confirmation flow in every supported renderer, including VS Code.
- Final assistant Markdown rendering is independent from image gallery
extraction: gallery presence never changes the chat body. Assistant image
syntax consistently renders as a shared image icon followed by its filename,
@@ -86,7 +87,7 @@ Use this doc when you ask an agent to change tool/header/description behavior.
- The managed `openchamber` plugin tool uses the expandable path and hides its broad protocol input. The plugin supplies the selected action's human description as the native tool title; the UI renders that metadata without owning an action map. The full versioned result envelope renders through the same neutral JSON summary/tree/raw views as other tools, without a tool-specific output card.
- `ToolPart` defers expanded content after a user toggle, preventing large tool input/output payloads from mounting during the initial chat render.
- The rich tool diff preview lives in `ToolPartDiffPreview.tsx` and is lazy-loaded from `ToolPart`. It is the only tool-card piece that imports the `@pierre/diffs` + Shiki rendering stack, keeping that stack out of the eager chat startup graph. While its chunk loads (first rendered diff only) the plain-text patch from `PlainDiffFallback.tsx` renders as the Suspense fallback, mirroring the preview's error fallback. `ToolPart` itself must not statically import `@pierre/diffs` runtime modules or `@/lib/shiki/appThemeRegistry`.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its fixed-height output viewport follows new output until the user scrolls up, then resumes following when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Running bash output falls back to `state.metadata.output` until canonical `state.output` arrives. Its output viewport grows with the content up to `46vh`, then scrolls and follows new output until the user scrolls up; following resumes when the user returns to the bottom. Live output appends or replaces rewritten snapshots as plain text without worker highlighting; finalized output normalizes ANSI terminal controls with a bounded synthetic-cell budget, bypasses the throttle, and receives the normal one-time highlighted rendering.
- Thinking/Justification duration is hidden in `sorted` mode (handled in `ReasoningPart.tsx` + `JustificationBlock.tsx`).
## "I want to change description for Perplexity" (example recipe)
@@ -1548,7 +1548,7 @@ const ToolExpandedContent: React.FC<ToolExpandedContentProps> = React.memo(({
output,
{
className: part.tool === 'bash' ? 'p-1 rounded-none' : 'p-1',
maxHeightClass: isStreamingBash ? 'h-[46vh]' : part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
maxHeightClass: part.tool === 'bash' ? 'max-h-[46vh]' : undefined,
followKey: isStreamingBash ? outputString : undefined,
}
);
@@ -72,19 +72,46 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
React.useEffect(() => {
const el = textRef.current;
if (!el) return;
if (!collapsibleUserMessages || isExpanded) return;
const checkTruncation = () => {
if (collapsibleUserMessages && !isExpanded) {
setIsTruncated(el.scrollHeight > el.clientHeight);
}
};
checkTruncation();
// A just-sent message mounts while its turn is still settling, so the
// synchronous read can land before the clamp has its final geometry.
// One deferred re-read covers that without waiting for an observer.
const initialFrame = window.requestAnimationFrame(checkTruncation);
// `el` is the clamped box: once line-clamp pins it to two lines its own
// size stops changing, so observing it alone freezes the first
// measurement. Markdown settles after mount (highlighting, late layout),
// and a message measured while still short would never regain the
// expand affordance. The children keep their natural height under the
// clamp, so they are what reports content growth.
const resizeObserver = new ResizeObserver(checkTruncation);
resizeObserver.observe(el);
return () => resizeObserver.disconnect();
const observeChildren = () => {
for (const child of Array.from(el.children)) {
resizeObserver.observe(child);
}
};
observeChildren();
// The renderer swaps subtrees as it settles; re-observe the new children.
const mutationObserver = new MutationObserver(() => {
observeChildren();
checkTruncation();
});
mutationObserver.observe(el, { childList: true, subtree: true });
return () => {
window.cancelAnimationFrame(initialFrame);
mutationObserver.disconnect();
resizeObserver.disconnect();
};
}, [collapsibleUserMessages, textContent, isExpanded]);
React.useEffect(() => {
@@ -115,10 +142,14 @@ const UserTextPart: React.FC<UserTextPartProps> = ({ part, messageId, agentMenti
return;
}
if (collapsibleUserMessages && !isExpanded && isTruncated) {
// Measure at click time instead of trusting the observed flag: whether
// the text is clipped right now is what decides if expanding does
// anything, and the flag can still be catching up on a fresh message.
if (collapsibleUserMessages && !isExpanded && element.scrollHeight > element.clientHeight) {
setIsTruncated(true);
setIsExpanded(true);
}
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, isTruncated, openSkill]);
}, [collapsibleUserMessages, hasActiveSelectionInElement, isExpanded, openSkill]);
const handleCollapse = React.useCallback((event: React.MouseEvent) => {
event.stopPropagation();
@@ -54,7 +54,8 @@ export const VirtualizedCodeBlock: React.FC<VirtualizedCodeBlockProps> = React.m
const syntaxVars = React.useMemo(() => getMarkdownSyntaxVars(currentTheme), [currentTheme]);
// Tokenize the whole block in one worker call; rows index into the result.
const fullText = React.useMemo(() => lines.map((line) => line.text).join('\n'), [lines]);
const highlighted = useWorkerHighlightedLines(fullText, language);
const highlightResult = useWorkerHighlightedLines(fullText, language);
const highlighted = highlightResult.lines;
const shouldVirtualize = lines.length > VIRTUALIZE_THRESHOLD;
@@ -229,15 +229,19 @@ export function WorkingPlaceholder({
return (
<div
// Styled to mirror the turn footer's model row (text-sm,
// muted-foreground/60, no left inset): when the turn completes this row
// disappears and the footer appears in the same visual spot, so the two
// must read as the same line swapping its text.
className={
'flex h-full items-center text-muted-foreground pl-0.5'
'flex h-full items-center text-muted-foreground/60'
}
role="status"
aria-live={displayedPermission ? 'assertive' : 'polite'}
aria-label={label}
data-waiting={displayedPermission ? 'true' : undefined}
>
<span className="typography-ui-header">
<span className="text-sm">
{hasProviderLogo && providerLogoSrc ? (
<img
src={providerLogoSrc}
@@ -1,22 +1,21 @@
import React from 'react';
/**
* Mobile: clamp an autocomplete popup (anchored above the composer via
* `bottom-full`) so it never rises past the top of the chat area. The chat
* `<main>` starts below the app header in both the Capacitor shell and the
* mobile browser, so its top edge is the correct boundary for both.
* Clamp an autocomplete popup (anchored above the composer via `bottom-full`)
* so it never rises past the top of the chat area. The chat `<main>` starts
* below the app header, so its top edge is the correct boundary.
*
* Re-measures on window resizes and when the native keyboard choreography
* settles (the composer and therefore the popup's anchor moves with it).
*
* Returns an inline max-height in px, or undefined when disabled. NOTE: the
* inline value REPLACES any `max-h-*` class (it does not combine) on mobile
* the popup is allowed to grow all the way to the boundary, unlike the
* desktop design cap.
* Returns an inline max-height only when the available space is smaller than
* the popup's normal CSS height cap. This keeps the desktop cap intact while
* still protecting the header on tall draft composers.
*/
export const useMobileAutocompleteMaxHeight = (
containerRef: React.RefObject<HTMLElement | null>,
enabled: boolean,
normalMaxHeight = 256,
): number | undefined => {
const [maxHeight, setMaxHeight] = React.useState<number | undefined>(undefined);
@@ -28,16 +27,15 @@ export const useMobileAutocompleteMaxHeight = (
const main = el.closest('main');
if (!main) return;
// Mobile browsers pan the page up to reveal the focused field, so
// <main>'s top can sit ABOVE the visible screen (negative client
// coordinates). The binding boundary is whichever is lower: the
// chat area's top or the visual viewport's top (its offsetTop is
// expressed in the same layout-viewport client coordinates).
// <main>'s top can sit above the visible screen. The binding
// boundary is whichever is lower: the chat area's top or the
// visual viewport's top.
const visualTop = window.visualViewport?.offsetTop ?? 0;
const boundaryTop = Math.max(main.getBoundingClientRect().top, visualTop);
// The popup's bottom edge is its anchor (composer top) and does not
// depend on its current height.
const available = el.getBoundingClientRect().bottom - boundaryTop - 8;
const next = Math.max(120, Math.floor(available));
const available = Math.max(0, Math.floor(el.getBoundingClientRect().bottom - boundaryTop - 8));
const next = available < normalMaxHeight ? available : undefined;
setMaxHeight((prev) => (prev === next ? prev : next));
};
measure();
@@ -45,7 +45,11 @@ exactly as it already does when the context panel opens.
- the user switched it off;
- the runtime is mobile or VS Code;
- the context panel is open for the active directory;
- the context panel is open for the directory the app is effectively on —
looked up through `useEffectiveDirectory` and `normalizeContextPanelDirectoryKey`,
the same key the rail and the panel use. It is deliberately **not** the
directory this panel reports about: a managed Chat reports about none, and
that empty key answered "closed" for a context panel that was plainly open;
- the row cannot fit `WORK_STATUS_MIN_CHAT_WIDTH` of transcript alongside
`WORK_STATUS_PANEL_WIDTH` of panel.
@@ -54,6 +58,11 @@ mode. It remains available on a new-session draft: when the draft targets a
project or pending worktree, the panel uses that directory for project, MCP,
and usage readouts before a session exists.
Managed Chats never render or warm the Project repository section. A Chat draft
also passes no fallback directory to the panel, so an active project's branch
cannot leak into the draft while directory-independent sections remain
available.
`rowRef` is a **callback ref, not an object ref**. An object ref gives no signal
when the node attaches, so the measuring effect read `.current`, found nothing
whenever the row mounted after the effect first ran, and only recovered on the
@@ -26,6 +26,8 @@ type Props = {
/** Null on a new-session draft: repository readouts still apply. */
sessionId: string | null;
directory: string | null;
/** Managed Chats have no project repository, even if another project remains active. */
repositoryEnabled?: boolean;
/** Whether the panel should currently occupy space. */
visible: boolean;
/**
@@ -63,7 +65,7 @@ const PANEL_TRANSITION_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
* eat a visible slice of every row's trailing value, and the shadows already
* say there is more to see.
*/
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, overlay = false }) => {
export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible, repositoryEnabled = true, overlay = false }) => {
const { t } = useI18n();
const setScrollTop = useUIStore((state) => state.setWorkStatusScrollTop);
const setOverlayOpen = useUIStore((state) => state.setWorkStatusOverlayOpen);
@@ -248,7 +250,7 @@ export const WorkStatusPanel: React.FC<Props> = ({ sessionId, directory, visible
sessionId={sessionId}
directory={directory}
showSession={sectionVisible('session')}
showRepository={sectionVisible('repository')}
showRepository={repositoryEnabled && sectionVisible('repository')}
goalRow={<WorkStatusGoalRow sessionId={sessionId} directory={directory} />}
/>
{sectionVisible('usage') ? <WorkStatusUsageSection /> : null}
@@ -10,14 +10,16 @@ type PanelState = {
let panelByDirectory: Record<string, PanelState> = {};
let panelEnabled = true;
let effectiveDirectory: string | undefined = '/repo';
mock.module('@/stores/useUIStore', () => ({
useUIStore: (selector: (state: unknown) => unknown) =>
selector({ contextPanelByDirectory: panelByDirectory, workStatusPanelEnabled: panelEnabled }),
normalizeContextPanelDirectoryKey: (value: string) => value,
}));
mock.module('@/lib/pathNormalization', () => ({
normalizePath: (value?: string | null) => value ?? null,
mock.module('@/hooks/useEffectiveDirectory', () => ({
useEffectiveDirectory: () => effectiveDirectory,
}));
const { useWorkStatusVisibility, WORK_STATUS_REQUIRED_ROW_WIDTH: REQUIRED } = await import(
@@ -88,7 +90,7 @@ const installMinimalDom = () => {
};
};
type Args = { directory: string | null; isMobile: boolean; isVSCode: boolean };
type Args = { isMobile: boolean; isVSCode: boolean };
/**
* Renders the hook with a stand-in row node, attached through the returned
@@ -130,6 +132,7 @@ const renderVisibility = (args: Args, rowWidth: number) => {
beforeEach(() => {
panelByDirectory = {};
panelEnabled = true;
effectiveDirectory = '/repo';
observed = [];
notify = null;
});
@@ -142,7 +145,7 @@ afterEach(() => {
describe('useWorkStatusVisibility', () => {
test('shows the panel when the row can afford both columns', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
@@ -151,7 +154,7 @@ describe('useWorkStatusVisibility', () => {
test('hides the panel when the row cannot afford both columns', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED - 1,
);
expect(result.visible).toBe(false);
@@ -173,7 +176,6 @@ describe('useWorkStatusVisibility', () => {
const Probe: React.FC = () => {
const { rowRef, visible } = useWorkStatusVisibility({
directory: '/repo',
isMobile: false,
isVSCode: false,
});
@@ -199,7 +201,7 @@ describe('useWorkStatusVisibility', () => {
// In the app this is the chat area (chat + context panel); here `closest`
// finds nothing, so the hook falls back to the row it was given.
const { rowNode, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(observed).toHaveLength(1);
@@ -209,7 +211,7 @@ describe('useWorkStatusVisibility', () => {
test('reacts to a live resize across the threshold', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
@@ -228,7 +230,7 @@ describe('useWorkStatusVisibility', () => {
'/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' },
};
const { result, rowNode, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(false);
@@ -236,11 +238,23 @@ describe('useWorkStatusVisibility', () => {
teardown();
});
test('yields to the open context panel even when the chat reports on no project', () => {
// A Chat session carries no repository, so the panel describes no
// directory. The context panel is still keyed by the directory the app is
// on, and looking it up under the chat's empty one answered "closed".
panelByDirectory = {
'/repo': { isOpen: true, tabs: [{ id: 'tab-1', mode: 'git' }], activeTabId: 'tab-1' },
};
const { result, teardown } = renderVisibility({ isMobile: false, isVSCode: false }, REQUIRED);
expect(result.visible).toBe(false);
teardown();
});
test('ignores an open context panel that has no resolvable tab', () => {
// ContextPanel renders nothing in that state, so it displaces nothing.
panelByDirectory = { '/repo': { isOpen: true, tabs: [], activeTabId: null } };
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED,
);
expect(result.visible).toBe(true);
@@ -264,7 +278,6 @@ describe('useWorkStatusVisibility', () => {
const Probe: React.FC = () => {
const [attached, setAttached] = React.useState(false);
const { rowRef, visible } = useWorkStatusVisibility({
directory: '/repo',
isMobile: false,
isVSCode: false,
});
@@ -292,7 +305,7 @@ describe('useWorkStatusVisibility', () => {
// there is room for it.
panelEnabled = false;
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED * 2,
);
expect(result.visible).toBe(false);
@@ -302,7 +315,7 @@ describe('useWorkStatusVisibility', () => {
test('reports no fit when the row is too narrow, whatever the switch says', () => {
const { result, teardown } = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: false },
{ isMobile: false, isVSCode: false },
REQUIRED - 1,
);
expect(result.fits).toBe(false);
@@ -312,7 +325,7 @@ describe('useWorkStatusVisibility', () => {
test('stays hidden on mobile and in VS Code regardless of width', () => {
const mobile = renderVisibility(
{ directory: '/repo', isMobile: true, isVSCode: false },
{ isMobile: true, isVSCode: false },
REQUIRED * 2,
);
expect(mobile.result.visible).toBe(false);
@@ -320,7 +333,7 @@ describe('useWorkStatusVisibility', () => {
observed = [];
const vscode = renderVisibility(
{ directory: '/repo', isMobile: false, isVSCode: true },
{ isMobile: false, isVSCode: true },
REQUIRED * 2,
);
expect(vscode.result.visible).toBe(false);
@@ -1,6 +1,6 @@
import React from 'react';
import { useUIStore } from '@/stores/useUIStore';
import { normalizePath } from '@/lib/pathNormalization';
import { normalizeContextPanelDirectoryKey, useUIStore } from '@/stores/useUIStore';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
/**
* Fixed panel width. The panel is not user-resizable: it is an object inside
@@ -23,7 +23,6 @@ export const WORK_STATUS_REQUIRED_ROW_WIDTH =
WORK_STATUS_PANEL_WIDTH + WORK_STATUS_PANEL_GUTTER + WORK_STATUS_MIN_CHAT_WIDTH;
type Options = {
directory: string | null | undefined;
isMobile: boolean;
isVSCode: boolean;
};
@@ -52,12 +51,20 @@ type Result = {
* panel, oscillating forever. The row width is independent of the panel, so it
* is the only stable input.
*/
export const useWorkStatusVisibility = ({ directory, isMobile, isVSCode }: Options): Result => {
export const useWorkStatusVisibility = ({ isMobile, isVSCode }: Options): Result => {
const [rowNode, setRowNode] = React.useState<HTMLDivElement | null>(null);
const [rowWidth, setRowWidth] = React.useState<number | null>(null);
const rowRef = React.useCallback((node: HTMLDivElement | null) => { setRowNode(node); }, []);
const directoryKey = React.useMemo(() => normalizePath(directory ?? null), [directory]);
// Keyed exactly like the rail and the panel itself: whichever directory the
// app is effectively on, not the directory this panel reports about. A chat
// with no project reports on nothing, and looking the context panel up under
// that empty key answered "closed" while it was plainly open on screen.
const effectiveDirectory = useEffectiveDirectory();
const directoryKey = React.useMemo(
() => (effectiveDirectory ? normalizeContextPanelDirectoryKey(effectiveDirectory) : ''),
[effectiveDirectory],
);
// Mirrors ContextPanel's own derivation: a panel with `isOpen` but no
// resolvable active tab renders nothing, and must not displace this panel.
@@ -1,26 +1,60 @@
import React from 'react';
import { highlightLinesInWorker } from '@/components/chat/markdown/markdown-worker';
import {
getCachedHighlightedLines,
highlightLinesInWorker,
} from '@/components/chat/markdown/markdown-worker';
// Tokenize a whole block ONCE in the Shiki worker and expose per-line inner
// HTML. For per-line layouts (diffs, gutters, virtualization) that would
// otherwise spawn one highlighter per row. Returns `null` until the first
// result lands (or permanently on failure) — callers render plain text then.
// otherwise spawn one highlighter per row. Cached results are available on the
// first render; cold requests distinguish loading from permanent failure so
// callers can choose whether to reveal their plain-text fallback.
//
// Whole-block tokenization also restores cross-line syntax context (multi-line
// strings / comments) that independent per-line highlighting loses.
export const useWorkerHighlightedLines = (code: string, language: string): string[] | null => {
const [lines, setLines] = React.useState<string[] | null>(null);
export type WorkerHighlightedLinesResult =
| { status: 'loading'; lines: null }
| { status: 'ready'; lines: string[] }
| { status: 'failed'; lines: null };
type HighlightState = WorkerHighlightedLinesResult & {
code: string;
language: string;
};
const getHighlightState = (code: string, language: string): HighlightState => {
const lines = getCachedHighlightedLines(code, language);
return lines
? { status: 'ready', lines, code, language }
: { status: 'loading', lines: null, code, language };
};
export const useWorkerHighlightedLines = (code: string, language: string): WorkerHighlightedLinesResult => {
const normalizedLanguage = (language || 'text').toLowerCase();
const [state, setState] = React.useState<HighlightState>(() => getHighlightState(code, normalizedLanguage));
React.useEffect(() => {
const cached = getCachedHighlightedLines(code, normalizedLanguage);
if (cached) {
setState({ status: 'ready', lines: cached, code, language: normalizedLanguage });
return;
}
setState({ status: 'loading', lines: null, code, language: normalizedLanguage });
let active = true;
setLines(null);
void highlightLinesInWorker(code, (language || 'text').toLowerCase()).then((result) => {
if (active) setLines(result);
void highlightLinesInWorker(code, normalizedLanguage).then((lines) => {
if (!active) return;
setState(lines
? { status: 'ready', lines, code, language: normalizedLanguage }
: { status: 'failed', lines: null, code, language: normalizedLanguage });
});
return () => {
active = false;
};
}, [code, language]);
}, [code, normalizedLanguage]);
return lines;
if (state.code !== code || state.language !== normalizedLanguage) {
return getHighlightState(code, normalizedLanguage);
}
return state;
};
@@ -11,6 +11,7 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useI18n } from '@/lib/i18n';
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
import { getRuntimeKey } from '@/lib/runtime-switch';
import { focusChatInput } from '@/components/chat/composer/editor/dom';
type LineRangeBase = {
start: number;
@@ -136,6 +137,7 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
const normalizedStoreRange = normalizeStoreRange(toStoreRange(normalizedRange));
const code = getCodeForRange(normalizedRange);
const isNewComment = !editingDraftId;
if (editingDraftId) {
updateDraft(target, editingDraftId, {
fileLabel,
@@ -160,6 +162,9 @@ export function useInlineCommentController<TRange extends LineRangeBase>(
}
reset();
if (isNewComment) {
requestAnimationFrame(focusChatInput);
}
}, [addDraft, editingDraftId, fileLabel, getCodeForRange, language, reset, selection, source, t, target, toStoreRange, updateDraft]);
return {
@@ -205,7 +205,10 @@ export const WindowsWindowControls = React.memo(function WindowsWindowControls({
<button
key="close"
type="button"
className={cn(buttonClassName, 'hover:bg-status-error hover:text-status-error-foreground')}
className={cn(
buttonClassName,
'hover:bg-[var(--status-error-background)] hover:text-[var(--status-error-foreground)]',
)}
onClick={() => { void invokeDesktop('desktop_close_current_window'); }}
title={t('header.windowControls.close')}
aria-label={t('header.windowControls.close')}
@@ -5,6 +5,10 @@
* area uses the same paddings/typography as the textarea and the action row
* reuses the footer icon-button styling so toggling dictation causes no
* vertical shift.
*
* No text appears while recording. The server transcribes the audio once the
* user stops, so the overlay shows the recording state and then Transcribing.
* The only transcript rendered here is the salvage text of a failed dictation.
*/
import React from 'react';
@@ -15,6 +19,7 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
import { cn } from '@/lib/utils';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useDictation } from '@/hooks/useDictation';
import { DictationWaveform } from '@/components/dictation/DictationWaveform';
import { isDictationCaptureSupported } from '@/lib/dictation/use-dictation-audio-source';
import { isVSCodeRuntime } from '@/lib/desktop';
import { useConfigStore } from '@/stores/useConfigStore';
@@ -50,25 +55,6 @@ const formatDuration = (seconds: number): string => {
return `${mins}:${String(secs).padStart(2, '0')}`;
};
const VolumeMeter: React.FC<{ volume: number }> = ({ volume }) => {
const { currentTheme } = useThemeSystem();
return (
<div
className="h-1.5 w-16 flex-shrink-0 overflow-hidden rounded-full"
style={{ backgroundColor: currentTheme.colors.interactive.border }}
aria-hidden="true"
>
<div
className="h-full rounded-full transition-[width] duration-75"
style={{
width: `${Math.round(Math.min(1, volume) * 100)}%`,
backgroundColor: currentTheme.colors.primary.base,
}}
/>
</div>
);
};
/**
* Polls the dictation status route while the local model is downloading and
* returns the download percent (null while unknown / not downloading).
@@ -162,7 +148,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
const {
status,
partialTranscript,
volume,
subscribeLevel,
duration,
error,
errorReason,
@@ -399,7 +385,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
{/* Measured for the composer-growth report keep all
transcript/placeholder/error content inside. */}
<div ref={transcriptContentRef}>
{partialTranscript ? (
{status === 'failed' && partialTranscript ? (
<p className="typography-markdown md:typography-ui-label whitespace-pre-wrap" style={{ color: currentTheme.colors.surface.foreground }}>
{partialTranscript}
</p>
@@ -436,8 +422,8 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
style={{ backgroundColor: currentTheme.colors.status.error }}
/>
</span>
<VolumeMeter volume={volume} />
<span className="typography-meta tabular-nums" style={{ color: currentTheme.colors.surface.mutedForeground }}>
<DictationWaveform subscribeLevel={subscribeLevel} className="block h-4 min-w-0 flex-1" />
<span className="typography-meta flex-shrink-0 tabular-nums" style={{ color: currentTheme.colors.surface.mutedForeground }}>
{formatDuration(duration)}
</span>
</>
@@ -446,7 +432,7 @@ export const ComposerDictation: React.FC<ComposerDictationProps> = ({
) : null}
{/* Same inter-control gap as the composer's right cluster:
gap-x-1 on mobile, md:gap-x-3 on desktop. */}
<div className={cn('ml-auto flex items-center', isMobile ? 'gap-x-1' : 'gap-x-1.5 md:gap-x-3')}>
<div className={cn('ml-auto flex flex-shrink-0 items-center', isMobile ? 'gap-x-1' : 'gap-x-1.5 md:gap-x-3')}>
{status === 'recording' ? (
<>
<button
@@ -0,0 +1,130 @@
/**
* Scrolling microphone level history for the dictation overlay.
*
* Newest sample is at the right edge and the history scrolls left, so the row
* reads as a live recording trace rather than a single level bar. Silence
* renders as a dot, speech as a rounded bar.
*
* Drawn on a canvas fed by a level subscription: the level updates ~12 times a
* second, and routing that through React state would re-render the whole
* dictation overlay at the same rate.
*/
import React from 'react';
import { useThemeSystem } from '@/contexts/useThemeSystem';
import type { DictationLevelListener } from '@/lib/dictation/use-dictation-audio-source';
interface DictationWaveformProps {
subscribeLevel: (listener: DictationLevelListener) => () => void;
className?: string;
}
const BAR_WIDTH = 2;
const BAR_GAP = 3;
const BAR_PITCH = BAR_WIDTH + BAR_GAP;
/** One sample per bar; ~16 bars/s scrolls at a readable speed. */
const SAMPLE_INTERVAL_MS = 60;
/** Raise quiet speech so normal talking uses most of the height. */
const LEVEL_CURVE = 0.65;
export const DictationWaveform: React.FC<DictationWaveformProps> = ({ subscribeLevel, className }) => {
const { currentTheme } = useThemeSystem();
const canvasRef = React.useRef<HTMLCanvasElement | null>(null);
const barColor = currentTheme.colors.surface.mutedForeground;
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) {
return;
}
const context = canvas.getContext('2d');
if (!context) {
return;
}
// Peak-hold between samples: a short loud syllable must not be missed
// just because it landed between two frames.
let peakSinceSample = 0;
const unsubscribe = subscribeLevel((level) => {
peakSinceSample = Math.max(peakSinceSample, level);
});
const bars: number[] = [];
let cssWidth = 0;
let cssHeight = 0;
const resize = () => {
const rect = canvas.getBoundingClientRect();
const dpr = window.devicePixelRatio || 1;
cssWidth = rect.width;
cssHeight = rect.height;
canvas.width = Math.max(1, Math.round(cssWidth * dpr));
canvas.height = Math.max(1, Math.round(cssHeight * dpr));
context.setTransform(dpr, 0, 0, dpr, 0, 0);
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
const draw = () => {
const capacity = Math.max(1, Math.floor(cssWidth / BAR_PITCH));
while (bars.length > capacity) {
bars.shift();
}
context.clearRect(0, 0, cssWidth, cssHeight);
context.strokeStyle = barColor;
context.fillStyle = barColor;
context.lineWidth = BAR_WIDTH;
context.lineCap = 'round';
const centerY = cssHeight / 2;
const maxHeight = Math.max(BAR_WIDTH, cssHeight);
// Anchor the newest bar to the right edge; older bars trail left.
const rightX = cssWidth - BAR_WIDTH / 2;
for (let i = 0; i < bars.length; i++) {
const x = rightX - (bars.length - 1 - i) * BAR_PITCH;
if (x < BAR_WIDTH / 2) {
continue;
}
const height = BAR_WIDTH + (maxHeight - BAR_WIDTH) * Math.pow(bars[i], LEVEL_CURVE);
// Round caps add BAR_WIDTH/2 past each end of the stroke, so the
// stroke itself is the height minus one cap diameter.
const half = (height - BAR_WIDTH) / 2;
context.beginPath();
if (half < 0.25) {
context.arc(x, centerY, BAR_WIDTH / 2, 0, Math.PI * 2);
context.fill();
} else {
context.moveTo(x, centerY - half);
context.lineTo(x, centerY + half);
context.stroke();
}
}
};
let frame = 0;
let lastSampleAt = 0;
const tick = (now: number) => {
frame = requestAnimationFrame(tick);
if (now - lastSampleAt < SAMPLE_INTERVAL_MS) {
return;
}
lastSampleAt = now;
bars.push(peakSinceSample);
peakSinceSample = 0;
draw();
};
frame = requestAnimationFrame(tick);
return () => {
cancelAnimationFrame(frame);
observer.disconnect();
unsubscribe();
};
}, [subscribeLevel, barColor]);
return <canvas ref={canvasRef} className={className} aria-hidden="true" />;
};
+18 -8
View File
@@ -22,6 +22,8 @@ import { useSessionUIStore } from '@/sync/session-ui-store';
import { useSessionWorktreeStore } from '@/sync/session-worktree-store';
import { formatSessionWorktreeBadge } from '@/sync/session-worktree-contract';
import { buildSessionMessageRecordsSnapshot, useDirectoryStore, useGlobalSessionStatus, useSessionMessagesResolved } from '@/sync/sync-context';
import { useDirectoryStore as useAppDirectoryStore } from '@/stores/useDirectoryStore';
import { isChatDirectoryForHome } from '@/lib/chatDirectories';
import { useSync } from '@/sync/use-sync';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
@@ -1052,6 +1054,10 @@ export const Header: React.FC<HeaderProps> = ({
}
return normalize(state.newSessionDraft.bootstrapPendingDirectory ?? state.newSessionDraft.directoryOverride ?? '');
});
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId);
const selectedSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const homeDirectory = useAppDirectoryStore((state) => state.homeDirectory);
const openDirectory = React.useMemo(() => {
return worktreeDirectory || sessionDirectory || draftDirectory;
@@ -1080,10 +1086,13 @@ export const Header: React.FC<HeaderProps> = ({
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const currentBranchLabel = gitBranchForDirectory || currentSessionWorktreeBranch || catalogWorktreeBranch;
const isChatContext = isNewSessionDraftOpen
? draftTarget === 'chat'
: isChatDirectoryForHome(sessionDirectory || selectedSessionDirectory, homeDirectory);
// Whether the title carries a second line under it. Hoisted because the
// session menu's vertical alignment depends on the same answer.
const showHeaderMetaRow = !workStatusPanelVisible
const showHeaderMetaRow = !isChatContext && !workStatusPanelVisible
&& Boolean(activeProjectLabel || currentBranchLabel || (!isNewSessionDraftOpen && worktreeBadgeKind));
@@ -1423,14 +1432,14 @@ export const Header: React.FC<HeaderProps> = ({
const handleOpenDraftMiniChat = React.useCallback(() => {
void invokeDesktop('desktop_open_draft_mini_chat_window', {
directory: normalize(openDirectory || activeProject?.path || ''),
projectId: activeProject?.id ?? null,
directory: isChatContext ? '' : draftDirectory,
projectId: isChatContext ? null : draftProjectId,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open draft mini chat window', error);
});
}, [activeProject?.id, activeProject?.path, openDirectory]);
}, [draftDirectory, draftProjectId, isChatContext]);
const handleOpenCurrentMiniChat = React.useCallback(() => {
if (isNewSessionDraftOpen) {
@@ -1443,13 +1452,13 @@ export const Header: React.FC<HeaderProps> = ({
}
void invokeDesktop('desktop_open_session_mini_chat_window', {
sessionId: currentSessionId,
directory: normalize(openDirectory || activeProject?.path || ''),
directory: sessionDirectory || normalize(selectedSessionDirectory || '') || worktreeDirectory,
apiBaseUrl: getRuntimeApiBaseUrl(),
clientToken: getRuntimeBearerTokenSync(),
}).catch((error) => {
console.warn('[header] failed to open session mini chat window', error);
});
}, [activeProject?.path, currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, openDirectory]);
}, [currentSessionId, handleOpenDraftMiniChat, isNewSessionDraftOpen, selectedSessionDirectory, sessionDirectory, worktreeDirectory]);
const handleOpenContextPanel = React.useCallback(() => {
const directory = normalize(openDirectory || '');
@@ -1901,7 +1910,8 @@ export const Header: React.FC<HeaderProps> = ({
<div
onMouseDown={handleDragStart}
className={cn(
'app-region-drag relative flex h-12 select-none items-center pr-3',
'app-region-drag relative flex h-12 select-none items-center',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
macosHeaderSizeClass
)}
style={webWindowControlsOverlayStyle}
@@ -2053,7 +2063,7 @@ export const Header: React.FC<HeaderProps> = ({
<DropdownMenuItem onClick={() => void shareCurrentSession()}><Icon name="share-2" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.share')}</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => void exportCurrentSession()}><Icon name="download" className="mr-2 size-4" />{t('sessions.sidebar.session.menu.exportMarkdown')}</DropdownMenuItem>
{!isVSCode && currentSession && !currentSession.parentId ? (
{!isVSCode && !isChatContext && currentSession && !currentSession.parentId ? (
<Tooltip>
<TooltipTrigger asChild>
<span className="block">
@@ -45,7 +45,7 @@ const SettingsWindow = lazyWithChunkRecovery(() => import('@/components/views/Se
export const MainLayout: React.FC = () => {
const isSidebarOpen = useUIStore((state) => state.isSidebarOpen);
const activeMainTab = useUIStore((state) => state.activeMainTab);
const activeSurface = useUIStore((state) => state.activeSurface);
const setIsMobile = useUIStore((state) => state.setIsMobile);
const isSessionSwitcherOpen = useUIStore((state) => state.isSessionSwitcherOpen);
const isSettingsDialogOpen = useUIStore((state) => state.isSettingsDialogOpen);
@@ -83,7 +83,7 @@ export const MainLayout: React.FC = () => {
if (sessionSelected || draftOpened) closeSurfacePages();
});
const unsubscribeTab = useUIStore.subscribe((state, prev) => {
if (state.activeMainTab !== prev.activeMainTab) closeSurfacePages();
if (state.activeSurface !== prev.activeSurface) closeSurfacePages();
});
return () => {
unsubscribeSession();
@@ -195,7 +195,7 @@ export const MainLayout: React.FC = () => {
}, [isMobile, setMobileSessionPanelOpen]);
useEffect(() => {
if (!isMobile || activeMainTab !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
if (!isMobile || activeSurface !== 'chat' || mobileLeftDrawerOpen || mobileRightSidebarOpen || isSettingsDialogOpen) {
return;
}
@@ -231,7 +231,7 @@ export const MainLayout: React.FC = () => {
window.clearTimeout(timeoutId);
}
};
}, [activeMainTab, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
}, [activeSurface, isMobile, isSettingsDialogOpen, mobileLeftDrawerOpen, mobileRightSidebarOpen]);
// Ensure mobile drawers are closed when opening full-screen settings
useEffect(() => {
@@ -263,10 +263,10 @@ export const MainLayout: React.FC = () => {
// Desktop surfaces live in the context panel; the only full-view
// overlays left there are the terminal (promoted by project actions)
// and the diagram viewer. Mobile keeps the full tab set.
if (!isMobile && activeMainTab !== 'terminal' && activeMainTab !== 'diagram') {
if (!isMobile && activeSurface !== 'terminal' && activeSurface !== 'diagram') {
return null;
}
switch (activeMainTab) {
switch (activeSurface) {
case 'plan':
return <React.Suspense fallback={null}><PlanView /></React.Suspense>;
case 'git':
@@ -284,9 +284,9 @@ export const MainLayout: React.FC = () => {
default:
return null;
}
}, [activeMainTab, isMobile, mobileRightSidebarOpen]);
}, [activeSurface, isMobile, mobileRightSidebarOpen]);
const isChatActive = activeMainTab === 'chat';
const isChatActive = activeSurface === 'chat';
return (
<DiffWorkerProvider>
@@ -5,6 +5,9 @@ import { useGitStore } from '@/stores/useGitStore';
import { useProjectsStore } from '@/stores/useProjectsStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { formatDirectoryName } from '@/lib/utils';
import { useSessionUIStore } from '@/sync/session-ui-store';
import { CHAT_DRAFT_PROJECT_ID, getChatsRootForHome, getChatsRootFromDirectory, isChatDirectoryPath } from '@/lib/chatDirectories';
import { useI18n } from '@/lib/i18n';
export const ProjectContextPanel: React.FC<{
onActionComplete?: () => void;
@@ -13,16 +16,28 @@ export const ProjectContextPanel: React.FC<{
const activeProjectId = useProjectsStore((state) => state.activeProjectId);
const projects = useProjectsStore((state) => state.projects);
const homeDirectory = useDirectoryStore((state) => state.homeDirectory);
const { t } = useI18n();
const gitDirectories = useGitStore((state) => state.directories);
const isChatContext = useSessionUIStore((state) => (
state.newSessionDraft.open
? state.newSessionDraft.target === 'chat'
: isChatDirectoryPath(state.currentSessionDirectory)
));
const chatSessionDirectory = useSessionUIStore((state) => state.currentSessionDirectory);
const chatsRoot = getChatsRootFromDirectory(chatSessionDirectory) ?? getChatsRootForHome(homeDirectory);
const activeProject = React.useMemo(() => {
if (isChatContext) return null;
if (activeProjectId) {
return projects.find((project) => project.id === activeProjectId) ?? projects[0] ?? null;
}
return projects[0] ?? null;
}, [activeProjectId, projects]);
}, [activeProjectId, isChatContext, projects]);
const projectRef = React.useMemo(() => {
if (isChatContext && chatsRoot) {
return { id: CHAT_DRAFT_PROJECT_ID, path: chatsRoot };
}
if (!activeProject) {
return null;
}
@@ -30,16 +45,17 @@ export const ProjectContextPanel: React.FC<{
id: activeProject.id,
path: activeProject.path,
};
}, [activeProject]);
}, [activeProject, chatsRoot, isChatContext]);
const projectLabel = React.useMemo(() => {
if (isChatContext) return t('sessions.sidebar.activity.chatsTitle');
if (!activeProject) {
return null;
}
return activeProject.label?.trim()
|| formatDirectoryName(activeProject.path, homeDirectory)
|| activeProject.path;
}, [activeProject, homeDirectory]);
}, [activeProject, homeDirectory, isChatContext, t]);
const canCreateWorktree = React.useMemo(() => {
if (!activeProject) {
@@ -20,6 +20,7 @@ import { Icon } from "@/components/icon/Icon";
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
import { contextTokensFromBreakdown } from '@/stores/utils/tokenUtils';
import type { SessionContextUsage } from '@/stores/types/sessionTypes';
import { isChatDirectoryPath } from '@/lib/chatDirectories';
type MiniChatMode = 'session' | 'draft';
@@ -51,6 +52,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const { t } = useI18n();
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
const draftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
const draftTarget = useSessionUIStore((state) => state.newSessionDraft.target);
const draftProjectId = useSessionUIStore((state) => state.newSessionDraft?.selectedProjectId ?? null);
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const projects = useProjectsStore((state) => state.projects);
@@ -99,6 +101,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const worktreeDirectory = normalizePath(worktreePath || sessionWorktreeMetadata?.path || worktreeAttachment?.cwd || worktreeAttachment?.worktreeRoot || '');
const currentDirectoryNormalized = normalizePath(currentDirectory);
const openDirectory = worktreeDirectory || sessionDirectory || draftDirectory || currentDirectoryNormalized;
const isChatContext = draftOpen ? draftTarget === 'chat' : isChatDirectoryPath(sessionDirectory);
const directoryLabel = compactPath(openDirectory);
const catalogWorktreeBranch = useSessionUIStore((state) => {
const candidateDirectory = normalizePath(worktreeDirectory || sessionDirectory || '');
@@ -111,9 +114,9 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
return null;
});
React.useEffect(() => {
if (!openDirectory) return;
if (!openDirectory || isChatContext) return;
void ensureGitStatus(openDirectory, runtimeApis.git).catch(() => {});
}, [ensureGitStatus, openDirectory, runtimeApis.git]);
}, [ensureGitStatus, isChatContext, openDirectory, runtimeApis.git]);
const pathMatchedProject = React.useMemo(() => {
const projectDirectory = normalizePath(sessionWorktreeMetadata?.projectDirectory ?? worktreeAttachment?.worktreeRoot ?? null);
@@ -125,13 +128,14 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
.sort((left, right) => right.path.length - left.path.length)[0] ?? null;
}, [openDirectory, projects, sessionWorktreeMetadata?.projectDirectory, worktreeAttachment?.worktreeRoot]);
const projectLabel = React.useMemo(() => {
if (isChatContext) return null;
const project = pathMatchedProject ?? activeProject;
if (!project) return directoryLabel || 'OpenChamber';
const label = project.label?.trim();
if (label) return label;
const segments = project.path.split(/[\\/]/).filter(Boolean);
return segments.at(-1) ?? project.path;
}, [activeProject, directoryLabel, pathMatchedProject]);
}, [activeProject, directoryLabel, isChatContext, pathMatchedProject]);
const gitBranchForDirectory = useGitBranchLabel(openDirectory || null);
const rawBranchLabel = gitBranchForDirectory || worktreeMetadataBranch || sessionWorktreeMetadata?.branch?.trim() || worktreeAttachment?.branch?.trim() || catalogWorktreeBranch;
const branchLabel = rawBranchLabel && rawBranchLabel !== 'HEAD' ? rawBranchLabel : null;
@@ -241,7 +245,11 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
const handleOpenMainApp = React.useCallback(() => {
const payload = currentSessionId
? { sessionId: currentSessionId, directory: (session as { directory?: string | null } | null)?.directory ?? currentDirectory ?? '' }
: { mode: 'draft', directory: openDirectory || currentDirectory || '', projectId: draftProjectId };
: {
mode: 'draft',
directory: isChatContext ? '' : openDirectory || currentDirectory || '',
projectId: isChatContext ? null : draftProjectId,
};
void invokeDesktop<{ focused?: boolean }>('desktop_focus_main_window', payload)
.then((result) => {
if (result?.focused === true) {
@@ -249,12 +257,13 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
}
return null;
});
}, [currentDirectory, currentSessionId, draftProjectId, openDirectory, session]);
}, [currentDirectory, currentSessionId, draftProjectId, isChatContext, openDirectory, session]);
return (
<header
className={cn(
'flex items-center gap-3 bg-background pr-3',
'flex items-center gap-3 bg-background',
usesFramelessChrome && windowControlsSide === 'right' ? 'pr-0' : 'pr-3',
hasMacTrafficLights ? 'pl-[5.5rem]' : 'pl-3',
usesFramelessChrome ? 'h-12' : macosHeaderSizeClass || 'min-h-14',
)}
@@ -273,7 +282,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
<span className="truncate typography-ui-label text-[14px] font-normal leading-tight text-foreground max-w-full">
{title}
</span>
<span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
{!isChatContext ? <span className="flex min-w-0 max-w-full items-center gap-1.5 truncate typography-micro text-[10.5px] font-normal leading-tight text-muted-foreground/75">
<span className="truncate">{projectLabel}</span>
{branchLabel ? (
<span className="inline-flex min-w-0 items-center gap-0.5">
@@ -281,7 +290,7 @@ const MiniChatHeader: React.FC<{ mode: MiniChatMode }> = ({ mode }) => {
<span className="truncate">{branchLabel}</span>
</span>
) : null}
</span>
</span> : null}
</button>
</SessionSwitcherDropdown>
<div className="min-w-0 flex-1" />
@@ -6,10 +6,10 @@ import { toast } from '@/components/ui';
import { cn } from '@/lib/utils';
import { useI18n } from '@/lib/i18n';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { opencodeClient } from '@/lib/opencode/client';
import {
useAgentsStore,
getConfigDirectory,
type AgentWithExtras,
} from '@/stores/useAgentsStore';
import {
@@ -105,6 +105,9 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
const [reloadToken, setReloadToken] = React.useState(0);
const agentName = agent.name;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
// --- Load the SOURCE permission map (the agent's own config file). ---
React.useEffect(() => {
@@ -113,7 +116,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
setLoadFailed(false);
void (async () => {
try {
const directory = getConfigDirectory();
const directory = settingsDirectory;
const query = directory ? `?directory=${encodeURIComponent(directory)}` : '';
const response = await runtimeFetch(`/api/config/agents/${encodeURIComponent(agentName)}/config${query}`, {
headers: {
@@ -136,14 +139,14 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName, reloadToken]);
}, [agentName, reloadToken, settingsDirectory]);
// --- Known tool ids for the key list (display only). ---
React.useEffect(() => {
let cancelled = false;
void (async () => {
try {
const ids = await opencodeClient.listToolIds({ directory: getConfigDirectory() });
const ids = await opencodeClient.listToolIds({ directory: settingsDirectory });
if (!cancelled && Array.isArray(ids)) {
setToolIds(ids.filter((id) => typeof id === 'string' && !FOLDED_TOOL_IDS.has(id)));
}
@@ -154,7 +157,7 @@ export const AgentPermissionsEditor: React.FC<AgentPermissionsEditorProps> = ({
return () => {
cancelled = true;
};
}, [agentName]);
}, [agentName, settingsDirectory]);
// --- Effective rules from the resolved view (read-only hints). ---
const effectiveRules = React.useMemo<EffectiveRule[]>(() => {
@@ -4,7 +4,8 @@ import { Input } from '@/components/ui/input';
import { NumberInput } from '@/components/ui/number-input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, type AgentConfig, type AgentMutationResult, type AgentScope } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from './ModelSelector';
import { useI18n } from '@/lib/i18n';
@@ -60,7 +61,6 @@ export const AgentsPage: React.FC = () => {
getAgentByName,
createAgent,
updateAgent,
agents,
agentDraft,
setAgentDraft,
} = useAgentsStore(useShallow((s) => ({
@@ -68,12 +68,15 @@ export const AgentsPage: React.FC = () => {
getAgentByName: s.getAgentByName,
createAgent: s.createAgent,
updateAgent: s.updateAgent,
agents: s.agents,
agentDraft: s.agentDraft,
setAgentDraft: s.setAgentDraft,
})));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
const selectedAgent = selectedAgentName ? getAgentByName(selectedAgentName, settingsDirectory) : null;
const isNewAgent = Boolean(agentDraft && agentDraft.name === selectedAgentName && !selectedAgent);
const [draftName, setDraftName] = React.useState('');
@@ -232,12 +235,12 @@ export const AgentsPage: React.FC = () => {
let result: AgentMutationResult;
if (isNewAgent) {
result = await createAgent(config);
result = await createAgent(config, settingsDirectory);
if (result.ok) {
setAgentDraft(null); // Clear draft after successful creation
}
} else {
result = await updateAgent(agentName, config);
result = await updateAgent(agentName, config, settingsDirectory);
}
if (result.ok) {
@@ -18,7 +18,8 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectAgentsForDirectory, useAgentsStore, isAgentBuiltIn, isAgentHidden, type AgentScope, type AgentDraft } from '@/stores/useAgentsStore';
import { useShallow } from 'zustand/react/shallow';
import { cn } from '@/lib/utils';
import type { Agent } from '@opencode-ai/sdk/v2';
@@ -113,7 +114,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
const {
selectedAgentName,
agents,
setSelectedAgent,
setAgentDraft,
createAgent,
@@ -121,7 +121,6 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents,
} = useAgentsStore(useShallow((s) => ({
selectedAgentName: s.selectedAgentName,
agents: s.agents,
setSelectedAgent: s.setSelectedAgent,
setAgentDraft: s.setAgentDraft,
createAgent: s.createAgent,
@@ -129,9 +128,14 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
loadAgents: s.loadAgents,
})));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const agents = useAgentsStore((state) => selectAgentsForDirectory(state, settingsDirectory));
React.useEffect(() => {
loadAgents();
}, [loadAgents]);
void loadAgents(settingsDirectory);
}, [loadAgents, settingsDirectory]);
const bgClass = 'bg-background';
@@ -183,7 +187,7 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
setIsConfirmActionPending(true);
try {
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope);
const result = await deleteAgent(confirmActionAgent.name, (confirmActionAgent as Agent & { scope?: AgentScope }).scope, settingsDirectory);
if (result.ok) {
if (result.requiresManualRestart) {
@@ -291,11 +295,11 @@ export const AgentsSidebar: React.FC<AgentsSidebarProps> = ({ onItemSelect }) =>
permission: rulesetToPermissionConfig(renameDialogAgent.permission),
disable: renameExt.disable,
scope: renameExt.scope,
});
}, settingsDirectory);
if (createResult.ok) {
// Delete old agent
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope);
const deleteResult = await deleteAgent(renameDialogAgent.name, renameExt.scope, settingsDirectory);
if (deleteResult.ok) {
if (createResult.requiresManualRestart || deleteResult.requiresManualRestart) {
toast.warning(t('settings.agents.page.toast.savedManualRestart'));
@@ -3,7 +3,8 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { toast } from '@/components/ui';
import { useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { selectCommandsForDirectory, useCommandsStore, type CommandConfig, type CommandScope } from '@/stores/useCommandsStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useShallow } from 'zustand/react/shallow';
import { ModelSelector } from '../agents/ModelSelector';
@@ -33,7 +34,6 @@ export const CommandsPage: React.FC = () => {
getCommandByName,
createCommand,
updateCommand,
commands,
commandDraft,
setCommandDraft,
} = useCommandsStore(useShallow((s) => ({
@@ -41,12 +41,15 @@ export const CommandsPage: React.FC = () => {
getCommandByName: s.getCommandByName,
createCommand: s.createCommand,
updateCommand: s.updateCommand,
commands: s.commands,
commandDraft: s.commandDraft,
setCommandDraft: s.setCommandDraft,
})));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName) : null;
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const selectedCommand = selectedCommandName ? getCommandByName(selectedCommandName, settingsDirectory) : null;
const isNewCommand = Boolean(commandDraft && commandDraft.name === selectedCommandName && !selectedCommand);
const [draftName, setDraftName] = React.useState('');
@@ -162,12 +165,12 @@ export const CommandsPage: React.FC = () => {
let success: boolean;
if (isNewCommand) {
success = await createCommand(config);
success = await createCommand(config, settingsDirectory);
if (success) {
setCommandDraft(null);
}
} else {
success = await updateCommand(commandName, config);
success = await updateCommand(commandName, config, settingsDirectory);
}
if (success) {
@@ -18,8 +18,9 @@ import {
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu';
import { ContextMenu, ContextMenuContent, ContextMenuItem, ContextMenuTrigger } from '@/components/ui/context-menu';
import { useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { useSkillsStore } from '@/stores/useSkillsStore';
import { selectCommandsForDirectory, useCommandsStore, isCommandBuiltIn, type Command } from '@/stores/useCommandsStore';
import { selectSkillsForDirectory, useSkillsStore } from '@/stores/useSkillsStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { useShallow } from 'zustand/react/shallow';
import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay';
import { cn } from '@/lib/utils';
@@ -43,7 +44,6 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
const {
selectedCommandName,
commands,
setSelectedCommand,
setCommandDraft,
createCommand,
@@ -51,20 +51,23 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
loadCommands,
} = useCommandsStore(useShallow((s) => ({
selectedCommandName: s.selectedCommandName,
commands: s.commands,
setSelectedCommand: s.setSelectedCommand,
setCommandDraft: s.setCommandDraft,
createCommand: s.createCommand,
deleteCommand: s.deleteCommand,
loadCommands: s.loadCommands,
})));
const skills = useSkillsStore((s) => s.skills);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const commands = useCommandsStore((state) => selectCommandsForDirectory(state, settingsDirectory));
const skills = useSkillsStore((state) => selectSkillsForDirectory(state, settingsDirectory));
const loadSkills = useSkillsStore((s) => s.loadSkills);
React.useEffect(() => {
loadCommands();
loadSkills();
}, [loadCommands, loadSkills]);
void loadCommands(settingsDirectory);
void loadSkills(settingsDirectory);
}, [loadCommands, loadSkills, settingsDirectory]);
const skillNames = React.useMemo(() => new Set(skills.map((skill) => skill.name)), [skills]);
const commandOnlyItems = React.useMemo(
@@ -131,7 +134,7 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
}
setIsConfirmActionPending(true);
const success = await deleteCommand(confirmActionCommand.name);
const success = await deleteCommand(confirmActionCommand.name, settingsDirectory);
if (success) {
if (confirmActionType === 'delete') {
@@ -204,11 +207,11 @@ export const CommandsSidebar: React.FC<CommandsSidebarProps> = ({ onItemSelect }
template: renameDialogCommand.template,
agent: renameDialogCommand.agent,
model: renameDialogCommand.model,
});
}, settingsDirectory);
if (success) {
// Delete old command
const deleteSuccess = await deleteCommand(renameDialogCommand.name);
const deleteSuccess = await deleteCommand(renameDialogCommand.name, settingsDirectory);
if (deleteSuccess) {
toast.success(`Command renamed to "${sanitizedName}"`);
setSelectedCommand(sanitizedName);
@@ -1,74 +0,0 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsSection } from '@/components/sections/shared/SettingsSection';
import type { IconName } from '@/components/icon/icons';
import { useI18n, type I18nKey } from '@/lib/i18n';
import { cn } from '@/lib/utils';
type ComingSoonMessenger = {
id: 'discord' | 'telegram';
icon: IconName;
brandClassName: string;
nameKey: I18nKey;
descriptionKey: I18nKey;
};
const COMING_SOON_MESSENGERS: readonly ComingSoonMessenger[] = [
{
id: 'discord',
icon: 'discord-fill',
brandClassName: 'text-[#5865F2]',
nameKey: 'settings.integrations.messengers.discord.name',
descriptionKey: 'settings.integrations.messengers.discord.description',
},
{
id: 'telegram',
icon: 'telegram-fill',
brandClassName: 'text-[#2AABEE]',
nameKey: 'settings.integrations.messengers.telegram.name',
descriptionKey: 'settings.integrations.messengers.telegram.description',
},
] as const;
/**
* Non-interactive Discord/Telegram placeholders same card chrome as live
* integrations, greyed out, with a Coming soon badge and no expandable body.
*/
export const ComingSoonMessengersSection: React.FC = () => {
const { t } = useI18n();
return (
<SettingsSection
title={t('settings.integrations.messengers.title')}
info={t('settings.integrations.messengers.info')}
divider={false}
settingsItem="integrations.messengers"
contentClassName="space-y-3"
>
{COMING_SOON_MESSENGERS.map((messenger) => (
<div
key={messenger.id}
data-settings-item={`integrations.messengers.${messenger.id}`}
aria-disabled="true"
className={cn(
'flex min-w-0 items-center gap-3 rounded-xl border border-[var(--interactive-border)] bg-[var(--surface-elevated)] px-4 py-3',
'pointer-events-none opacity-60',
)}
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
<Icon name={messenger.icon} className={cn('size-5', messenger.brandClassName)} />
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(messenger.nameKey)}</div>
<p className="mt-0.5 line-clamp-1 text-xs leading-snug text-muted-foreground">
{t(messenger.descriptionKey)}
</p>
</div>
<span className="max-w-36 shrink-0 truncate rounded-full bg-[var(--surface-muted)] px-2 py-0.5 text-[10px] font-medium text-muted-foreground">
{t('settings.common.state.comingSoon')}
</span>
</div>
))}
</SettingsSection>
);
};
@@ -1,5 +1,7 @@
import React from 'react';
import { Icon } from '@/components/icon/Icon';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
import { SETTINGS_DESCRIPTION_CLASS } from '@/components/sections/shared/SettingsSection';
import { useI18n } from '@/lib/i18n';
import { ThirdPartyIntegrationsSection } from './ThirdPartyIntegrationsSection';
@@ -17,7 +19,17 @@ export const IntegrationsPage: React.FC<IntegrationsPageProps> = ({
return (
<SettingsPageLayout
title={t('settings.page.integrations.title')}
description={t('settings.page.integrations.description')}
description={(
<div className="space-y-3">
<p className={SETTINGS_DESCRIPTION_CLASS}>{t('settings.page.integrations.description')}</p>
<div role="alert" className="flex items-start gap-2 rounded-lg border border-[var(--status-warning-border)] bg-[var(--status-warning-background)] p-3">
<Icon name="error-warning" className="mt-0.5 size-4 shrink-0 text-[var(--status-warning)]" />
<p className="typography-meta text-[var(--status-warning)]">
{t('settings.integrations.experimentalWarning')}
</p>
</div>
</div>
)}
showSaveStatus={false}
>
<ThirdPartyIntegrationsSection
@@ -13,7 +13,6 @@ 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 { ProviderLogo } from '@/components/ui/ProviderLogo';
import { useI18n } from '@/lib/i18n';
import { openExternalUrl } from '@/lib/url';
import { cn } from '@/lib/utils';
@@ -330,11 +329,7 @@ export const ThirdPartyIntegrationsSection: React.FC<ThirdPartyIntegrationsSecti
className="flex w-full min-w-0 items-center gap-3 px-4 py-3 text-left hover:bg-[var(--interactive-hover)]/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-[var(--interactive-focus-ring)]"
>
<div className="flex size-10 shrink-0 items-center justify-center rounded-[10px] bg-[var(--surface-muted)]">
{plugin.providerId === 'command-code' ? (
<ProviderLogo providerId={plugin.providerId} className="size-5" />
) : (
<Icon name={plugin.icon} className={cn('size-5', plugin.brandClassName)} />
)}
</div>
<div className="min-w-0 flex-1">
<div className="truncate text-sm font-semibold text-foreground">{t(plugin.nameKey)}</div>
@@ -131,11 +131,6 @@ describe('third-party plugin catalog helpers', () => {
packageName: '@openchamber/opencode-claude',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
@@ -25,16 +25,6 @@ export const THIRD_PARTY_PLUGINS: readonly ThirdPartyPluginDefinition[] = [
descriptionKey: 'settings.integrations.thirdParty.opencodeClaude.description',
homepage: 'https://github.com/openchamber/opencode-claude',
},
{
id: 'opencode-commandcode',
packageName: '@openchamber/opencode-commandcode',
providerId: 'command-code',
icon: 'command-code',
brandClassName: 'text-foreground',
nameKey: 'settings.integrations.thirdParty.opencodeCommandcode.name',
descriptionKey: 'settings.integrations.thirdParty.opencodeCommandcode.description',
homepage: 'https://github.com/openchamber/opencode-commandcode',
},
{
id: 'opencode-cursor-oauth',
packageName: '@openchamber/opencode-cursor',
@@ -7,6 +7,7 @@ import { copyTextToClipboard } from '@/lib/clipboard';
import { openExternalUrl } from '@/lib/url';
import { isVSCodeRuntime } from '@/lib/desktop';
import {
selectMcpServersForDirectory,
useMcpConfigStore,
envRecordToArray,
type McpDraft,
@@ -19,7 +20,7 @@ import {
} from './mcpImport';
import { useMcpStore } from '@/stores/useMcpStore';
import { usePendingOpenCodeRestartStore } from '@/stores/usePendingOpenCodeRestartStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { runtimeFetch } from '@/lib/runtime-fetch';
import { cn } from '@/lib/utils';
import { SettingsPageLayout } from '@/components/sections/shared/SettingsPageLayout';
@@ -556,7 +557,6 @@ export const McpPage: React.FC = () => {
);
const {
selectedMcpName,
mcpServers,
mcpDraft,
setMcpDraft,
setSelectedMcp,
@@ -566,7 +566,6 @@ export const McpPage: React.FC = () => {
deleteMcp,
} = useMcpConfigStore(useShallow((s) => ({
selectedMcpName: s.selectedMcpName,
mcpServers: s.mcpServers,
mcpDraft: s.mcpDraft,
setMcpDraft: s.setMcpDraft,
setSelectedMcp: s.setSelectedMcp,
@@ -576,10 +575,12 @@ export const McpPage: React.FC = () => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const currentDirectory = useSettingsDirectory();
const isVSCodeAuthRuntime = React.useMemo(() => isVSCodeRuntime(), []);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory ?? null));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory));
const mcpDiagnostics = useMcpStore((state) => state.getDiagnosticForDirectory(currentDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const connectMcp = useMcpStore((state) => state.connect);
const disconnectMcp = useMcpStore((state) => state.disconnect);
@@ -588,7 +589,8 @@ export const McpPage: React.FC = () => {
const testConnectionMcp = useMcpStore((state) => state.testConnection);
const pendingRestartChanges = usePendingOpenCodeRestartStore((state) => state.changes);
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName) : null;
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, currentDirectory));
const selectedServer = selectedMcpName ? getMcpByName(selectedMcpName, currentDirectory) : null;
const isNewServer = Boolean(mcpDraft && mcpDraft.name === selectedMcpName && !selectedServer);
// ── form state ──
@@ -908,7 +910,7 @@ export const McpPage: React.FC = () => {
};
setIsSaving(true);
try {
const result = isNewServer ? await createMcp(draft) : await updateMcp(name, draft);
const result = isNewServer ? await createMcp(draft, currentDirectory) : await updateMcp(name, draft, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -940,7 +942,7 @@ export const McpPage: React.FC = () => {
const handleDelete = async () => {
if (!selectedMcpName) return;
setIsDeleting(true);
const result = await deleteMcp(selectedMcpName);
const result = await deleteMcp(selectedMcpName, currentDirectory);
if (result.ok) {
await clearPendingMcpAuthContext(authStateKey);
resetTransientAuthState();
@@ -967,7 +969,7 @@ export const McpPage: React.FC = () => {
} else {
await connectMcp(selectedMcpName, currentDirectory);
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (nextStatus?.status === 'connected') {
toast.success(t('settings.mcp.page.toast.connected'));
} else if (nextStatus?.status === 'needs_auth') {
@@ -1029,7 +1031,7 @@ export const McpPage: React.FC = () => {
const actionKey = runtimeActionKey;
let queuedStateKey: string | null = null;
try {
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName]?.status;
const currentStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName]?.status;
authPollStartsFromNeedsAuthRef.current = currentStatus === 'needs_auth' || currentStatus === 'needs_client_registration';
// One implementation for every surface that can authorise; the page
@@ -1237,7 +1239,7 @@ export const McpPage: React.FC = () => {
void (async () => {
authPollAttemptsRef.current += 1;
await refreshStatus({ directory: currentDirectory, silent: true });
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory ?? null)[selectedMcpName];
const nextStatus = useMcpStore.getState().getStatusForDirectory(currentDirectory)[selectedMcpName];
if (!nextStatus) {
return;
@@ -8,10 +8,10 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { selectMcpServersForDirectory, useMcpConfigStore, type McpDraft, type McpServerConfig } from '@/stores/useMcpConfigStore';
import { useShallow } from 'zustand/react/shallow';
import { useMcpStore } from '@/stores/useMcpStore';
import { useDirectoryStore } from '@/stores/useDirectoryStore';
import { useSettingsDirectory } from '@/hooks/useSettingsDirectory';
import { isMobileDeviceViaCSS } from '@/lib/device';
import { cn } from '@/lib/utils';
import { toast } from '@/components/ui';
@@ -65,9 +65,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const { t } = useI18n();
const bgClass = 'bg-background';
const { mcpServers, selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
const { selectedMcpName, setSelectedMcp, setMcpDraft, loadMcpConfigs, deleteMcp } =
useMcpConfigStore(useShallow((s) => ({
mcpServers: s.mcpServers,
selectedMcpName: s.selectedMcpName,
setSelectedMcp: s.setSelectedMcp,
setMcpDraft: s.setMcpDraft,
@@ -75,8 +74,11 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
deleteMcp: s.deleteMcp,
})));
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(currentDirectory ?? null));
// Settings browses whichever project its own selector points at; the app
// stays where it is.
const settingsDirectory = useSettingsDirectory();
const mcpServers = useMcpConfigStore((state) => selectMcpServersForDirectory(state, settingsDirectory));
const mcpStatus = useMcpStore((state) => state.getStatusForDirectory(settingsDirectory));
const refreshStatus = useMcpStore((state) => state.refresh);
const getErrorForDirectory = useMcpStore((state) => state.getErrorForDirectory);
@@ -96,8 +98,8 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
);
React.useEffect(() => {
void loadMcpConfigs();
}, [loadMcpConfigs]);
void loadMcpConfigs({ directory: settingsDirectory });
}, [loadMcpConfigs, settingsDirectory]);
const handleRefresh = React.useCallback(() => {
if (isRefreshingStatus) return;
@@ -106,17 +108,17 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const minSpinPromise = new Promise((resolve) => setTimeout(resolve, 500));
Promise.all([
refreshStatus({ directory: currentDirectory, silent: true }),
refreshStatus({ directory: settingsDirectory, silent: true }),
minSpinPromise,
]).then(() => {
const error = getErrorForDirectory(currentDirectory);
const error = getErrorForDirectory(settingsDirectory);
if (error) {
toast.error(error);
}
}).finally(() => {
setIsRefreshingStatus(false);
});
}, [currentDirectory, getErrorForDirectory, isRefreshingStatus, refreshStatus]);
}, [getErrorForDirectory, isRefreshingStatus, refreshStatus, settingsDirectory]);
const handleCreateNew = () => {
const baseName = 'new-mcp-server';
@@ -151,7 +153,7 @@ export const McpSidebar: React.FC<McpSidebarProps> = ({ onItemSelect }) => {
const handleDelete = async () => {
if (!deleteTarget) return;
setIsDeleting(true);
const result = await deleteMcp(deleteTarget.name);
const result = await deleteMcp(deleteTarget.name, settingsDirectory);
if (result.ok) {
if (result.reloadFailed) {
toast.warning(result.message || `MCP server "${deleteTarget.name}" deleted, but OpenCode reload failed`, {

Some files were not shown because too many files have changed in this diff Show More