diff --git a/.github/workflows/stale.yml b/.github/workflows/stale.yml index 26cef37e..414a56f0 100644 --- a/.github/workflows/stale.yml +++ b/.github/workflows/stale.yml @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 65659752..38d653c2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 9e6ba882..7db3d8bb 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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 ``` diff --git a/bun.lock b/bun.lock index 753e10c4..2b7a26c2 100644 --- a/bun.lock +++ b/bun.lock @@ -30,7 +30,7 @@ "@heroui/theme": "^2.4.23", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "1.18.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=="], diff --git a/package.json b/package.json index d8a591f0..b4143c6a 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/packages/docs/content/docs/de/integrations.mdx b/packages/docs/content/docs/de/integrations.mdx index 42f5972b..38b24051 100644 --- a/packages/docs/content/docs/de/integrations.mdx +++ b/packages/docs/content/docs/de/integrations.mdx @@ -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. diff --git a/packages/docs/content/docs/es/integrations.mdx b/packages/docs/content/docs/es/integrations.mdx index 206e40d8..17582762 100644 --- a/packages/docs/content/docs/es/integrations.mdx +++ b/packages/docs/content/docs/es/integrations.mdx @@ -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. diff --git a/packages/docs/content/docs/es/providers.mdx b/packages/docs/content/docs/es/providers.mdx index 24e5b94f..65526b23 100644 --- a/packages/docs/content/docs/es/providers.mdx +++ b/packages/docs/content/docs/es/providers.mdx @@ -45,6 +45,6 @@ Los inicios de sesión de los proveedores los guarda OpenCode, no OpenChamber, a ## Relacionado -- [Integraciones](/es/integrations/) — usa una suscripción de Claude, 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 diff --git a/packages/docs/content/docs/fr/integrations.mdx b/packages/docs/content/docs/fr/integrations.mdx index df347a34..2628a870 100644 --- a/packages/docs/content/docs/fr/integrations.mdx +++ b/packages/docs/content/docs/fr/integrations.mdx @@ -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. diff --git a/packages/docs/content/docs/fr/providers.mdx b/packages/docs/content/docs/fr/providers.mdx index d79633d8..a3ce0149 100644 --- a/packages/docs/content/docs/fr/providers.mdx +++ b/packages/docs/content/docs/fr/providers.mdx @@ -45,6 +45,6 @@ Les connexions aux fournisseurs sont stockées par OpenCode, pas OpenChamber ; e ## Pages liées -- [Intégrations](/integrations/) — utiliser un abonnement Claude, 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 diff --git a/packages/docs/content/docs/integrations.mdx b/packages/docs/content/docs/integrations.mdx index 14aee1f1..8aef2d38 100644 --- a/packages/docs/content/docs/integrations.mdx +++ b/packages/docs/content/docs/integrations.mdx @@ -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. diff --git a/packages/docs/content/docs/ja/integrations.mdx b/packages/docs/content/docs/ja/integrations.mdx index 02fa8e36..b13ac0cd 100644 --- a/packages/docs/content/docs/ja/integrations.mdx +++ b/packages/docs/content/docs/ja/integrations.mdx @@ -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 で使えるようにします。 diff --git a/packages/docs/content/docs/ja/providers.mdx b/packages/docs/content/docs/ja/providers.mdx index 62f1c663..7e9c0525 100644 --- a/packages/docs/content/docs/ja/providers.mdx +++ b/packages/docs/content/docs/ja/providers.mdx @@ -45,6 +45,6 @@ OpenChamber が何かを行うには、少なくとも 1 つの AI プロバイ ## 関連 -- [統合機能](/integrations/) — Claude、Command Code、Cursor のサブスクリプションをプロバイダーとして使う +- [統合機能](/integrations/) — Claude または Cursor のサブスクリプションをプロバイダーとして使う - [MCP サーバー](/mcp/) — エージェントに追加ツールを加える - [使用量とクォータ](/usage/) — 使った量を追跡する diff --git a/packages/docs/content/docs/ko/integrations.mdx b/packages/docs/content/docs/ko/integrations.mdx index f2589d50..0663879e 100644 --- a/packages/docs/content/docs/ko/integrations.mdx +++ b/packages/docs/content/docs/ko/integrations.mdx @@ -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에서 사용할 수 있게 합니다. diff --git a/packages/docs/content/docs/ko/providers.mdx b/packages/docs/content/docs/ko/providers.mdx index 11d9007f..98fb109d 100644 --- a/packages/docs/content/docs/ko/providers.mdx +++ b/packages/docs/content/docs/ko/providers.mdx @@ -45,6 +45,6 @@ OpenChamber가 무언가를 하려면 먼저 최소한 하나의 AI 공급자가 ## 관련 항목 -- [통합 기능](/ko/integrations/) — Claude, Command Code, Cursor 구독을 공급자로 사용 +- [통합 기능](/ko/integrations/) — Claude 또는 Cursor 구독을 공급자로 사용 - [MCP Servers](/ko/mcp/) — 에이전트에 추가 도구를 제공합니다 - [Usage & Quotas](/ko/usage/) — 사용량을 추적합니다 diff --git a/packages/docs/content/docs/pl/integrations.mdx b/packages/docs/content/docs/pl/integrations.mdx index aca4b269..7e3ddf2c 100644 --- a/packages/docs/content/docs/pl/integrations.mdx +++ b/packages/docs/content/docs/pl/integrations.mdx @@ -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 wymagają 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. diff --git a/packages/docs/content/docs/pl/providers.mdx b/packages/docs/content/docs/pl/providers.mdx index 79d1ad41..4c777176 100644 --- a/packages/docs/content/docs/pl/providers.mdx +++ b/packages/docs/content/docs/pl/providers.mdx @@ -45,6 +45,6 @@ Logowania dostawców są przechowywane przez OpenCode, a nie OpenChamber, więc ## Powiązane -- [Integracje](/pl/integrations/) — używaj subskrypcji Claude, 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ś diff --git a/packages/docs/content/docs/providers.mdx b/packages/docs/content/docs/providers.mdx index df7a7d1e..2505ced4 100644 --- a/packages/docs/content/docs/providers.mdx +++ b/packages/docs/content/docs/providers.mdx @@ -57,6 +57,6 @@ Provider sign-ins are stored by OpenCode, not OpenChamber, so they're shared wit ## Related -- [Integrations](/integrations/) — use a Claude, 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 diff --git a/packages/docs/content/docs/pt-br/integrations.mdx b/packages/docs/content/docs/pt-br/integrations.mdx index 1d66cbda..82ed2e68 100644 --- a/packages/docs/content/docs/pt-br/integrations.mdx +++ b/packages/docs/content/docs/pt-br/integrations.mdx @@ -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. diff --git a/packages/docs/content/docs/pt-br/providers.mdx b/packages/docs/content/docs/pt-br/providers.mdx index b05dc222..70ece24f 100644 --- a/packages/docs/content/docs/pt-br/providers.mdx +++ b/packages/docs/content/docs/pt-br/providers.mdx @@ -45,6 +45,6 @@ Os logins de provedores são armazenados pelo OpenCode, não pelo OpenChamber, e ## Relacionado -- [Integrações](/pt-br/integrations/) — use uma assinatura Claude, 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 diff --git a/packages/docs/content/docs/uk/integrations.mdx b/packages/docs/content/docs/uk/integrations.mdx index b68da46c..d6d075f4 100644 --- a/packages/docs/content/docs/uk/integrations.mdx +++ b/packages/docs/content/docs/uk/integrations.mdx @@ -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. diff --git a/packages/docs/content/docs/uk/providers.mdx b/packages/docs/content/docs/uk/providers.mdx index 4fb61d65..9f2777e5 100644 --- a/packages/docs/content/docs/uk/providers.mdx +++ b/packages/docs/content/docs/uk/providers.mdx @@ -45,6 +45,6 @@ description: Підключайте AI-провайдерів, обирайте ## Пов'язане -- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude, Command Code або Cursor як провайдерів +- [Інтеграції](/uk/integrations/) — використовуйте підписки Claude або Cursor як провайдерів - [MCP Servers](/uk/mcp/) — додайте агентам додаткові інструменти - [Використання та квоти](/uk/usage/) — відстежуйте, скільки ви витратили diff --git a/packages/docs/content/docs/zh-cn/integrations.mdx b/packages/docs/content/docs/zh-cn/integrations.mdx index 94daf884..a5686e3c 100644 --- a/packages/docs/content/docs/zh-cn/integrations.mdx +++ b/packages/docs/content/docs/zh-cn/integrations.mdx @@ -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 中使用。 diff --git a/packages/docs/content/docs/zh-cn/providers.mdx b/packages/docs/content/docs/zh-cn/providers.mdx index 62cf4019..5e81e7d3 100644 --- a/packages/docs/content/docs/zh-cn/providers.mdx +++ b/packages/docs/content/docs/zh-cn/providers.mdx @@ -45,6 +45,6 @@ description: 连接 AI 提供商、选择模型并设置智能体。 ## 相关内容 -- [集成](/zh-cn/integrations/) — 将 Claude、Command Code 或 Cursor 订阅用作提供商 +- [集成](/zh-cn/integrations/) — 将 Claude 或 Cursor 订阅用作提供商 - [MCP Servers](/zh-cn/mcp/) — 为智能体添加额外工具 - [用量与配额](/zh-cn/usage/) — 跟踪你已使用的量 diff --git a/packages/electron/README.md b/packages/electron/README.md index a7763d48..7a306894 100644 --- a/packages/electron/README.md +++ b/packages/electron/README.md @@ -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. diff --git a/packages/electron/main.mjs b/packages/electron/main.mjs index c6fe8edb..9ecbead7 100644 --- a/packages/electron/main.mjs +++ b/packages/electron/main.mjs @@ -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)}`; - 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); - if (process.platform !== 'win32') await fsp.chmod(filePath, 0o600); + try { + await fsp.writeFile(tmp, JSON.stringify(data, null, 2), { encoding: 'utf8', mode: 0o600 }); + if (process.platform !== 'win32') await fsp.chmod(tmp, 0o600); + 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(); diff --git a/packages/electron/package.json b/packages/electron/package.json index 8432487f..b4d03bec 100644 --- a/packages/electron/package.json +++ b/packages/electron/package.json @@ -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", diff --git a/packages/electron/ssh-manager.mjs b/packages/electron/ssh-manager.mjs index 5c0b52ba..390c2a38 100644 --- a/packages/electron/ssh-manager.mjs +++ b/packages/electron/ssh-manager.mjs @@ -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)}`; - await fsp.writeFile(tmp, JSON.stringify(root, null, 2)); - await fsp.rename(tmp, settingsFilePath); + try { + await fsp.writeFile(tmp, JSON.stringify(root, null, 2)); + 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) { - 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}`); + 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); + + 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); diff --git a/packages/electron/ssh-manager.test.mjs b/packages/electron/ssh-manager.test.mjs index eb5d1af7..fdbf84ea 100644 --- a/packages/electron/ssh-manager.test.mjs +++ b/packages/electron/ssh-manager.test.mjs @@ -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'); + }); }); diff --git a/packages/electron/windows-file-replace.mjs b/packages/electron/windows-file-replace.mjs new file mode 100644 index 00000000..b43ad904 --- /dev/null +++ b/packages/electron/windows-file-replace.mjs @@ -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); + } + } +}; diff --git a/packages/electron/windows-file-replace.test.mjs b/packages/electron/windows-file-replace.test.mjs new file mode 100644 index 00000000..a860037d --- /dev/null +++ b/packages/electron/windows-file-replace.test.mjs @@ -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]); +}); diff --git a/packages/ui/package.json b/packages/ui/package.json index 2b224dcb..14eaf104 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -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", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index ff0ffe7c..5c9e8806 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -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} /> + @@ -951,6 +953,7 @@ function App({ apis }: AppProps) { + {!isBootShell && ( <> diff --git a/packages/ui/src/apps/ElectronMiniChatApp.tsx b/packages/ui/src/apps/ElectronMiniChatApp.tsx index 10a993d8..7aed5ba0 100644 --- a/packages/ui/src/apps/ElectronMiniChatApp.tsx +++ b/packages/ui/src/apps/ElectronMiniChatApp.tsx @@ -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) {
+
diff --git a/packages/ui/src/apps/MobileApp.tsx b/packages/ui/src/apps/MobileApp.tsx index b790270c..4d30dc00 100644 --- a/packages/ui/src/apps/MobileApp.tsx +++ b/packages/ui/src/apps/MobileApp.tsx @@ -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); }} /> + {isInitialized ? : null} diff --git a/packages/ui/src/apps/VSCodeApp.tsx b/packages/ui/src/apps/VSCodeApp.tsx index 47b8086c..737a0239 100644 --- a/packages/ui/src/apps/VSCodeApp.tsx +++ b/packages/ui/src/apps/VSCodeApp.tsx @@ -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) {
+
@@ -127,6 +131,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
+ diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx new file mode 100644 index 00000000..ceb2624e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.test.tsx @@ -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) =>
{children}
, + DialogDescription: ({ children }: React.PropsWithChildren) =>

{children}

, + DialogFooter: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogHeader: ({ children }: React.PropsWithChildren) =>
{children}
, + DialogTitle: ({ children }: React.PropsWithChildren) =>

{children}

, +})); + +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( + + + , + ); + + expect(markup).toContain('>Cancel'); + expect(markup).toContain('autofocus=""'); + expect(markup).toContain('>Open once'); + expect(markup).toContain('>Trust and open'); + + settleAppLinkConfirmation('cancel'); + }); +}); diff --git a/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx new file mode 100644 index 00000000..73960a9e --- /dev/null +++ b/packages/ui/src/components/chat/AppLinkConfirmDialog.tsx @@ -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 ( + { + if (!open) { + settle('cancel'); + } + }} + > + + + {t('chat.appLink.confirm.title')} + + {scheme + ? t('chat.appLink.confirm.description', { scheme: `${scheme}://` }) + : t('chat.appLink.confirm.descriptionPlain')} + + +
+ {url} +
+ + + + + +
+
+ ); +}; diff --git a/packages/ui/src/components/chat/ChatContainer.tsx b/packages/ui/src/components/chat/ChatContainer.tsx index a50ea6e7..d7676df7 100644 --- a/packages/ui/src/components/chat/ChatContainer.tsx +++ b/packages/ui/src/components/chat/ChatContainer.tsx @@ -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 ( -
+

{renderDraftTitle( projectLabel @@ -558,6 +566,8 @@ export const ChatContainer: React.FC = ({ // 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 = ({ 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 = ({ // 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 = ({ void ensureSessionRenderable(currentSessionId); }, [currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot, messagesEnabled]); + const composerSlotRef = React.useRef(null); + const previousComposerRectRef = React.useRef(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
; } @@ -1106,82 +1182,37 @@ export const ChatContainer: React.FC = ({ ); } - 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). -
-
- {useCompactDraftLayout && !isDesktopExpandedInput ? : null} -
- {promptReadOnly ? : } -
- {workStatusOverlayMountable ? ( - - ) : null} -
- {workStatusPanelMountable ? ( - - ) : null} -
- ); - } + const sessionSurface = (() => { + if (draftOpen || draftPresentationExiting) { + if (!useCompactDraftLayout || isDesktopExpandedInput) { + return null; + } + return ; + } - if (!currentSessionId) { - return null; - } + if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { + if (sessionMessageLoadState.status === 'error') { + return ( +
+
+
+ +
+

{t('chat.container.sessionLoadError.title')}

+

{t('chat.container.sessionLoadError.description')}

+ +
+
+ ); + } - if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) { - if (sessionMessageLoadState.status === 'error') { - return ( -
- {returnToParentButton} -
-
-
- -
-

{t('chat.container.sessionLoadError.title')}

-

{t('chat.container.sessionLoadError.description')}

- -
-
-
- {promptReadOnly ? : } -
-
- ); - } - return ( -
- {returnToParentButton} -
@@ -1192,20 +1223,18 @@ export const ChatContainer: React.FC = ({
- {item.toolRows.map((row) => { - return ( -
- - - -
- ); - })} + {item.toolRows.map((row) => ( +
+ + + +
+ ))}
- - - + {item.textWidths.map((width, index) => ( + + ))}
@@ -1214,62 +1243,25 @@ export const ChatContainer: React.FC = ({
+ ); + } + + if (sessionMessages.length === 0 && !sessionIsWorking) { + return (
- {promptReadOnly ? : } -
-

- ); - } - - if (sessionMessages.length === 0 && !sessionIsWorking) { - return ( - // No transform here either — same fixed-positioning constraint as the - // draft branch above. -
- {returnToParentButton} -
- {!isDesktopExpandedInput ? ( -
- -
- ) : null} -
-
- {promptReadOnly ? : } -
-
- ); - } + /> + ); + } - return ( -
-
- {returnToParentButton} - = ({ isLoadingOlderPrompts={timelineController.isLoadingOlder} onLoadEarlierPrompts={handleLoadOlderClick} /> + ); + })(); + + return ( +
+
+ {returnToParentButton} + {sessionSurface}
- {!isDesktopExpandedInput && sessionMessages.length > 0 && ( + {!draftLayoutVisible && !isDesktopExpandedInput && sessionMessages.length > 0 && ( )} - {promptReadOnly ? : } + {promptReadOnly ? ( + + ) : ( + + )}
{/* Inside the chat column, not beside it: as a row sibling it took @@ -1327,6 +1338,7 @@ export const ChatContainer: React.FC = ({ visible={showWorkStatusOverlay} sessionId={currentSessionId ?? null} directory={workStatusDirectory ?? null} + repositoryEnabled={!isManagedChatContext} /> ) : null} @@ -1349,6 +1361,7 @@ export const ChatContainer: React.FC = ({ visible={showWorkStatusPanel} sessionId={currentSessionId ?? null} directory={workStatusDirectory ?? null} + repositoryEnabled={!isManagedChatContext} /> ) : null}
diff --git a/packages/ui/src/components/chat/ChatInput.tsx b/packages/ui/src/components/chat/ChatInput.tsx index f822053d..a7befbcf 100644 --- a/packages/ui/src/components/chat/ChatInput.tsx +++ b/packages/ui/src/components/chat/ChatInput.tsx @@ -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 = ({ onOpenSettings, scrollToBottom, active = true }) => { +const ChatInputComponent: React.FC = ({ + onOpenSettings, + scrollToBottom, + active = true, + draftPresentationExiting = false, +}) => { const { t } = useI18n(); // Track if we restored a draft on mount (for text selection) const initialDraftRef = React.useRef(null); @@ -314,6 +323,20 @@ const ChatInputComponent: React.FC = ({ 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( + () => (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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollTo const availableSkills = useSkillsStore((s) => s.skills); const knownSlashNames = React.useMemo(() => { const names = new Set([ - '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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollTo } } - const sendMessageOptions: { + let sendMessageOptions: { target?: NonNullable; + sessionId?: string; + directory?: string; draftSnapshot?: NonNullable; delivery?: 'steer'; - } | undefined = (capturedTarget || capturedDraftSnapshot || delivery) - ? { - ...(capturedTarget ? { target: capturedTarget } : {}), - ...(capturedDraftSnapshot ? { draftSnapshot: capturedDraftSnapshot } : {}), - ...(delivery ? { delivery } : {}), - } - : undefined; + } | 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; + } + if (delivery && sendMessageOptions) sendMessageOptions.delivery = delivery; const preparedDocumentMentions = new Map(); const reservedFilenames = new Set([ @@ -1219,6 +1263,40 @@ const ChatInputComponent: React.FC = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ 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 = ({ onOpenSettings, scrollTo )} style={isMobile && inputBarOffset > 0 ? { marginBottom: `${inputBarOffset}px` } : undefined} > - {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( -
+ {showDesktopDraftPresentation ? ( +

{renderDraftTitle( draftProjectLabel @@ -2645,21 +2741,23 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo ? null : } /> - {!isMobile && showDraftTargetSelectors && selectedDraftProject ? ( - + {!isMobile && (showDraftTargetSelectors || draftPresentationExiting) && selectedDraftProject ? ( +
+ +
) : null} {isMobile && showDraftTargetSelectors && selectedDraftProject ? ( = ({ onOpenSettings, scrollTo }} onFocus={mobileShell.onEditorFocus} onBlur={mobileShell.onEditorBlur} - placeholder={currentSessionId || newSessionDraftOpen - ? inputMode === 'shell' - ? t('chat.chatInput.placeholder.shell') - : t(useCompactChatPlaceholder ? 'chat.chatInput.placeholder.chatCompact' : 'chat.chatInput.placeholder.chat') - : t('chat.chatInput.placeholder.selectSession')} + 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') + : t('chat.chatInput.placeholder.selectSession')} editable={Boolean(currentSessionId || newSessionDraftOpen)} autoCorrect={isMobile} autoCapitalize={isMobile ? 'sentences' : 'none'} @@ -2923,12 +3023,13 @@ const ChatInputComponent: React.FC = ({ onOpenSettings, scrollTo /> ) : null}

- {newSessionDraftOpen && !isDesktopExpanded && !isMobile && !isVSCode && !isMiniChatSurface ? ( + {showDesktopDraftPresentation ? ( submitPresetPrompt(starter.submitText, starter.ref.type)} - className="chat-input-column mt-4" + className={cn('chat-input-column mt-4', draftPresentationClassName)} /> ) : null} + {currentSessionId ? : null} {/* Issue Picker Dialog */} diff --git a/packages/ui/src/components/chat/ChatMessage.tsx b/packages/ui/src/components/chat/ChatMessage.tsx index 5ec9a8ac..3fe0ef62 100644 --- a/packages/ui/src/components/chat/ChatMessage.tsx +++ b/packages/ui/src/components/chat/ChatMessage.tsx @@ -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 = ({ 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 = ({ respectReducedMotion >
-
+ {/* peek: the action row under the bubble is suppressed, so + reserve its gap to the next message here, OUTSIDE the + bubble background. */} +
([]); const containerRef = React.useRef(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
{ itemRefs.current[index] = el; }} @@ -471,13 +481,9 @@ export const CommandAutocomplete = React.forwardRef )}
- {command.description && !isMobile && ( -
- {command.description} -
- )}
+ ); })} {commands.length === 0 && ( diff --git a/packages/ui/src/components/chat/DiffPreview.tsx b/packages/ui/src/components/chat/DiffPreview.tsx index c749b033..2c0fc05a 100644 --- a/packages/ui/src/components/chat/DiffPreview.tsx +++ b/packages/ui/src/components/chat/DiffPreview.tsx @@ -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 ? ( - - ) : ( - {content} - ); +// 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 = ({ content, html, status }) => { + if (status === 'ready' && html !== undefined) { + return ; + } + if (status === 'loading') { + return {content}; + } + return {content}; +}; interface DiffPreviewProps { diff: string; @@ -44,7 +56,7 @@ export const DiffPreview: React.FC = ({ diff, filePath }) => {
{hunk.lines.map((line, lineIdx) => { - const html = highlighted?.[lineCursor]; + const html = highlighted.lines?.[lineCursor]; lineCursor += 1; return (
= ({ diff, filePath }) => { {line.lineNumber || ''}
- +
); @@ -106,7 +118,11 @@ export const WritePreview: React.FC = ({ content, filePath }) {lineIdx + 1}
- +
))} diff --git a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx index cef55209..9192fbc7 100644 --- a/packages/ui/src/components/chat/FileMentionAutocomplete.tsx +++ b/packages/ui/src/components/chat/FileMentionAutocomplete.tsx @@ -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([]); const containerRef = React.useRef(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 { const isSelected = selectedIndex === index; return ( +
{ itemRefs.current[index] = el; }} @@ -470,11 +472,9 @@ export const FileMentionAutocomplete = React.forwardRef
@{agent.name}
- {agent.description && !isMobile ? ( -
{agent.description}
- ) : null}
+
); })} {visibleAgents.length === 2 && normalizedSearchQuery.length === 0 && agents.length > 2 && ( diff --git a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx index 6d4665e2..b8af3e03 100644 --- a/packages/ui/src/components/chat/MarkdownRendererImpl.tsx +++ b/packages/ui/src/components/chat/MarkdownRendererImpl.tsx @@ -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>(); -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 => { - 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((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 = ({ 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); diff --git a/packages/ui/src/components/chat/ModelControls.tsx b/packages/ui/src/components/chat/ModelControls.tsx index 262664b9..f7f9e1f2 100644 --- a/packages/ui/src/components/chat/ModelControls.tsx +++ b/packages/ui/src/components/chat/ModelControls.tsx @@ -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 ( + + {children} + + {description} + + + ); +}; + const asPermissionRuleset = (value: unknown): PermissionRule[] | null => { if (!Array.isArray(value)) { return null; @@ -893,25 +915,29 @@ export const ModelControls: React.FC = ({ ? 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 = ({ continue; } - 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') { + if (currentAgentName !== agent.name) { + setAgent(agent.name); + } + const existingSelection = useSelectionStore.getState().getSessionAgentSelection(currentSessionId) || stickySessionAgentRef.current; + if (!existingSelection) { + saveSessionAgentSelection(currentSessionId, agent.name); + } return 'resolved'; } if (result === 'provider-missing') { @@ -2316,6 +2341,7 @@ export const ModelControls: React.FC = ({ = ({
- + {t('chat.modelControls.thinking')} handleVariantSelect(undefined)}>
@@ -2708,7 +2734,7 @@ export const ModelControls: React.FC = ({
- +
@@ -2746,12 +2772,11 @@ export const ModelControls: React.FC = ({
) : ( sortedAndFilteredAgents.map((agent) => ( - handleAgentChange(agent.name)} - > -
+ + handleAgentChange(agent.name)} + >
= ({ )} /> {capitalizeAgentName(agent.name)}
- {agent.description && ( - - {agent.description} - - )} -
-
+ +
)) )}
diff --git a/packages/ui/src/components/chat/SkillAutocomplete.tsx b/packages/ui/src/components/chat/SkillAutocomplete.tsx index b88fd3f3..a5d05e4a 100644 --- a/packages/ui/src/components/chat/SkillAutocomplete.tsx +++ b/packages/ui/src/components/chat/SkillAutocomplete.tsx @@ -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 { const containerRef = React.useRef(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
{ @@ -157,13 +159,9 @@ export const SkillAutocomplete = React.forwardRef
- {skill.description && !isMobile && ( -
- {skill.description} -
- )}
+ ); }; diff --git a/packages/ui/src/components/chat/SnippetAutocomplete.tsx b/packages/ui/src/components/chat/SnippetAutocomplete.tsx index dbba82fd..87125618 100644 --- a/packages/ui/src/components/chat/SnippetAutocomplete.tsx +++ b/packages/ui/src/components/chat/SnippetAutocomplete.tsx @@ -32,7 +32,7 @@ export const SnippetAutocomplete = React.forwardRef(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([]); diff --git a/packages/ui/src/components/chat/StatusRow.tsx b/packages/ui/src/components/chat/StatusRow.tsx index 66ecde5f..1159e822 100644 --- a/packages/ui/src/components/chat/StatusRow.tsx +++ b/packages/ui/src/components/chat/StatusRow.tsx @@ -306,13 +306,19 @@ export const StatusRow: React.FC = ({ return (
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} > -
+ {/* 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. */} +
{/* Left: Abort status | Working placeholder | leftAccessory */}
{showAssistantStatus && showAbortStatus ? ( diff --git a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx index cd72ed46..54ed84d2 100644 --- a/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx +++ b/packages/ui/src/components/chat/__tests__/issue-2903-subagent-status-line-only.test.tsx @@ -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(''); - 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(''); + expect(emptyIdleBlock).not.toContain(''); }); test('visibility handshake remains as defense-in-depth for background work', () => { diff --git a/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts b/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts new file mode 100644 index 00000000..ff341f3f --- /dev/null +++ b/packages/ui/src/components/chat/__tests__/issue-3036-stale-model.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.test.ts b/packages/ui/src/components/chat/appLinkConfirmation.test.ts new file mode 100644 index 00000000..1fb41557 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.test.ts @@ -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(); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkConfirmation.ts b/packages/ui/src/components/chat/appLinkConfirmation.ts new file mode 100644 index 00000000..d9eea31e --- /dev/null +++ b/packages/ui/src/components/chat/appLinkConfirmation.ts @@ -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 => { + 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((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; diff --git a/packages/ui/src/components/chat/appLinkInteractions.test.ts b/packages/ui/src/components/chat/appLinkInteractions.test.ts new file mode 100644 index 00000000..e32acbd9 --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.test.ts @@ -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(); + + 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 = {}): 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([]); + }); +}); diff --git a/packages/ui/src/components/chat/appLinkInteractions.ts b/packages/ui/src/components/chat/appLinkInteractions.ts new file mode 100644 index 00000000..595e671a --- /dev/null +++ b/packages/ui/src/components/chat/appLinkInteractions.ts @@ -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); + }; +}; diff --git a/packages/ui/src/components/chat/btw/BtwPanel.tsx b/packages/ui/src/components/chat/btw/BtwPanel.tsx new file mode 100644 index 00000000..ecae7ba3 --- /dev/null +++ b/packages/ui/src/components/chat/btw/BtwPanel.tsx @@ -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 ( + + ); + } + + if (panel.creating) { + return ( + +
+ + {t('chat.btw.loading')} +
+
+ ); + } + + 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; + sessionQuestions: ReturnType; + 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, + contentRef: React.RefObject, + contentReady: boolean, +): ((event: React.UIEvent) => 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) => { + 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 }) => ( +
+
+
+ {onTitleClick ? ( + + ) : ( + + +

+ {title} +

+
+ )} +
+ {actions} +
+ {children ? ( + <> + {children} +
+ + ) : null} +
+
+); + +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 = ( +
+ + +
+ ); + + if (collapsed) { + return ( + + ); + } + + return ( + + ); +}; + +/** + * 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 ( + + ); +}; + +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(null); + const contentRef = React.useRef(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 ( + + + + + + ); +}; + +const BtwMessages: React.FC<{ + data: BtwSessionData; + bodyRef: React.RefObject; + contentRef: React.RefObject; + onBodyScroll: (event: React.UIEvent) => void; + maxHeight?: number; +}> = ({ data, bodyRef, contentRef, onBodyScroll, maxHeight }) => { + const { t } = useI18n(); + + if (data.isEmpty) { + return ( +
+ + {t('chat.btw.loading')} +
+ ); + } + + return ( + +
+ {data.messageRecords.map((record, index) => ( + + ))} + {data.sessionQuestions.length > 0 || data.sessionPermissions.length > 0 ? ( +
+ {data.sessionQuestions.map((question) => ( + + ))} + {data.sessionPermissions.map((permission) => ( + + ))} +
+ ) : null} + {/* Always reserve this row so the content does not shift down + by a line when the indicator disappears. */} +
+ + {t('chat.btw.working')} +
+
+
+ ); +}; diff --git a/packages/ui/src/components/chat/btw/useBtwPanelState.ts b/packages/ui/src/components/chat/btw/useBtwPanelState.ts new file mode 100644 index 00000000..6d36f060 --- /dev/null +++ b/packages/ui/src/components/chat/btw/useBtwPanelState.ts @@ -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), + }; +} diff --git a/packages/ui/src/components/chat/chatSurfaceContextValue.ts b/packages/ui/src/components/chat/chatSurfaceContextValue.ts index 30065ad0..74470c17 100644 --- a/packages/ui/src/components/chat/chatSurfaceContextValue.ts +++ b/packages/ui/src/components/chat/chatSurfaceContextValue.ts @@ -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('default'); diff --git a/packages/ui/src/components/chat/composer/DOCUMENTATION.md b/packages/ui/src/components/chat/composer/DOCUMENTATION.md index 667c2db7..f800230b 100644 --- a/packages/ui/src/components/chat/composer/DOCUMENTATION.md +++ b/packages/ui/src/components/chat/composer/DOCUMENTATION.md @@ -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 | diff --git a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts index ae8cc408..d4586591 100644 --- a/packages/ui/src/components/chat/composer/state/useDraftTarget.ts +++ b/packages/ui/src/components/chat/composer/state/useDraftTarget.ts @@ -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(() => ({ + 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); } diff --git a/packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx b/packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx new file mode 100644 index 00000000..f85d77b8 --- /dev/null +++ b/packages/ui/src/components/chat/composer/ui/AutocompleteRowTooltip.tsx @@ -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 ( + {}}> + {children} + {active && delayedActive ? ( + +

{description}

+
+ ) : null} +
+ ); +} diff --git a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx index ba237472..1d6254ea 100644 --- a/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx +++ b/packages/ui/src/components/chat/composer/ui/ComposerAttachmentControls.tsx @@ -93,7 +93,7 @@ export const ComposerAttachmentControls = React.memo(function ComposerAttachment - + { requestAnimationFrame(handlePickLocalFiles); diff --git a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx index e3cefb15..8c44d434 100644 --- a/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx +++ b/packages/ui/src/components/chat/composer/ui/DraftTargetSelectors.tsx @@ -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' ? ( + + ) : projectIconName ? ( ) : ( @@ -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" > - {} + {selectedProject.kind === 'chat' + ? {t('chat.chatInput.chooseProject')} + : } - + {projects.map((project) => ( - {} + ))} @@ -140,7 +144,7 @@ export function DraftTargetSelectors(props: DraftTargetProps) { {selectedBranchLabel ?? t('chat.chatInput.branch')} - + {projectRootBranchOption ? ( {t('chat.chatInput.projectRoot')} @@ -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')} > - {} + {selectedProject.kind === 'chat' + ? {t('chat.chatInput.chooseProject')} + : } {showBranchSelector ? ( @@ -275,7 +281,7 @@ export function MobileDraftTargetSheets( onOpenPickerChange(null); }} > - {} + {project.id === selectedProject.id ? ( ) : null} diff --git a/packages/ui/src/components/chat/fileReferenceStat.test.ts b/packages/ui/src/components/chat/fileReferenceStat.test.ts new file mode 100644 index 00000000..2de4afad --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceStat.test.ts @@ -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); + }); +}); diff --git a/packages/ui/src/components/chat/fileReferenceStat.ts b/packages/ui/src/components/chat/fileReferenceStat.ts new file mode 100644 index 00000000..cbc7c105 --- /dev/null +++ b/packages/ui/src/components/chat/fileReferenceStat.ts @@ -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>(); +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 => { + 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((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; +}; diff --git a/packages/ui/src/components/chat/markdown/markdown-worker.ts b/packages/ui/src/components/chat/markdown/markdown-worker.ts index ab5b6eb6..2feb270a 100644 --- a/packages/ui/src/components/chat/markdown/markdown-worker.ts +++ b/packages/ui/src/components/chat/markdown/markdown-worker.ts @@ -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 diff --git a/packages/ui/src/components/chat/markdown/markdownCore.test.ts b/packages/ui/src/components/chat/markdown/markdownCore.test.ts index 968363d3..9153250d 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.test.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.test.ts @@ -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', () => { diff --git a/packages/ui/src/components/chat/markdown/markdownCore.ts b/packages/ui/src/components/chat/markdown/markdownCore.ts index 2d3cb7a9..822a3168 100644 --- a/packages/ui/src/components/chat/markdown/markdownCore.ts +++ b/packages/ui/src/components/chat/markdown/markdownCore.ts @@ -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 => { +const parseBlock = async ( + block: MarkdownBlock, + imageMode: MarkdownImageMode, +): Promise => { 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; diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index fa0bbfa3..dc870847 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -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 ? (
+ {/* 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 ( - + ); @@ -737,7 +740,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi if (isShellActionPart(part)) { return ( - + ); @@ -752,7 +755,7 @@ const UserMessageBody = React.memo(({ messageId, parts, messageCreatedAt, isMobi } } return ( - +