Merge remote-tracking branch 'origin/main' into feat/german-locale
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Agent Control Tool
|
||||
description: Let an agent manage OpenChamber sessions, worktrees, and scheduled tasks from chat.
|
||||
---
|
||||
|
||||
# Agent Control Tool
|
||||
|
||||
Use the `openchamber` agent tool to manage work in the app directly from chat. It is enabled by default when OpenChamber runs its own local OpenCode server; there is no separate tool to install or shell command to run.
|
||||
|
||||
## What you can ask
|
||||
|
||||
Ask the agent in plain language. For example:
|
||||
|
||||
- "Create a new OpenChamber session in this project, use the `openai/gpt-5.6-sol` model, and send it this prompt: review the authentication flow."
|
||||
- "Create a new OpenChamber session for this task in a separate worktree, and ask it to add tests for the login flow."
|
||||
- "Use OpenChamber to list my 10 most recent sessions and include their current status."
|
||||
- "Create an OpenChamber scheduled task named Weekday review that sends this prompt at 09:00 every weekday: review changes since the last run."
|
||||
- "Run the OpenChamber scheduled task named Weekday review now."
|
||||
- "Check the OpenChamber session named Authentication review and show me its latest assistant response."
|
||||
|
||||
The tool can list projects and model preferences, create and follow up on sessions, fork a session, create isolated worktree sessions, and manage scheduled tasks. Sessions started this way appear in OpenChamber like any other session, so you can open them and continue the work yourself.
|
||||
|
||||
## Keep in mind
|
||||
|
||||
- New session prompts return immediately by default. Follow the session in OpenChamber, or ask the agent to check it later.
|
||||
- A separate worktree is only created when you ask for one. Uncommitted changes from your current worktree are not copied into it.
|
||||
- The tool cannot delete sessions or worktrees, register project paths, run arbitrary shell commands, or call arbitrary URLs.
|
||||
|
||||
## Turn the tool on or off
|
||||
|
||||
Open **Settings → General → OpenCode CLI**, change **Agent control tool**, then select **Save + Reload**. The setting applies after the managed OpenCode server restarts.
|
||||
|
||||
The tool is not available when OpenChamber connects to an external OpenCode server through `OPENCODE_HOST` or skip-start, or inside the VS Code extension. Desktop and web installations that use OpenChamber's managed OpenCode server support it automatically.
|
||||
|
||||
## Related
|
||||
|
||||
- [Scheduled Tasks](/scheduled-tasks/)
|
||||
- [Worktree Sessions](/worktrees/)
|
||||
- [Session Goals](/session-goals/)
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Herramienta de control para agentes
|
||||
description: Permite que un agente gestione sesiones, worktrees y tareas programadas de OpenChamber desde el chat.
|
||||
---
|
||||
|
||||
# Herramienta de control para agentes
|
||||
|
||||
Usa la herramienta de agente `openchamber` para gestionar el trabajo en la aplicación directamente desde el chat. Está activada de forma predeterminada cuando OpenChamber ejecuta su propio servidor OpenCode local; no necesitas instalar otra herramienta ni ejecutar un comando de shell.
|
||||
|
||||
## Qué puedes pedir
|
||||
|
||||
Pídeselo al agente con lenguaje natural. Por ejemplo:
|
||||
|
||||
- «Crea una sesión nueva de OpenChamber en este proyecto, usa el modelo `openai/gpt-5.6-sol` y envíale este prompt: revisa el flujo de autenticación».
|
||||
- «Crea una sesión nueva de OpenChamber para esta tarea en un worktree separado y pídele que añada pruebas para el flujo de inicio de sesión».
|
||||
- «Usa OpenChamber para mostrar mis 10 sesiones más recientes e incluye su estado actual».
|
||||
- «Crea en OpenChamber una tarea programada llamada Revisión laborable que envíe este prompt a las 09:00 de cada día laborable: revisa los cambios desde la última ejecución».
|
||||
- «Ejecuta ahora la tarea programada de OpenChamber llamada Revisión laborable».
|
||||
- «Comprueba la sesión de OpenChamber llamada Revisión de autenticación y muestra la última respuesta del asistente».
|
||||
|
||||
La herramienta puede listar proyectos y preferencias de modelos, crear y continuar sesiones, bifurcar una sesión, crear sesiones en worktrees aislados y gestionar tareas programadas. Las sesiones iniciadas así aparecen en OpenChamber como cualquier otra, por lo que puedes abrirlas y continuar el trabajo personalmente.
|
||||
|
||||
## Ten en cuenta
|
||||
|
||||
- Los prompts de sesiones nuevas regresan de inmediato de forma predeterminada. Sigue la sesión en OpenChamber o pide al agente que la compruebe más tarde.
|
||||
- Solo se crea un worktree separado cuando lo pides. Los cambios sin confirmar de tu worktree actual no se copian.
|
||||
- La herramienta no puede eliminar sesiones ni worktrees, registrar rutas de proyectos, ejecutar comandos de shell arbitrarios ni acceder a URL arbitrarias.
|
||||
|
||||
## Activar o desactivar la herramienta
|
||||
|
||||
Abre **Ajustes → General → OpenCode CLI**, cambia **Herramienta de control para agentes** y selecciona **Save + Reload**. El ajuste se aplica cuando se reinicia el servidor OpenCode gestionado.
|
||||
|
||||
La herramienta no está disponible cuando OpenChamber se conecta a un servidor OpenCode externo mediante `OPENCODE_HOST` o skip-start, ni dentro de la extensión de VS Code. Las instalaciones web y de escritorio que usan el servidor OpenCode gestionado por OpenChamber la admiten automáticamente.
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Tareas programadas](/es/scheduled-tasks/)
|
||||
- [Sesiones de worktree](/es/worktrees/)
|
||||
- [Objetivos de sesión](/es/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Recorrido por los cambios
|
||||
description: Lee un diff en el orden que tiene sentido, no en orden alfabético.
|
||||
---
|
||||
|
||||
# Recorrido por los cambios
|
||||
|
||||
Un diff está ordenado por ruta de archivo, que casi nunca es el orden en el que el cambio cobra sentido. El recorrido lo reordena: las ediciones relacionadas se agrupan en **paradas**, cada parada explica qué hace ahora el código de forma distinta, y las paradas se ordenan para que cada una se apoye en la anterior.
|
||||
|
||||
Explica y ordena. No juzga tu código ni emite veredictos — para eso está [Review](/git/).
|
||||
|
||||
Ábrelo con el icono **Recorrido** en la barra derecha, o con el botón **Recorrido con IA** en los paneles de cambios y de pull request. Ambos solo abren el panel; no se genera nada hasta que pulsas **Generar recorrido**.
|
||||
|
||||
## Qué puede recorrer
|
||||
|
||||
| Ámbito | Qué incluye |
|
||||
| --- | --- |
|
||||
| Todo sin confirmar | Todo lo que aún no está en un commit: preparado, sin preparar y archivos nuevos |
|
||||
| Preparados | Solo lo que iría a un commit ahora mismo |
|
||||
| Sin preparar | Árbol de trabajo y archivos nuevos |
|
||||
| Esta rama | Todos los commits de la rama que no están en su base |
|
||||
| Pull request | El cambio tal como existe en GitHub |
|
||||
|
||||
**Esta rama** no significa "commits sin subir": es todo lo que la rama añade a su base, se haya subido o no. Por eso, tras hacer commit pero antes de subirlo, esta y el pull request difieren a propósito: una muestra lo que hiciste, el otro lo que ven ahora quienes revisan.
|
||||
|
||||
Cada ámbito se guarda por separado, así que cambiar entre ellos nunca pierde nada.
|
||||
|
||||
## Elegir el modelo
|
||||
|
||||
Los recorridos usan tu modelo pequeño por defecto. Elige otro en **Ajustes → Sesiones → Modelo del recorrido de cambios**, o solo para una revisión desde la cabecera del panel — útil cuando un cambio es lo bastante delicado como para merecer un modelo más potente.
|
||||
|
||||
El selector solo ofrece modelos capaces de devolver salida estructurada, porque sin ella el recorrido no se puede montar. Si un modelo se queda corto para el diff, la generación se rechaza con una explicación en vez de recortar la entrada en silencio: un recorrido escrito sobre medio diff suena seguro y se equivoca.
|
||||
|
||||
Al reabrir el panel verás el modelo que produjo lo que tienes delante, así que **Regenerar** repite con el mismo salvo que lo cambies.
|
||||
|
||||
## Elegir el idioma
|
||||
|
||||
Los recorridos se escriben en el idioma de tu interfaz por defecto. El selector de idioma de la cabecera del panel arranca ahí, y puedes elegir cualquier otro idioma al que esté traducido OpenChamber para una sola revisión: una explicación guiada solo sirve en un idioma que leas con soltura.
|
||||
|
||||
Solo se traduce la prosa. Los identificadores, las rutas de archivo y los nombres de API se quedan tal cual aparecen en tu código, así que lo que nombra una parada sigue siendo lo que puedes buscar.
|
||||
|
||||
Si todavía no hay nada generado en el idioma que elegiste, el panel no se vacía: sigue mostrando el recorrido que tiene y lo dice. Pulsa **Generar recorrido** para obtenerlo en el idioma nuevo.
|
||||
|
||||
## Coste y caché
|
||||
|
||||
Nada se genera por su cuenta. La generación solo empieza cuando la pides, y regenerar también es manual.
|
||||
|
||||
Los resultados se guardan en caché según el contenido exacto del diff. Devuelve el árbol de trabajo a un estado anterior y el recorrido anterior vuelve gratis, sin llamar al modelo. El idioma y el modelo forman parte de esa clave, así que cada combinación se guarda por separado: cuando un diff ya tiene recorrido en dos idiomas, cambiar entre ellos es instantáneo y no cuesta nada.
|
||||
|
||||
La generación se ejecuta en el servidor de OpenChamber, no en la pestaña del navegador. Recarga la página o cierra el panel y continúa; al volver, el resultado te espera. Solo **Cancelar** la detiene.
|
||||
|
||||
## Ser honesto sobre lo desactualizado
|
||||
|
||||
Cada parada está anclada al contenido exacto del código que describe, así que el panel puede avisarte cuando ese código ha cambiado:
|
||||
|
||||
- **Pasos desactualizados** — el código que describía una parada cambió o desapareció. El recorrido se sigue mostrando, marcado, para que decidas si regenerar.
|
||||
- **Sin cubrir** — cambios del diff actual que ninguna parada describe. Ahí entran las ediciones hechas después de generar, los cambios que el recorrido consideró rutinarios y los archivos de bloqueo u otra salida generada, que se dejan fuera del modelo a propósito. Todo aparece al final para que nada desaparezca en silencio.
|
||||
|
||||
Regenerar no parchea, reescribe: el recorrido anterior va al modelo como contexto, así que lo que sigue siendo cierto se conserva, y todo se reancla al código actual.
|
||||
|
||||
## Notas
|
||||
|
||||
- Puedes comentar cualquier línea igual que en la vista de diff; los comentarios se adjuntan al campo del chat.
|
||||
- Disponible en escritorio y anchos de tableta. No se ofrece en la extensión de VS Code ni en la app móvil.
|
||||
- Recorrer un pull request requiere una cuenta de GitHub conectada — consulta [Issues y PR de GitHub](/github/).
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Git y GitHub](/git/) — el panel de cambios del que lee, y la acción Review que sí juzga el código
|
||||
- [Issues y PR de GitHub](/github/) — conecta GitHub para recorrer pull requests
|
||||
- [Proveedores, modelos y agentes](/providers/) — de dónde sale el modelo pequeño
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Outil de contrôle pour les agents
|
||||
description: Permettez à un agent de gérer les sessions, worktrees et tâches planifiées OpenChamber depuis le chat.
|
||||
---
|
||||
|
||||
# Outil de contrôle pour les agents
|
||||
|
||||
Utilisez l’outil d’agent `openchamber` pour gérer le travail dans l’application directement depuis le chat. Il est activé par défaut quand OpenChamber exécute son propre serveur OpenCode local ; aucun outil séparé ni aucune commande shell ne sont nécessaires.
|
||||
|
||||
## Ce que vous pouvez demander
|
||||
|
||||
Demandez-le à l’agent en langage naturel. Par exemple :
|
||||
|
||||
- « Crée une nouvelle session OpenChamber dans ce projet, utilise le modèle `openai/gpt-5.6-sol` et envoie-lui ce prompt : vérifie le flux d’authentification. »
|
||||
- « Crée une nouvelle session OpenChamber pour cette tâche dans un worktree séparé et demande-lui d’ajouter des tests au flux de connexion. »
|
||||
- « Utilise OpenChamber pour afficher mes 10 sessions les plus récentes avec leur état actuel. »
|
||||
- « Crée dans OpenChamber une tâche planifiée nommée Revue des jours ouvrés qui envoie ce prompt à 09:00 chaque jour ouvré : vérifie les changements depuis la dernière exécution. »
|
||||
- « Exécute maintenant la tâche planifiée OpenChamber nommée Revue des jours ouvrés. »
|
||||
- « Vérifie la session OpenChamber nommée Revue de l’authentification et affiche la dernière réponse de l’assistant. »
|
||||
|
||||
L’outil peut répertorier les projets et les préférences de modèles, créer et poursuivre des sessions, bifurquer une session, créer des sessions dans des worktrees isolés et gérer les tâches planifiées. Les sessions ainsi lancées apparaissent dans OpenChamber comme les autres : vous pouvez les ouvrir et poursuivre le travail vous-même.
|
||||
|
||||
## À retenir
|
||||
|
||||
- Les prompts de nouvelles sessions rendent la main immédiatement par défaut. Suivez la session dans OpenChamber ou demandez à l’agent de la vérifier plus tard.
|
||||
- Un worktree séparé n’est créé que si vous le demandez. Les modifications non commitées du worktree actuel n’y sont pas copiées.
|
||||
- L’outil ne peut pas supprimer de sessions ou de worktrees, enregistrer des chemins de projets, exécuter des commandes shell arbitraires ni appeler des URL arbitraires.
|
||||
|
||||
## Activer ou désactiver l’outil
|
||||
|
||||
Ouvrez **Paramètres → Général → OpenCode CLI**, modifiez **Outil de contrôle pour les agents**, puis sélectionnez **Save + Reload**. Le réglage s’applique après le redémarrage du serveur OpenCode géré.
|
||||
|
||||
L’outil n’est pas disponible quand OpenChamber se connecte à un serveur OpenCode externe avec `OPENCODE_HOST` ou skip-start, ni dans l’extension VS Code. Les installations desktop et web utilisant le serveur OpenCode géré par OpenChamber le prennent automatiquement en charge.
|
||||
|
||||
## Pages associées
|
||||
|
||||
- [Tâches planifiées](/fr/scheduled-tasks/)
|
||||
- [Sessions worktree](/fr/worktrees/)
|
||||
- [Objectifs de session](/fr/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Parcours des modifications
|
||||
description: Lisez un diff dans l’ordre qui a du sens, pas dans l’ordre alphabétique.
|
||||
---
|
||||
|
||||
# Parcours des modifications
|
||||
|
||||
Un diff est trié par chemin de fichier, ce qui n’est presque jamais l’ordre dans lequel la modification prend son sens. Le parcours le réorganise : les changements liés sont regroupés en **étapes**, chaque étape explique ce que le code fait désormais différemment, et les étapes s’enchaînent pour que chacune s’appuie sur la précédente.
|
||||
|
||||
Il explique et ordonne. Il ne juge pas votre code et ne rend aucun verdict — c’est le rôle de [Review](/git/).
|
||||
|
||||
Ouvrez-le par l’icône **Parcours** dans la barre de droite, ou par le bouton **Parcours IA** dans les panneaux des modifications et de la pull request. Les deux se contentent d’ouvrir le panneau ; rien n’est généré tant que vous n’appuyez pas sur **Générer le parcours**.
|
||||
|
||||
## Ce qu’il peut parcourir
|
||||
|
||||
| Portée | Ce qu’elle couvre |
|
||||
| --- | --- |
|
||||
| Tout non validé | Tout ce qui n’est pas encore dans un commit : indexé, non indexé et nouveaux fichiers |
|
||||
| Indexées | Uniquement ce qui partirait dans un commit maintenant |
|
||||
| Non indexées | Copie de travail et nouveaux fichiers |
|
||||
| Cette branche | Tous les commits de la branche absents de sa base |
|
||||
| Pull request | La modification telle qu’elle existe sur GitHub |
|
||||
|
||||
**Cette branche** ne veut pas dire « commits non poussés » : c’est tout ce que la branche ajoute à sa base, poussé ou non. Après un commit mais avant un push, elle et la pull request diffèrent donc volontairement : l’une montre ce que vous avez fait, l’autre ce que voient les relecteurs.
|
||||
|
||||
Chaque portée est stockée séparément : passer de l’une à l’autre ne perd jamais rien.
|
||||
|
||||
## Choisir le modèle
|
||||
|
||||
Les parcours utilisent votre petit modèle par défaut. Choisissez-en un autre dans **Paramètres → Sessions → Modèle du parcours des modifications**, ou pour une seule relecture depuis l’en-tête du panneau — utile quand une modification est assez risquée pour mériter un modèle plus solide.
|
||||
|
||||
Le sélecteur ne propose que des modèles capables de sortie structurée, sans laquelle le parcours ne peut pas être assemblé. Si un modèle est trop petit pour le diff, la génération est refusée avec une explication plutôt que de tronquer l’entrée en silence : un parcours écrit sur la moitié d’un diff sonne assuré et se trompe.
|
||||
|
||||
En rouvrant le panneau, vous voyez le modèle qui a produit ce que vous avez sous les yeux ; **Régénérer** reprend donc le même tant que vous n’en changez pas.
|
||||
|
||||
## Choisir la langue
|
||||
|
||||
Les parcours sont rédigés dans la langue de votre interface par défaut. Le sélecteur de langue dans l’en-tête du panneau démarre là, et vous pouvez choisir n’importe quelle autre langue dans laquelle OpenChamber est traduit pour une seule relecture : une explication guidée ne sert que dans une langue que vous lisez à l’aise.
|
||||
|
||||
Seule la prose est traduite. Les identifiants, les chemins de fichiers et les noms d’API restent exactement tels qu’ils apparaissent dans votre code, si bien que ce qu’une étape nomme reste ce que vous pouvez rechercher.
|
||||
|
||||
Si rien n’a encore été généré dans la langue choisie, le panneau ne se vide pas : il continue d’afficher le parcours qu’il a et le signale. Appuyez sur **Générer le parcours** pour l’obtenir dans la nouvelle langue.
|
||||
|
||||
## Coût et cache
|
||||
|
||||
Rien ne se génère tout seul. La génération ne démarre que sur votre demande, et la régénération est manuelle elle aussi.
|
||||
|
||||
Les résultats sont mis en cache d’après le contenu exact du diff. Ramenez la copie de travail à un état antérieur et le parcours d’alors revient gratuitement, sans appel au modèle. La langue et le modèle font partie de cette clé, donc chaque combinaison est conservée séparément : dès qu’un diff a un parcours en deux langues, passer de l’une à l’autre est instantané et gratuit.
|
||||
|
||||
La génération tourne sur le serveur OpenChamber, pas dans votre onglet. Rechargez la page ou fermez le panneau : le travail continue et le résultat vous attend. Seul **Annuler** l’interrompt.
|
||||
|
||||
## Rester honnête sur l’obsolescence
|
||||
|
||||
Chaque étape est ancrée au contenu exact du code qu’elle décrit, ce qui permet au panneau de signaler quand ce code a bougé :
|
||||
|
||||
- **Étapes obsolètes** — le code décrit par une étape a changé ou disparu. Le parcours reste affiché, marqué, pour que vous décidiez s’il faut régénérer.
|
||||
- **Non traité** — des modifications du diff actuel qu’aucune étape ne décrit. On y trouve les changements faits après la génération, ceux que le parcours a jugés courants, ainsi que les fichiers de verrouillage et autres sorties générées, délibérément tenus hors du modèle. Tout est listé à la fin pour que rien ne disparaisse en silence.
|
||||
|
||||
Régénérer ne rapièce pas, cela réécrit : le parcours précédent est fourni au modèle comme contexte, ce qui reste vrai est conservé, et tout est réancré sur le code actuel.
|
||||
|
||||
## Notes
|
||||
|
||||
- Vous pouvez commenter n’importe quelle ligne comme dans la vue diff ; les commentaires se rattachent au champ du chat.
|
||||
- Disponible sur ordinateur et sur les largeurs de tablette. Non proposé dans l’extension VS Code ni dans l’application mobile.
|
||||
- Parcourir une pull request exige un compte GitHub connecté — voir [Issues et PR GitHub](/github/).
|
||||
|
||||
## Voir aussi
|
||||
|
||||
- [Git et GitHub](/git/) — le panneau des modifications qu’il lit, et l’action Review qui, elle, juge le code
|
||||
- [Issues et PR GitHub](/github/) — connectez GitHub pour parcourir les pull requests
|
||||
- [Fournisseurs, modèles et agents](/providers/) — d’où vient le petit modèle
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: エージェント制御ツール
|
||||
description: エージェントがチャットから OpenChamber のセッション、worktree、スケジュールタスクを管理できるようにします。
|
||||
---
|
||||
|
||||
# エージェント制御ツール
|
||||
|
||||
`openchamber` エージェントツールを使うと、チャットからアプリ内の作業を直接管理できます。OpenChamber が独自のローカル OpenCode サーバーを実行している場合はデフォルトで有効になり、別のツールのインストールや shell コマンドの実行は必要ありません。
|
||||
|
||||
## 依頼できること
|
||||
|
||||
自然な言葉でエージェントに依頼します。例:
|
||||
|
||||
- 「このプロジェクトに新しい OpenChamber セッションを作成し、`openai/gpt-5.6-sol` モデルを使って次のプロンプトを送信して:認証フローをレビューして。」
|
||||
- 「このタスク用の新しい OpenChamber セッションを別の worktree に作成し、ログインフローのテストを追加するよう依頼して。」
|
||||
- 「OpenChamber を使って最近のセッション 10 件とそれぞれの現在の状態を表示して。」
|
||||
- 「OpenChamber に平日レビューというスケジュールタスクを作成し、平日の 09:00 に次のプロンプトを送信して:前回の実行以降の変更をレビューして。」
|
||||
- 「平日レビューという OpenChamber スケジュールタスクを今すぐ実行して。」
|
||||
- 「認証レビューという OpenChamber セッションを確認し、最新のアシスタント応答を表示して。」
|
||||
|
||||
このツールは、プロジェクトとモデル設定の一覧表示、セッションの作成と継続、セッションのフォーク、分離された worktree セッションの作成、スケジュールタスクの管理を行えます。この方法で開始したセッションも通常のセッションと同じように OpenChamber に表示されるため、自分で開いて作業を続けられます。
|
||||
|
||||
## 注意点
|
||||
|
||||
- 新しいセッションへのプロンプトは、デフォルトではすぐに処理を返します。OpenChamber でセッションを確認するか、後でエージェントに結果を確認するよう依頼してください。
|
||||
- 別の worktree は、明示的に依頼した場合にのみ作成されます。現在の worktree にある未コミットの変更はコピーされません。
|
||||
- このツールは、セッションや worktree の削除、プロジェクトパスの登録、任意の shell コマンドの実行、任意の URL の呼び出しはできません。
|
||||
|
||||
## ツールを有効または無効にする
|
||||
|
||||
**設定 → 一般 → OpenCode CLI** を開き、**エージェント制御ツール**を変更して、**Save + Reload** を選択します。この設定は、管理対象の OpenCode サーバーが再起動した後に反映されます。
|
||||
|
||||
OpenChamber が `OPENCODE_HOST` または skip-start で外部 OpenCode サーバーに接続している場合や、VS Code 拡張機能内では、このツールを利用できません。OpenChamber が管理する OpenCode サーバーを使用するデスクトップ版と Web 版では自動的に利用できます。
|
||||
|
||||
## 関連項目
|
||||
|
||||
- [スケジュールタスク](/ja/scheduled-tasks/)
|
||||
- [Worktree セッション](/ja/worktrees/)
|
||||
- [セッションゴール](/ja/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: 変更のウォークスルー
|
||||
description: 差分をアルファベット順ではなく、意味の通る順序で読みます。
|
||||
---
|
||||
|
||||
# 変更のウォークスルー
|
||||
|
||||
差分はファイルパス順に並びますが、それは変更の意味が通る順序であることはほとんどありません。ウォークスルーはこれを並べ替えます。関連する編集を**ステップ**にまとめ、各ステップはコードが今までと何が違う動きをするのかを説明し、前のステップの上に次が積み上がる順序で並びます。
|
||||
|
||||
説明し、順序を与えるものです。コードを評価したり判定を下したりはしません。それは [Review](/git/) の役割です。
|
||||
|
||||
右側のレールの**ウォークスルー**アイコン、または変更パネルとプルリクエストパネルの **AI ウォークスルー**ボタンから開きます。どちらもパネルを開くだけで、**ウォークスルーを生成**を押すまで何も生成されません。
|
||||
|
||||
## 対象にできる範囲
|
||||
|
||||
| 範囲 | 含まれるもの |
|
||||
| --- | --- |
|
||||
| 未コミットすべて | まだコミットされていないもの全部: ステージ済み、未ステージ、新規ファイル |
|
||||
| ステージ済み | 今コミットすれば入るものだけ |
|
||||
| 未ステージ | 作業ツリーと新規ファイル |
|
||||
| このブランチ | ベースに無い、このブランチのすべてのコミット |
|
||||
| プルリクエスト | GitHub 上に存在する形の変更 |
|
||||
|
||||
**このブランチ**は「未プッシュのコミット」ではなく、プッシュの有無に関わらずブランチがベースに追加したすべてです。そのためコミット後・プッシュ前には、これとプルリクエストは意図的に食い違います。前者はあなたが何をしたかを、後者はレビュアーが今何を見ているかを示します。
|
||||
|
||||
範囲ごとに別々に保存されるため、切り替えても失われるものはありません。
|
||||
|
||||
## モデルの選択
|
||||
|
||||
ウォークスルーは既定でスモールモデルを使います。**設定 → セッション → 変更ウォークスルーのモデル**で別のモデルを選べます。一度だけならパネルのヘッダーからも選べます。リスクの高い変更を、より強いモデルに任せたいときに便利です。
|
||||
|
||||
選択肢に出るのは構造化出力を返せるモデルだけです。それが無ければウォークスルーは組み立てられません。差分に対してモデルが小さすぎる場合は、入力を黙って切り詰めるのではなく、理由を示して生成を拒否します。差分の半分だけを見て書かれたウォークスルーは、自信ありげに間違えるからです。
|
||||
|
||||
パネルを開き直すと、目の前の内容を生成したモデルが表示されます。したがって**再生成**は、変更しない限り同じモデルで繰り返します。
|
||||
|
||||
## 言語の選択
|
||||
|
||||
ウォークスルーは既定でインターフェースの言語で書かれます。パネル上部の言語セレクターはその言語から始まり、1 回のレビューだけ OpenChamber が翻訳されている他の言語に切り替えることもできます。案内は、無理なく読める言語でなければ意味がありません。
|
||||
|
||||
翻訳されるのは文章だけです。識別子、ファイルパス、API 名はコードにあるままなので、ストップが指し示すものはそのまま検索できます。
|
||||
|
||||
選んだ言語でまだ何も生成されていない場合、パネルは空にならず、手元にあるウォークスルーを表示したままその旨を伝えます。**ウォークスルーを生成** を押すと、新しい言語で生成されます。
|
||||
|
||||
## コストとキャッシュ
|
||||
|
||||
勝手に生成されることはありません。生成はあなたが求めたときだけ始まり、再生成も手動です。
|
||||
|
||||
結果は差分の正確な内容に対してキャッシュされます。作業ツリーを以前の状態に戻せば、そのときのウォークスルーがモデル呼び出し無しで戻ります。言語とモデルもこのキーの一部なので、組み合わせごとに別々に保存されます。ある差分に 2 つの言語のウォークスルーができれば、その切り替えは即座で無料です。
|
||||
|
||||
生成はブラウザのタブではなく OpenChamber サーバー上で動きます。ページを再読み込みしてもパネルを閉じても処理は続き、戻れば結果が待っています。止められるのは**キャンセル**だけです。
|
||||
|
||||
## 古くなったことを正直に示す
|
||||
|
||||
各ステップは説明対象のコードの正確な内容に紐づいているため、そのコードが動いたことをパネルが伝えられます。
|
||||
|
||||
- **古くなったステップ** — ステップが説明していたコードが変わった、あるいは無くなった。ウォークスルーは印を付けたまま表示され、再生成するかはあなたが決めます。
|
||||
- **未対応** — 現在の差分のうち、どのステップも説明していない変更。生成後に加えた編集、ウォークスルーが定型的と判断した変更、そして意図的にモデルへ渡していないロックファイルなどの生成物が含まれます。すべて末尾に一覧されるので、黙って消えるものはありません。
|
||||
|
||||
再生成は継ぎ当てではなく書き直しです。前回のウォークスルーが文脈としてモデルに渡るため、まだ正しい部分は残り、すべてが現在のコードに紐づけ直されます。
|
||||
|
||||
## 補足
|
||||
|
||||
- 差分ビューと同じように任意の行にコメントできます。コメントはチャットの入力欄に添付されます。
|
||||
- デスクトップとタブレット幅で利用できます。VS Code 拡張とモバイルアプリでは提供されません。
|
||||
- プルリクエストのウォークスルーには GitHub アカウントの接続が必要です。[GitHub の Issue と PR](/github/) を参照してください。
|
||||
|
||||
## 関連
|
||||
|
||||
- [Git と GitHub](/git/) — 読み取り元となる変更パネルと、実際にコードを評価する Review アクション
|
||||
- [GitHub の Issue と PR](/github/) — プルリクエストを扱うために GitHub を接続する
|
||||
- [プロバイダー・モデル・エージェント](/providers/) — スモールモデルの出どころ
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 에이전트 제어 도구
|
||||
description: 에이전트가 채팅에서 OpenChamber 세션, worktree, 예약 작업을 관리하도록 허용합니다.
|
||||
---
|
||||
|
||||
# 에이전트 제어 도구
|
||||
|
||||
`openchamber` 에이전트 도구를 사용하면 채팅에서 앱의 작업을 바로 관리할 수 있습니다. OpenChamber가 자체 로컬 OpenCode 서버를 실행할 때 기본으로 활성화되며, 별도 도구를 설치하거나 shell 명령을 실행할 필요가 없습니다.
|
||||
|
||||
## 요청할 수 있는 작업
|
||||
|
||||
자연어로 에이전트에게 요청하세요. 예를 들면 다음과 같습니다.
|
||||
|
||||
- “이 프로젝트에 새 OpenChamber 세션을 만들고 `openai/gpt-5.6-sol` 모델을 사용해서 다음 프롬프트를 보내 줘: 인증 흐름을 검토해.”
|
||||
- “이 작업을 위한 새 OpenChamber 세션을 별도의 worktree에 만들고 로그인 흐름 테스트를 추가하도록 요청해 줘.”
|
||||
- “OpenChamber를 사용해서 최근 세션 10개와 각 세션의 현재 상태를 보여 줘.”
|
||||
- “OpenChamber에 평일 검토라는 예약 작업을 만들고 평일마다 09:00에 다음 프롬프트를 보내 줘: 마지막 실행 이후의 변경 사항을 검토해.”
|
||||
- “평일 검토라는 OpenChamber 예약 작업을 지금 실행해 줘.”
|
||||
- “인증 검토라는 OpenChamber 세션을 확인하고 최신 어시스턴트 응답을 보여 줘.”
|
||||
|
||||
이 도구는 프로젝트와 모델 기본 설정을 나열하고, 세션을 만들거나 이어서 작업하고, 세션을 포크하고, 격리된 worktree 세션을 만들며, 예약 작업을 관리할 수 있습니다. 이렇게 시작한 세션도 일반 세션처럼 OpenChamber에 표시되므로 직접 열어 작업을 계속할 수 있습니다.
|
||||
|
||||
## 알아둘 점
|
||||
|
||||
- 새 세션 프롬프트는 기본적으로 즉시 반환됩니다. OpenChamber에서 세션을 확인하거나 나중에 에이전트에게 결과를 확인해 달라고 요청하세요.
|
||||
- 별도의 worktree는 요청한 경우에만 생성됩니다. 현재 worktree의 커밋하지 않은 변경 사항은 복사되지 않습니다.
|
||||
- 이 도구는 세션이나 worktree 삭제, 프로젝트 경로 등록, 임의의 shell 명령 실행 또는 임의 URL 호출을 할 수 없습니다.
|
||||
|
||||
## 도구 켜기 또는 끄기
|
||||
|
||||
**설정 → 일반 → OpenCode CLI**를 열고 **에이전트 제어 도구**를 변경한 다음 **Save + Reload**를 선택하세요. 관리형 OpenCode 서버가 다시 시작된 후 설정이 적용됩니다.
|
||||
|
||||
OpenChamber가 `OPENCODE_HOST` 또는 skip-start를 통해 외부 OpenCode 서버에 연결된 경우와 VS Code 확장에서는 이 도구를 사용할 수 없습니다. OpenChamber의 관리형 OpenCode 서버를 사용하는 데스크톱 및 웹 설치에서는 자동으로 지원됩니다.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [예약 작업](/ko/scheduled-tasks/)
|
||||
- [Worktree 세션](/ko/worktrees/)
|
||||
- [세션 목표](/ko/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: 변경 워크스루
|
||||
description: diff를 알파벳순이 아니라 이해되는 순서로 읽습니다.
|
||||
---
|
||||
|
||||
# 변경 워크스루
|
||||
|
||||
diff는 파일 경로순으로 정렬되지만, 그 순서가 변경을 이해하기 좋은 순서인 경우는 거의 없습니다. 워크스루는 이를 다시 배열합니다. 관련된 수정들을 **단계**로 묶고, 각 단계는 코드가 이제 무엇을 다르게 하는지 설명하며, 앞 단계 위에 다음 단계가 쌓이도록 순서를 정합니다.
|
||||
|
||||
설명하고 순서를 부여할 뿐, 코드를 심사하거나 판정을 내리지 않습니다. 그건 [Review](/git/)의 역할입니다.
|
||||
|
||||
오른쪽 레일의 **워크스루** 아이콘이나, 변경 패널과 풀 리퀘스트 패널의 **AI 워크스루** 버튼으로 엽니다. 둘 다 패널을 열기만 하며, **워크스루 생성**을 누르기 전에는 아무것도 생성되지 않습니다.
|
||||
|
||||
## 다룰 수 있는 범위
|
||||
|
||||
| 범위 | 포함되는 것 |
|
||||
| --- | --- |
|
||||
| 커밋되지 않은 전체 | 아직 커밋되지 않은 모든 것: 스테이지됨, 스테이지 안 됨, 새 파일 |
|
||||
| 스테이지됨 | 지금 커밋하면 들어갈 것만 |
|
||||
| 스테이지 안 됨 | 작업 트리와 새 파일 |
|
||||
| 이 브랜치 | 베이스에 없는 이 브랜치의 모든 커밋 |
|
||||
| 풀 리퀘스트 | GitHub에 존재하는 형태의 변경 |
|
||||
|
||||
**이 브랜치**는 "푸시하지 않은 커밋"이 아니라, 푸시 여부와 무관하게 브랜치가 베이스에 더한 전부입니다. 그래서 커밋한 뒤 푸시하기 전에는 이것과 풀 리퀘스트가 의도적으로 달라집니다. 하나는 당신이 한 일을, 다른 하나는 리뷰어가 지금 보는 것을 보여줍니다.
|
||||
|
||||
범위마다 따로 저장되므로 전환해도 잃는 것이 없습니다.
|
||||
|
||||
## 모델 선택
|
||||
|
||||
워크스루는 기본적으로 스몰 모델을 사용합니다. **설정 → 세션 → 변경 워크스루 모델**에서 다른 모델을 고르거나, 한 번만 쓸 모델은 패널 헤더에서 고를 수 있습니다. 변경이 충분히 위험해서 더 강한 모델에 맡기고 싶을 때 유용합니다.
|
||||
|
||||
선택 목록에는 구조화된 출력을 반환할 수 있는 모델만 나옵니다. 그것 없이는 워크스루를 구성할 수 없기 때문입니다. 모델이 diff에 비해 작으면 입력을 조용히 잘라내는 대신 이유를 설명하며 생성을 거부합니다. diff의 절반만 보고 쓴 워크스루는 자신 있게 틀리기 때문입니다.
|
||||
|
||||
패널을 다시 열면 지금 보고 있는 결과를 만든 모델이 표시되므로, 바꾸지 않는 한 **다시 생성**은 같은 모델로 반복합니다.
|
||||
|
||||
## 언어 선택
|
||||
|
||||
워크스루는 기본적으로 인터페이스 언어로 작성됩니다. 패널 헤더의 언어 선택기가 그 언어에서 시작하며, 한 번의 리뷰에 한해 OpenChamber가 번역된 다른 언어를 고를 수 있습니다. 안내는 편하게 읽을 수 있는 언어여야 쓸모가 있습니다.
|
||||
|
||||
번역되는 것은 서술뿐입니다. 식별자, 파일 경로, API 이름은 코드에 있는 그대로 남으므로, 각 지점이 가리키는 이름을 그대로 검색할 수 있습니다.
|
||||
|
||||
선택한 언어로 아직 생성된 것이 없으면 패널은 비워지지 않고, 가지고 있는 워크스루를 계속 보여주면서 그 사실을 알립니다. **워크스루 생성**을 누르면 새 언어로 만들어집니다.
|
||||
|
||||
## 비용과 캐시
|
||||
|
||||
저절로 생성되는 것은 없습니다. 생성은 요청할 때만 시작되고, 재생성도 수동입니다.
|
||||
|
||||
결과는 diff의 정확한 내용을 기준으로 캐시됩니다. 작업 트리를 이전 상태로 되돌리면 그때의 워크스루가 모델 호출 없이 그대로 돌아옵니다. 언어와 모델도 이 키의 일부라서 조합마다 따로 보관됩니다. 한 diff에 두 언어의 워크스루가 생기면 그 사이를 오가는 것은 즉시 이루어지고 비용도 들지 않습니다.
|
||||
|
||||
생성은 브라우저 탭이 아니라 OpenChamber 서버에서 실행됩니다. 페이지를 새로 고치거나 패널을 닫아도 작업은 계속되고, 돌아오면 결과가 기다립니다. 멈추는 것은 **취소**뿐입니다.
|
||||
|
||||
## 오래됨을 정직하게 알리기
|
||||
|
||||
각 단계는 설명 대상 코드의 정확한 내용에 묶여 있어서, 그 코드가 변했을 때 패널이 알려줄 수 있습니다.
|
||||
|
||||
- **오래된 단계** — 단계가 설명하던 코드가 바뀌었거나 사라졌습니다. 워크스루는 표시된 채로 계속 보이며, 다시 생성할지는 당신이 정합니다.
|
||||
- **미포함** — 현재 diff에서 어떤 단계도 설명하지 않는 변경입니다. 생성 이후에 한 수정, 워크스루가 일상적이라고 판단한 변경, 그리고 의도적으로 모델에 넘기지 않는 잠금 파일 등 생성물이 여기에 들어갑니다. 모두 끝에 나열되므로 조용히 사라지는 것은 없습니다.
|
||||
|
||||
재생성은 기우는 것이 아니라 다시 쓰는 것입니다. 이전 워크스루가 맥락으로 모델에 전달되어 여전히 맞는 부분은 살아남고, 전체가 현재 코드에 다시 묶입니다.
|
||||
|
||||
## 참고
|
||||
|
||||
- diff 보기와 똑같이 아무 줄에나 코멘트할 수 있고, 코멘트는 채팅 입력창에 첨부됩니다.
|
||||
- 데스크톱과 태블릿 너비에서 사용할 수 있습니다. VS Code 확장과 모바일 앱에서는 제공되지 않습니다.
|
||||
- 풀 리퀘스트 워크스루에는 연결된 GitHub 계정이 필요합니다 — [GitHub 이슈와 PR](/github/)을 참고하세요.
|
||||
|
||||
## 관련 문서
|
||||
|
||||
- [Git과 GitHub](/git/) — 이 기능이 읽어오는 변경 패널, 그리고 실제로 코드를 심사하는 Review 액션
|
||||
- [GitHub 이슈와 PR](/github/) — 풀 리퀘스트를 다루려면 GitHub를 연결하세요
|
||||
- [프로바이더, 모델, 에이전트](/providers/) — 스몰 모델이 어디서 오는지
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Narzędzie sterowania dla agentów
|
||||
description: Pozwól agentowi zarządzać sesjami, worktree i zaplanowanymi zadaniami OpenChamber z czatu.
|
||||
---
|
||||
|
||||
# Narzędzie sterowania dla agentów
|
||||
|
||||
Użyj narzędzia agenta `openchamber`, aby zarządzać pracą w aplikacji bezpośrednio z czatu. Jest ono domyślnie włączone, gdy OpenChamber uruchamia własny lokalny serwer OpenCode; nie trzeba instalować osobnego narzędzia ani uruchamiać polecenia powłoki.
|
||||
|
||||
## O co możesz poprosić
|
||||
|
||||
Poproś agenta zwykłym językiem. Na przykład:
|
||||
|
||||
- „Utwórz nową sesję OpenChamber w tym projekcie, użyj modelu `openai/gpt-5.6-sol` i wyślij jej prompt: sprawdź proces uwierzytelniania”.
|
||||
- „Utwórz dla tego zadania nową sesję OpenChamber w osobnym worktree i poproś ją o dodanie testów procesu logowania”.
|
||||
- „Użyj OpenChamber, aby wyświetlić 10 moich ostatnich sesji wraz z ich bieżącym stanem”.
|
||||
- „Utwórz w OpenChamber zaplanowane zadanie o nazwie Przegląd w dni robocze, które o 09:00 w każdy dzień roboczy wyśle prompt: sprawdź zmiany od ostatniego uruchomienia”.
|
||||
- „Uruchom teraz zaplanowane zadanie OpenChamber o nazwie Przegląd w dni robocze”.
|
||||
- „Sprawdź sesję OpenChamber o nazwie Przegląd uwierzytelniania i pokaż najnowszą odpowiedź asystenta”.
|
||||
|
||||
Narzędzie może wyświetlać projekty i preferencje modeli, tworzyć i kontynuować sesje, rozwidlać sesję, tworzyć izolowane sesje worktree oraz zarządzać zaplanowanymi zadaniami. Uruchomione w ten sposób sesje pojawiają się w OpenChamber jak każde inne, więc możesz je otworzyć i samodzielnie kontynuować pracę.
|
||||
|
||||
## Pamiętaj
|
||||
|
||||
- Prompty nowych sesji domyślnie zwracają sterowanie od razu. Śledź sesję w OpenChamber lub poproś agenta, aby sprawdził ją później.
|
||||
- Osobny worktree jest tworzony tylko na Twoją prośbę. Niezatwierdzone zmiany z bieżącego worktree nie są do niego kopiowane.
|
||||
- Narzędzie nie może usuwać sesji ani worktree, rejestrować ścieżek projektów, uruchamiać dowolnych poleceń powłoki ani wywoływać dowolnych adresów URL.
|
||||
|
||||
## Włączanie i wyłączanie narzędzia
|
||||
|
||||
Otwórz **Ustawienia → Ogólne → OpenCode CLI**, zmień **Narzędzie sterowania dla agentów**, a następnie wybierz **Save + Reload**. Ustawienie zacznie działać po ponownym uruchomieniu zarządzanego serwera OpenCode.
|
||||
|
||||
Narzędzie nie jest dostępne, gdy OpenChamber łączy się z zewnętrznym serwerem OpenCode przez `OPENCODE_HOST` lub skip-start, ani w rozszerzeniu VS Code. Instalacje desktopowe i webowe korzystające z serwera OpenCode zarządzanego przez OpenChamber obsługują je automatycznie.
|
||||
|
||||
## Powiązane
|
||||
|
||||
- [Zaplanowane zadania](/pl/scheduled-tasks/)
|
||||
- [Sesje worktree](/pl/worktrees/)
|
||||
- [Cele sesji](/pl/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Przewodnik po zmianach
|
||||
description: Czytaj różnice w kolejności, która ma sens, a nie alfabetycznie.
|
||||
---
|
||||
|
||||
# Przewodnik po zmianach
|
||||
|
||||
Różnice są posortowane po ścieżkach plików, a to prawie nigdy nie jest kolejność, w której zmiana staje się zrozumiała. Przewodnik układa je na nowo: powiązane edycje trafiają do wspólnych **kroków**, każdy krok tłumaczy, co kod robi teraz inaczej, a kolejność kroków jest taka, by każdy opierał się na poprzednim.
|
||||
|
||||
Tłumaczy i porządkuje. Nie ocenia kodu i nie wydaje werdyktów — od tego jest [Review](/git/).
|
||||
|
||||
Otwórz go ikoną **Przewodnik** na prawym pasku albo przyciskiem **Przewodnik AI** w panelach zmian i pull requestu. Oba tylko otwierają panel; nic nie powstaje, dopóki nie naciśniesz **Wygeneruj przewodnik**.
|
||||
|
||||
## Co można przejrzeć
|
||||
|
||||
| Zakres | Co obejmuje |
|
||||
| --- | --- |
|
||||
| Wszystko niezatwierdzone | Wszystko, czego nie ma jeszcze w commicie: poczekalnia, drzewo robocze i nowe pliki |
|
||||
| W poczekalni | Tylko to, co trafiłoby teraz do commita |
|
||||
| Poza poczekalnią | Drzewo robocze i nowe pliki |
|
||||
| Ta gałąź | Wszystkie commity gałęzi, których nie ma w jej bazie |
|
||||
| Pull request | Zmiana w postaci, w jakiej istnieje na GitHubie |
|
||||
|
||||
**Ta gałąź** to nie „commity bez pusha", lecz wszystko, co gałąź dokłada do swojej bazy — niezależnie od pusha. Dlatego po commicie, a przed pushem, ona i pull request celowo się różnią: jedno pokazuje, co zrobiłeś, drugie to, co widzą teraz recenzenci.
|
||||
|
||||
Każdy zakres jest zapisywany osobno, więc przełączanie między nimi niczego nie gubi.
|
||||
|
||||
## Wybór modelu
|
||||
|
||||
Przewodniki domyślnie używają małego modelu. Inny wybierzesz w **Ustawienia → Sesje → Model przewodnika po zmianach** albo — na jeden raz — w nagłówku panelu. Przydaje się, gdy zmiana jest na tyle ryzykowna, że zasługuje na mocniejszy model.
|
||||
|
||||
Lista pokazuje tylko modele potrafiące zwracać ustrukturyzowaną odpowiedź, bo bez niej przewodnika nie da się złożyć. Jeśli model jest za mały na te różnice, generowanie zostaje odrzucone z wyjaśnieniem, zamiast po cichu obciąć wejście: przewodnik napisany na podstawie połowy różnic brzmi pewnie i się myli.
|
||||
|
||||
Po ponownym otwarciu panelu zobaczysz model, który stworzył to, co masz przed sobą, więc **Wygeneruj ponownie** powtórzy tym samym, dopóki go nie zmienisz.
|
||||
|
||||
## Wybór języka
|
||||
|
||||
Przewodniki są domyślnie pisane w języku Twojego interfejsu. Selektor języka w nagłówku panelu zaczyna właśnie od niego, a dla pojedynczego przeglądu możesz wybrać dowolny inny język, na który przetłumaczono OpenChamber — prowadzone wyjaśnienie ma sens tylko w języku, który czytasz swobodnie.
|
||||
|
||||
Tłumaczona jest wyłącznie proza. Identyfikatory, ścieżki plików i nazwy API pozostają dokładnie takie, jakie są w Twoim kodzie, więc to, co nazywa dany przystanek, nadal da się wyszukać.
|
||||
|
||||
Jeśli w wybranym języku nic jeszcze nie powstało, panel się nie opróżnia: nadal pokazuje przewodnik, który ma, i informuje o tym. Naciśnij **Wygeneruj przewodnik**, aby otrzymać go w nowym języku.
|
||||
|
||||
## Koszt i pamięć podręczna
|
||||
|
||||
Nic nie generuje się samo. Generowanie zaczyna się wyłącznie na Twoje żądanie, ponowne również jest ręczne.
|
||||
|
||||
Wyniki są zapisywane w pamięci podręcznej według dokładnej treści różnic. Przywróć drzewo robocze do wcześniejszego stanu, a tamten przewodnik wróci za darmo, bez wywołania modelu. Język i model są częścią tego klucza, więc każda kombinacja jest przechowywana osobno: gdy różnice mają już przewodnik w dwóch językach, przełączanie między nimi jest natychmiastowe i nic nie kosztuje.
|
||||
|
||||
Generowanie działa na serwerze OpenChamber, nie w karcie przeglądarki. Odśwież stronę albo zamknij panel, a praca trwa dalej; po powrocie wynik czeka. Zatrzymuje ją tylko **Anuluj**.
|
||||
|
||||
## Uczciwość wobec nieaktualności
|
||||
|
||||
Każdy krok jest przypięty do dokładnej treści kodu, który opisuje, więc panel potrafi powiedzieć, kiedy ten kod się zmienił:
|
||||
|
||||
- **Nieaktualne kroki** — kod opisywany przez krok zmienił się albo zniknął. Przewodnik nadal się wyświetla, z oznaczeniem, żebyś sam zdecydował o ponownym wygenerowaniu.
|
||||
- **Nieuwzględnione** — zmiany w bieżących różnicach, których nie opisuje żaden krok. Trafiają tu edycje zrobione po wygenerowaniu, zmiany uznane przez przewodnik za rutynowe oraz pliki blokad i inne wyniki narzędzi, celowo trzymane poza modelem. Wszystko jest wypisane na końcu, żeby nic nie zniknęło po cichu.
|
||||
|
||||
Ponowne wygenerowanie nie łata, lecz pisze od nowa: poprzedni przewodnik trafia do modelu jako kontekst, więc to, co nadal jest prawdą, zostaje, a całość zostaje przypięta do bieżącego kodu.
|
||||
|
||||
## Uwagi
|
||||
|
||||
- Możesz komentować dowolną linię tak samo jak w widoku różnic; komentarze dołączają się do pola czatu.
|
||||
- Dostępne na komputerze i przy szerokościach tabletu. Nie ma tego w rozszerzeniu VS Code ani w aplikacji mobilnej.
|
||||
- Przewodnik po pull requeście wymaga połączonego konta GitHub — zobacz [Issues i PR na GitHubie](/github/).
|
||||
|
||||
## Powiązane
|
||||
|
||||
- [Git i GitHub](/git/) — panel zmian, z którego to czyta, oraz akcja Review, która faktycznie ocenia kod
|
||||
- [Issues i PR na GitHubie](/github/) — połącz GitHub, aby przeglądać pull requesty
|
||||
- [Dostawcy, modele i agenci](/providers/) — skąd bierze się mały model
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Ferramenta de controle para agentes
|
||||
description: Permita que um agente gerencie sessões, worktrees e tarefas agendadas do OpenChamber pelo chat.
|
||||
---
|
||||
|
||||
# Ferramenta de controle para agentes
|
||||
|
||||
Use a ferramenta de agente `openchamber` para gerenciar o trabalho no aplicativo diretamente pelo chat. Ela vem ativada por padrão quando o OpenChamber executa seu próprio servidor OpenCode local; não é preciso instalar outra ferramenta nem executar um comando de shell.
|
||||
|
||||
## O que você pode pedir
|
||||
|
||||
Peça ao agente em linguagem natural. Por exemplo:
|
||||
|
||||
- “Crie uma nova sessão do OpenChamber neste projeto, use o modelo `openai/gpt-5.6-sol` e envie este prompt: revise o fluxo de autenticação.”
|
||||
- “Crie uma nova sessão do OpenChamber para esta tarefa em um worktree separado e peça para ela adicionar testes ao fluxo de login.”
|
||||
- “Use o OpenChamber para listar minhas 10 sessões mais recentes e inclua o status atual delas.”
|
||||
- “Crie no OpenChamber uma tarefa agendada chamada Revisão dos dias úteis que envie este prompt às 09:00 de cada dia útil: revise as alterações desde a última execução.”
|
||||
- “Execute agora a tarefa agendada do OpenChamber chamada Revisão dos dias úteis.”
|
||||
- “Verifique a sessão do OpenChamber chamada Revisão de autenticação e mostre a resposta mais recente do assistente.”
|
||||
|
||||
A ferramenta pode listar projetos e preferências de modelos, criar e continuar sessões, bifurcar uma sessão, criar sessões em worktrees isolados e gerenciar tarefas agendadas. As sessões iniciadas assim aparecem no OpenChamber como qualquer outra, então você pode abri-las e continuar o trabalho por conta própria.
|
||||
|
||||
## Lembre-se
|
||||
|
||||
- Os prompts de novas sessões retornam imediatamente por padrão. Acompanhe a sessão no OpenChamber ou peça ao agente para verificá-la mais tarde.
|
||||
- Um worktree separado só é criado quando você pede. As alterações não commitadas do worktree atual não são copiadas.
|
||||
- A ferramenta não pode excluir sessões ou worktrees, registrar caminhos de projetos, executar comandos de shell arbitrários nem acessar URLs arbitrárias.
|
||||
|
||||
## Ativar ou desativar a ferramenta
|
||||
|
||||
Abra **Configurações → Geral → OpenCode CLI**, altere **Ferramenta de controle para agentes** e selecione **Save + Reload**. A configuração entra em vigor depois que o servidor OpenCode gerenciado reinicia.
|
||||
|
||||
A ferramenta não está disponível quando o OpenChamber se conecta a um servidor OpenCode externo por `OPENCODE_HOST` ou skip-start, nem na extensão do VS Code. As instalações desktop e web que usam o servidor OpenCode gerenciado pelo OpenChamber têm suporte automático.
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Tarefas agendadas](/pt-br/scheduled-tasks/)
|
||||
- [Sessões de worktree](/pt-br/worktrees/)
|
||||
- [Objetivos de sessão](/pt-br/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Percurso pelas mudanças
|
||||
description: Leia um diff na ordem que faz sentido, não em ordem alfabética.
|
||||
---
|
||||
|
||||
# Percurso pelas mudanças
|
||||
|
||||
Um diff é ordenado por caminho de arquivo, que quase nunca é a ordem em que a mudança faz sentido. O percurso reorganiza isso: edições relacionadas viram **paradas**, cada parada explica o que o código passa a fazer de diferente, e as paradas seguem uma ordem em que cada uma se apoia na anterior.
|
||||
|
||||
Ele explica e ordena. Não julga o seu código nem dá veredictos — isso é papel do [Review](/git/).
|
||||
|
||||
Abra pelo ícone **Percurso** na barra direita ou pelo botão **Percurso com IA** nos painéis de mudanças e de pull request. Ambos apenas abrem o painel; nada é gerado até você apertar **Gerar percurso**.
|
||||
|
||||
## O que dá para percorrer
|
||||
|
||||
| Escopo | O que inclui |
|
||||
| --- | --- |
|
||||
| Tudo sem commit | Tudo que ainda não está em commit: no stage, fora do stage e arquivos novos |
|
||||
| No stage | Só o que iria para um commit agora |
|
||||
| Fora do stage | Árvore de trabalho e arquivos novos |
|
||||
| Este branch | Todos os commits do branch que não estão na base |
|
||||
| Pull request | A mudança como ela existe no GitHub |
|
||||
|
||||
**Este branch** não quer dizer "commits sem push": é tudo o que o branch acrescenta à sua base, com push ou sem. Por isso, depois do commit e antes do push, ele e o pull request divergem de propósito: um mostra o que você fez, o outro o que os revisores veem agora.
|
||||
|
||||
Cada escopo é guardado separadamente, então alternar entre eles nunca perde nada.
|
||||
|
||||
## Escolhendo o modelo
|
||||
|
||||
Os percursos usam o seu modelo pequeno por padrão. Escolha outro em **Configurações → Sessões → Modelo do percurso de mudanças**, ou apenas para uma revisão no cabeçalho do painel — útil quando a mudança é arriscada o bastante para merecer um modelo mais forte.
|
||||
|
||||
O seletor só oferece modelos capazes de devolver saída estruturada, porque sem ela o percurso não se monta. Se o modelo for pequeno demais para o diff, a geração é recusada com explicação em vez de cortar a entrada em silêncio: um percurso escrito sobre metade de um diff soa seguro e está errado.
|
||||
|
||||
Ao reabrir o painel você vê o modelo que produziu o que está na tela, então **Gerar novamente** repete com o mesmo, a menos que você troque.
|
||||
|
||||
## Escolhendo o idioma
|
||||
|
||||
Os percursos são escritos no idioma da sua interface por padrão. O seletor de idioma no cabeçalho do painel começa por ele, e você pode escolher qualquer outro idioma para o qual o OpenChamber esteja traduzido para uma única revisão — uma explicação guiada só serve num idioma que você lê com folga.
|
||||
|
||||
Só a prosa é traduzida. Identificadores, caminhos de arquivo e nomes de API continuam exatamente como aparecem no seu código, então o que uma parada nomeia continua sendo o que você consegue buscar.
|
||||
|
||||
Se ainda não houver nada gerado no idioma escolhido, o painel não se esvazia: ele continua mostrando o percurso que tem e avisa. Toque em **Gerar percurso** para obtê-lo no novo idioma.
|
||||
|
||||
## Custo e cache
|
||||
|
||||
Nada é gerado sozinho. A geração só começa quando você pede, e gerar de novo também é manual.
|
||||
|
||||
Os resultados ficam em cache pelo conteúdo exato do diff. Volte a árvore de trabalho para um estado anterior e aquele percurso retorna de graça, sem chamar o modelo. O idioma e o modelo fazem parte dessa chave, então cada combinação é guardada separadamente: quando um diff já tem percurso em dois idiomas, alternar entre eles é instantâneo e não custa nada.
|
||||
|
||||
A geração roda no servidor do OpenChamber, não na aba do navegador. Recarregue a página ou feche o painel e o trabalho continua; ao voltar, o resultado está esperando. Só **Cancelar** interrompe.
|
||||
|
||||
## Honestidade sobre o que ficou velho
|
||||
|
||||
Cada parada está ancorada ao conteúdo exato do código que descreve, então o painel consegue avisar quando esse código mudou:
|
||||
|
||||
- **Etapas desatualizadas** — o código que a parada descrevia mudou ou sumiu. O percurso continua aparecendo, marcado, para você decidir se regenera.
|
||||
- **Sem cobertura** — mudanças do diff atual que nenhuma parada descreve. Entram aí as edições feitas depois de gerar, as mudanças que o percurso considerou rotineiras e os arquivos de lock e outras saídas geradas, mantidas fora do modelo de propósito. Tudo aparece no fim, para que nada suma em silêncio.
|
||||
|
||||
Regenerar não remenda, reescreve: o percurso anterior vai ao modelo como contexto, o que ainda é verdade permanece, e tudo é reancorado no código atual.
|
||||
|
||||
## Notas
|
||||
|
||||
- Você pode comentar qualquer linha como na visão de diff; os comentários se anexam ao campo do chat.
|
||||
- Disponível em desktop e larguras de tablet. Não é oferecido na extensão do VS Code nem no app móvel.
|
||||
- Percorrer um pull request exige uma conta do GitHub conectada — veja [Issues e PRs do GitHub](/github/).
|
||||
|
||||
## Relacionado
|
||||
|
||||
- [Git e GitHub](/git/) — o painel de mudanças de onde isso lê, e a ação Review, que de fato julga o código
|
||||
- [Issues e PRs do GitHub](/github/) — conecte o GitHub para percorrer pull requests
|
||||
- [Provedores, modelos e agentes](/providers/) — de onde vem o modelo pequeno
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: Інструмент керування для агентів
|
||||
description: Дозвольте агенту керувати сесіями, worktree та запланованими задачами OpenChamber із чату.
|
||||
---
|
||||
|
||||
# Інструмент керування для агентів
|
||||
|
||||
Використовуйте інструмент агента `openchamber`, щоб керувати роботою в застосунку безпосередньо з чату. Він увімкнений за замовчуванням, коли OpenChamber запускає власний локальний сервер OpenCode; не потрібно встановлювати окремий інструмент чи запускати shell-команду.
|
||||
|
||||
## Про що можна попросити
|
||||
|
||||
Звертайтеся до агента звичайною мовою. Наприклад:
|
||||
|
||||
- «Створи нову сесію OpenChamber у цьому проєкті, використай модель `openai/gpt-5.6-sol` і надішли їй промпт: перевір процес автентифікації».
|
||||
- «Створи для цієї задачі нову сесію OpenChamber в окремому worktree й попроси її додати тести для процесу входу».
|
||||
- «Через OpenChamber покажи 10 моїх останніх сесій разом із їхнім поточним станом».
|
||||
- «Створи в OpenChamber заплановану задачу “Перевірка в будні”, яка щодня з понеділка до п’ятниці о 09:00 надсилатиме промпт: перевір зміни з моменту останнього запуску».
|
||||
- «Запусти зараз заплановану задачу OpenChamber “Перевірка в будні”».
|
||||
- «Перевір сесію OpenChamber “Перевірка автентифікації” й покажи останню відповідь асистента».
|
||||
|
||||
Інструмент може показувати проєкти й налаштування моделей, створювати та продовжувати сесії, відгалужувати сесію, створювати ізольовані сесії worktree й керувати запланованими задачами. Запущені так сесії з'являються в OpenChamber як звичайні, тож ви можете відкрити їх і продовжити роботу самостійно.
|
||||
|
||||
## Майте на увазі
|
||||
|
||||
- Промпти нових сесій за замовчуванням повертають результат виклику одразу. Стежте за сесією в OpenChamber або попросіть агента перевірити її пізніше.
|
||||
- Окремий worktree створюється лише на ваше прохання. Незакомічені зміни з поточного worktree до нього не копіюються.
|
||||
- Інструмент не може видаляти сесії чи worktree, реєструвати шляхи проєктів, запускати довільні shell-команди або звертатися до довільних URL.
|
||||
|
||||
## Увімкнення та вимкнення
|
||||
|
||||
Відкрийте **Налаштування → Загальні → OpenCode CLI**, змініть **Інструмент керування для агентів**, потім виберіть **Save + Reload**. Налаштування застосовується після перезапуску керованого сервера OpenCode.
|
||||
|
||||
Інструмент недоступний, коли OpenChamber підключається до зовнішнього сервера OpenCode через `OPENCODE_HOST` чи skip-start, а також у розширенні VS Code. Десктопні та вебінсталяції з керованим OpenChamber сервером OpenCode підтримують його автоматично.
|
||||
|
||||
## Пов'язане
|
||||
|
||||
- [Заплановані задачі](/uk/scheduled-tasks/)
|
||||
- [Сесії worktree](/uk/worktrees/)
|
||||
- [Цілі сесії](/uk/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Розбір змін
|
||||
description: Читайте diff у порядку, який має сенс, а не в алфавітному.
|
||||
---
|
||||
|
||||
# Розбір змін
|
||||
|
||||
Diff упорядкований за шляхами файлів, а це майже ніколи не той порядок, у якому зміна стає зрозумілою. Розбір перебудовує його: пов'язані правки збираються в **кроки**, кожен крок пояснює, що саме код тепер робить інакше, а самі кроки йдуть так, щоб кожен спирався на попередній.
|
||||
|
||||
Він пояснює й упорядковує. Він не оцінює ваш код і не виносить вердиктів — для цього є [Review](/git/).
|
||||
|
||||
Відкрийте його іконкою **Розбір** у правому рейлі або кнопкою **AI-розбір** у панелях змін і pull request. Обидві лише відкривають панель; нічого не генерується, доки ви не натиснете **Створити розбір**.
|
||||
|
||||
## Що можна розібрати
|
||||
|
||||
| Область | Що охоплює |
|
||||
| --- | --- |
|
||||
| Усе незакомічене | Усе, що ще не в комітах: індекс, робоче дерево й нові файли |
|
||||
| В індексі | Лише те, що зараз пішло б у коміт |
|
||||
| Поза індексом | Робоче дерево й нові файли |
|
||||
| Ця гілка | Усі коміти гілки, яких немає в базовій |
|
||||
| Pull request | Зміна в тому вигляді, у якому вона є на GitHub |
|
||||
|
||||
**Ця гілка** — це не «незапушені коміти», а все, що гілка додає до базової, незалежно від пушу. Тому після коміту, але до пушу, вона й pull request навмисно розходяться: одне показує, що ви зробили, друге — що зараз бачать рецензенти.
|
||||
|
||||
Кожна область зберігається окремо, тож перемикання між ними нічого не втрачає.
|
||||
|
||||
## Вибір моделі
|
||||
|
||||
За замовчуванням розбір використовує вашу small model. Іншу можна обрати в **Налаштування → Сесії → Модель для розбору змін** або для одного разу в шапці панелі — корисно, коли зміна достатньо ризикована, щоб віддати її сильнішій моделі.
|
||||
|
||||
У списку показані лише моделі, які вміють structured output, бо без нього розбір неможливо зібрати. Якщо модель замала для цього diff, генерація відхиляється з поясненням, а не обрізає вхід мовчки: розбір, написаний за половиною diff, звучить упевнено й при цьому помиляється.
|
||||
|
||||
Відкривши панель знову, ви побачите модель, яка створила те, що перед вами, тож **Створити заново** повторить тією самою, доки ви її не зміните.
|
||||
|
||||
## Вибір мови
|
||||
|
||||
За замовчуванням розбір пишеться мовою вашого інтерфейсу. Саме з неї починає селектор мови в шапці панелі, і для окремого розбору можна обрати будь-яку іншу мову, якою перекладено OpenChamber — пояснення має сенс лише тією мовою, яку ви читаєте вільно.
|
||||
|
||||
Перекладається лише проза. Ідентифікатори, шляхи до файлів і назви API лишаються точно такими, як у вашому коді, тож те, що називає зупинка, і далі можна знайти пошуком.
|
||||
|
||||
Якщо обраною мовою ще нічого не згенеровано, панель не порожніє: вона й далі показує наявний розбір і повідомляє про це. Натисніть **Створити розбір**, щоб отримати його новою мовою.
|
||||
|
||||
## Витрати й кеш
|
||||
|
||||
Ніщо не генерується саме. Генерація починається лише на ваш запит, і повторна теж робиться вручну.
|
||||
|
||||
Результати кешуються за точним вмістом diff. Поверніть робоче дерево до попереднього стану — і попередній розбір повернеться безкоштовно, без звернення до моделі. Мова й модель — частина цього ключа, тож кожна комбінація зберігається окремо: коли для diff уже є розбір двома мовами, перемикання між ними миттєве й безкоштовне.
|
||||
|
||||
Генерація виконується на сервері OpenChamber, а не у вкладці браузера. Перезавантажте сторінку чи закрийте панель — робота триває, а результат чекатиме на вас. Зупиняє її лише кнопка **Скасувати**.
|
||||
|
||||
## Чесність щодо застарілого
|
||||
|
||||
Кожен крок прив'язаний до точного вмісту коду, який він описує, тож панель може сказати, коли той код змінився:
|
||||
|
||||
- **Застарілі кроки** — код, який описував крок, змінився або зник. Розбір усе одно показується, з позначкою, щоб ви самі вирішили, чи перегенеровувати.
|
||||
- **Не описано** — зміни в поточному diff, яких не описує жоден крок. Сюди потрапляють правки, зроблені після генерації, зміни, які розбір визнав рутинними, а також lock-файли й інші згенеровані файли, які свідомо не потрапляють до моделі. Усе це перелічено в кінці, щоб нічого не зникло непомітно.
|
||||
|
||||
Повторна генерація не латає, а переписує: попередній розбір іде в модель як контекст, тому точні частини зберігаються, а прив'язки заново перераховуються під поточний код.
|
||||
|
||||
## Примітки
|
||||
|
||||
- Коментувати можна будь-який рядок, так само як у вигляді diff; коментарі чіпляються до поля вводу в чаті.
|
||||
- Доступно на десктопі та планшетних ширинах. У розширенні для VS Code і в мобільному застосунку не пропонується.
|
||||
- Для розбору pull request потрібен під'єднаний акаунт GitHub — див. [Issues та PR на GitHub](/github/).
|
||||
|
||||
## Пов'язане
|
||||
|
||||
- [Git і GitHub](/git/) — панель змін, з якої це читається, і дія Review, яка таки оцінює код
|
||||
- [Issues та PR на GitHub](/github/) — під'єднайте GitHub, щоб розбирати pull request
|
||||
- [Провайдери, моделі та агенти](/providers/) — звідки береться small model
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: Changes Walkthrough
|
||||
description: Read a diff in the order it makes sense, not in alphabetical order.
|
||||
---
|
||||
|
||||
# Changes Walkthrough
|
||||
|
||||
A diff is sorted by file path, which is almost never the order in which the change makes sense. The walkthrough reorders it: related edits are grouped into **stops**, each stop explains what the code now does differently, and the stops are ordered so each one builds on the last.
|
||||
|
||||
It explains and orders. It does not judge your code or hand out verdicts — that is what [Review](/git/) is for.
|
||||
|
||||
Open it from the **Walkthrough** icon in the right rail, or from the **AI walkthrough** button in the Changes and Pull Request panels. Both just open the panel; nothing is generated until you press **Generate walkthrough**.
|
||||
|
||||
## What it can review
|
||||
|
||||
| Scope | What it covers |
|
||||
| --- | --- |
|
||||
| All uncommitted | Everything not yet committed: staged, unstaged, and new files |
|
||||
| Staged | Only what would go into a commit right now |
|
||||
| Unstaged | Working tree and new files |
|
||||
| This branch | Every commit on this branch that is not on its base |
|
||||
| Pull request | The change as it exists on GitHub |
|
||||
|
||||
**This branch** is not "unpushed commits" — it is everything the branch adds to its base, pushed or not. So after committing but before pushing, it and the pull request deliberately differ: one shows what you did, the other what reviewers currently see.
|
||||
|
||||
Each scope is stored separately, so switching between them never loses anything.
|
||||
|
||||
## Choosing the model
|
||||
|
||||
Walkthroughs use your small model by default. Pick a different one in **Settings → Sessions → Changes Walkthrough Model**, or for a single review in the panel header — useful when a change is risky enough to deserve a stronger model.
|
||||
|
||||
The picker only offers models that can return structured output, because the walkthrough cannot be assembled without it. If a model is too small for the diff, generation is refused with an explanation rather than silently truncating the input: a walkthrough written against half a diff reads as confident and is wrong.
|
||||
|
||||
Reopening a panel shows the model that produced what you are looking at, so **Regenerate** repeats with the same one unless you change it.
|
||||
|
||||
## Choosing the language
|
||||
|
||||
Walkthroughs are written in your interface language by default. The language picker in the panel header starts there, and you can pick any other language OpenChamber is translated into for a single review — a guided explanation is only useful in a language you read comfortably.
|
||||
|
||||
Only the prose is translated. Identifiers, file paths, and API names stay exactly as they appear in your code, so what a stop names is still what you can search for.
|
||||
|
||||
When nothing has been generated yet in the language you picked, the panel keeps showing the walkthrough it has and says so, rather than emptying itself. Press **Generate walkthrough** to get one in the new language.
|
||||
|
||||
## Cost and caching
|
||||
|
||||
Nothing generates on its own. Generation only ever starts when you ask, and regeneration is manual too.
|
||||
|
||||
Results are cached against the exact content of the diff. Return the working tree to an earlier state and the earlier walkthrough comes back for free, no model call. Language and model are both part of that key, so each combination is kept separately: once a diff has a walkthrough in two languages, switching between them is instant and costs nothing.
|
||||
|
||||
Generation runs on the OpenChamber server, not in your browser tab. Reload the page or close the panel and it keeps going; come back and the result is waiting. Pressing **Cancel** is the only thing that stops it.
|
||||
|
||||
## Staying honest about staleness
|
||||
|
||||
Every stop is anchored to the exact content of the code it describes, so the panel can tell you when that code has moved on:
|
||||
|
||||
- **Outdated steps** — the code a stop described has changed or is gone. The walkthrough still shows, marked, so you can decide whether to regenerate.
|
||||
- **Not covered** — changes in the current diff that no stop describes. That includes edits made after generating, changes the walkthrough judged routine, and lockfiles and other generated files, which are deliberately kept out of the model's input. They are all listed at the end of the stream so nothing disappears silently.
|
||||
|
||||
Regenerating re-authors rather than patches: the previous walkthrough goes to the model as context so accurate parts survive, and everything is re-anchored to the current code.
|
||||
|
||||
## Notes
|
||||
|
||||
- Comment on any line in the walkthrough exactly as in the diff view; comments attach to the chat composer.
|
||||
- Available on desktop and tablet widths. Not offered in the VS Code extension or the mobile app.
|
||||
- A pull request review needs a connected GitHub account — see [GitHub Issues & PRs](/github/).
|
||||
|
||||
## Related
|
||||
|
||||
- [Git & GitHub](/git/) — the Changes panel this reads from, and the Review action that does judge code
|
||||
- [GitHub Issues & PRs](/github/) — connect GitHub to review pull requests
|
||||
- [Providers, Models & Agents](/providers/) — where the small model comes from
|
||||
@@ -0,0 +1,39 @@
|
||||
---
|
||||
title: 智能体控制工具
|
||||
description: 让智能体从聊天中管理 OpenChamber 会话、worktree 和计划任务。
|
||||
---
|
||||
|
||||
# 智能体控制工具
|
||||
|
||||
使用 `openchamber` 智能体工具直接从聊天中管理应用内的工作。当 OpenChamber 运行自己的本地 OpenCode 服务器时,此工具默认启用;无需安装单独的工具或运行 shell 命令。
|
||||
|
||||
## 可以提出哪些请求
|
||||
|
||||
用自然语言告诉智能体即可。例如:
|
||||
|
||||
- “在此项目中创建一个新的 OpenChamber 会话,使用 `openai/gpt-5.6-sol` 模型,并向它发送此提示:审查身份验证流程。”
|
||||
- “在单独的 worktree 中为此任务创建一个新的 OpenChamber 会话,并让它为登录流程添加测试。”
|
||||
- “使用 OpenChamber 列出我最近的 10 个会话,并包含它们的当前状态。”
|
||||
- “在 OpenChamber 中创建名为‘工作日审查’的计划任务,每个工作日 09:00 发送此提示:审查自上次运行以来的更改。”
|
||||
- “立即运行名为‘工作日审查’的 OpenChamber 计划任务。”
|
||||
- “检查名为‘身份验证审查’的 OpenChamber 会话,并显示最新的助手回复。”
|
||||
|
||||
该工具可以列出项目和模型偏好、创建和继续会话、分叉会话、创建隔离的 worktree 会话,以及管理计划任务。以这种方式启动的会话会像普通会话一样显示在 OpenChamber 中,因此你可以打开并自行继续工作。
|
||||
|
||||
## 注意事项
|
||||
|
||||
- 新会话提示默认会立即返回。请在 OpenChamber 中跟进会话,或稍后让智能体检查结果。
|
||||
- 只有在你明确要求时才会创建单独的 worktree。当前 worktree 中未提交的更改不会复制过去。
|
||||
- 该工具不能删除会话或 worktree、注册项目路径、运行任意 shell 命令或访问任意 URL。
|
||||
|
||||
## 开启或关闭工具
|
||||
|
||||
打开 **设置 → 常规 → OpenCode CLI**,更改 **智能体控制工具**,然后选择 **Save + Reload**。该设置会在托管的 OpenCode 服务器重启后生效。
|
||||
|
||||
当 OpenChamber 通过 `OPENCODE_HOST` 或 skip-start 连接外部 OpenCode 服务器时,或在 VS Code 扩展中,此工具不可用。使用 OpenChamber 托管 OpenCode 服务器的桌面端和 Web 安装会自动支持此工具。
|
||||
|
||||
## 相关内容
|
||||
|
||||
- [计划任务](/zh-cn/scheduled-tasks/)
|
||||
- [Worktree 会话](/zh-cn/worktrees/)
|
||||
- [会话目标](/zh-cn/session-goals/)
|
||||
@@ -0,0 +1,71 @@
|
||||
---
|
||||
title: 改动导读
|
||||
description: 按讲得通的顺序读差异,而不是按字母顺序。
|
||||
---
|
||||
|
||||
# 改动导读
|
||||
|
||||
差异按文件路径排序,而这几乎从来不是让改动讲得通的顺序。导读会重新编排:相关的修改被归入一个个**步骤**,每个步骤说明代码现在有什么不同的行为,步骤的先后顺序保证后一步建立在前一步之上。
|
||||
|
||||
它负责解释和排序,不评判你的代码,也不给结论——那是 [Review](/git/) 的职责。
|
||||
|
||||
从右侧栏的**导读**图标打开,或在改动面板和拉取请求面板中点击 **AI 导读**按钮。两者都只是打开面板;在你按下**生成导读**之前不会生成任何内容。
|
||||
|
||||
## 可以导读的范围
|
||||
|
||||
| 范围 | 包含内容 |
|
||||
| --- | --- |
|
||||
| 全部未提交 | 尚未进入提交的一切:已暂存、未暂存和新文件 |
|
||||
| 已暂存 | 只包含此刻提交会带上的内容 |
|
||||
| 未暂存 | 工作区和新文件 |
|
||||
| 当前分支 | 该分支上基线分支所没有的全部提交 |
|
||||
| 拉取请求 | GitHub 上现有形态的改动 |
|
||||
|
||||
**当前分支**不是“未推送的提交”,而是该分支相对基线新增的全部内容,无论是否推送。因此在提交之后、推送之前,它和拉取请求会有意不同:一个显示你做了什么,另一个显示评审者当前看到什么。
|
||||
|
||||
各个范围分别保存,来回切换不会丢失任何内容。
|
||||
|
||||
## 选择模型
|
||||
|
||||
导读默认使用你的小模型。可在**设置 → 会话 → 改动导读模型**中更换,或只为这一次在面板标题栏中选择——当改动的风险足以交给更强的模型时很有用。
|
||||
|
||||
选择列表只提供能返回结构化输出的模型,因为没有它就无法组装导读。如果模型对这份差异来说太小,生成会带着说明被拒绝,而不是悄悄截断输入:只看了半份差异写出的导读听起来笃定,实际却是错的。
|
||||
|
||||
重新打开面板时会显示生成当前内容的那个模型,所以只要你不更换,**重新生成**就会沿用它。
|
||||
|
||||
## 选择语言
|
||||
|
||||
导读默认使用你的界面语言书写。面板顶部的语言选择器从该语言开始,你也可以只为这一次导读改用 OpenChamber 已翻译的任意其他语言——讲解只有用你读起来轻松的语言才有价值。
|
||||
|
||||
只有叙述文字会被翻译。标识符、文件路径和 API 名称保持代码中的原样,因此每个停靠点提到的名字仍然可以直接搜索。
|
||||
|
||||
如果所选语言还没有生成过内容,面板不会清空:它会继续显示已有的导读并说明这一点。按 **生成导读** 即可得到新语言的版本。
|
||||
|
||||
## 开销与缓存
|
||||
|
||||
不会自行生成。生成只在你请求时开始,重新生成同样需要手动触发。
|
||||
|
||||
结果按差异的确切内容缓存。把工作区恢复到先前状态,当时的导读就会免费回来,不会调用模型。语言和模型都属于这个键的一部分,因此每种组合各自保存:一个差异一旦有了两种语言的导读,在它们之间切换就是即时且免费的。
|
||||
|
||||
生成运行在 OpenChamber 服务器上,而不是浏览器标签页里。刷新页面或关闭面板,工作仍在继续;回来时结果已在等你。只有**取消**能停止它。
|
||||
|
||||
## 对过时保持诚实
|
||||
|
||||
每个步骤都锚定在它所描述代码的确切内容上,因此面板能告诉你那段代码何时发生了变化:
|
||||
|
||||
- **过时的步骤**——步骤所描述的代码已改变或不存在。导读仍会显示并加以标记,由你决定是否重新生成。
|
||||
- **未涵盖**——当前差异中没有任何步骤描述的改动。其中包括生成之后所做的修改、导读判定为常规的改动,以及锁文件等特意不送入模型的生成产物。它们都列在末尾,不会有内容悄无声息地消失。
|
||||
|
||||
重新生成不是打补丁,而是重写:上一版导读会作为上下文交给模型,仍然成立的部分得以保留,整体则重新锚定到当前代码。
|
||||
|
||||
## 说明
|
||||
|
||||
- 可以像在差异视图中一样对任意行发表评论,评论会附加到聊天输入框。
|
||||
- 在桌面和平板宽度下可用。VS Code 扩展和移动应用中不提供。
|
||||
- 导读拉取请求需要已连接的 GitHub 账户,参见 [GitHub Issue 与 PR](/github/)。
|
||||
|
||||
## 相关
|
||||
|
||||
- [Git 与 GitHub](/git/)——它读取的改动面板,以及确实会评判代码的 Review 操作
|
||||
- [GitHub Issue 与 PR](/github/)——连接 GitHub 以导读拉取请求
|
||||
- [提供方、模型与代理](/providers/)——小模型从何而来
|
||||
@@ -154,6 +154,20 @@
|
||||
"ja": "スケジュールタスク"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Agent Control Tool",
|
||||
"link": "/agent-control-tool/",
|
||||
"translations": {
|
||||
"uk": "Інструмент керування для агентів",
|
||||
"zh-CN": "智能体控制工具",
|
||||
"es": "Herramienta de control para agentes",
|
||||
"pt-BR": "Ferramenta de controle para agentes",
|
||||
"ko": "에이전트 제어 도구",
|
||||
"pl": "Narzędzie sterowania dla agentów",
|
||||
"fr": "Outil de contrôle pour les agents",
|
||||
"ja": "エージェント制御ツール"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Session Goals",
|
||||
"link": "/session-goals/",
|
||||
@@ -226,6 +240,20 @@
|
||||
"ja": "Git と GitHub"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "Changes Walkthrough",
|
||||
"link": "/walkthrough/",
|
||||
"translations": {
|
||||
"uk": "Розбір змін",
|
||||
"zh-CN": "改动导读",
|
||||
"es": "Recorrido por los cambios",
|
||||
"pt-BR": "Percurso pelas mudanças",
|
||||
"ko": "변경 워크스루",
|
||||
"pl": "Przewodnik po zmianach",
|
||||
"fr": "Parcours des modifications",
|
||||
"ja": "変更のウォークスルー"
|
||||
}
|
||||
},
|
||||
{
|
||||
"label": "GitHub Issues & PRs",
|
||||
"link": "/github/",
|
||||
|
||||
@@ -10,6 +10,8 @@ Desktop starts the OpenChamber web server in the same Electron main process. The
|
||||
|
||||
`main.mjs` imports `@openchamber/web/server/index.js` and calls `startWebUiServer()`. The Electron window then loads the UI from the local server in development, or from packaged `resources/web-dist` assets in packaged builds.
|
||||
|
||||
Same-origin session-chat iframes complete an authenticated parent-frame handshake before creating their SDK client. The parent supplies its active in-memory endpoint and credentials; when relay is active it also supplies the public relay descriptor without any pairing grant, because Electron preload and IPC are unavailable inside the iframe. The iframe establishes its own transport and rebinds its SDK before rendering. Additional windows retain their own per-window runtime bootstrap instead of being overwritten by the main window. Credentials are never placed in iframe URLs, and other child pages do not receive this runtime state.
|
||||
|
||||
The preload bridge exposes desktop-only APIs to the web UI through `window.__OPENCHAMBER_DESKTOP__`. Privileged commands are checked in `main.mjs`, not only in the UI.
|
||||
|
||||
## Main Files
|
||||
@@ -17,6 +19,7 @@ The preload bridge exposes desktop-only APIs to the web UI through `window.__OPE
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `main.mjs` | Electron main process, app lifecycle, windows, menus, deep links, native IPC handlers, updates, local server startup |
|
||||
| `startup-url-selection.mjs` | Pure bundled/HMR startup probe and loopback connection-limit policy |
|
||||
| `preload.mjs` | Safe bridge from the rendered UI to Electron IPC |
|
||||
| `ssh-manager.mjs` | SSH host import, connection lifecycle, tunnel/port forwarding helpers |
|
||||
| `scripts/electron-dev.mjs` | Desktop dev launcher with Vite HMR support |
|
||||
@@ -38,6 +41,8 @@ bun run electron:dev
|
||||
|
||||
`bun run electron:dev` starts the web dev server with HMR, then launches Electron against `packages/electron/main.mjs`.
|
||||
|
||||
The Electron workspace package trusts Electron's install script so `bun install` downloads the platform runtime in fresh checkouts and worktrees.
|
||||
|
||||
Useful variants:
|
||||
|
||||
```bash
|
||||
@@ -62,7 +67,7 @@ That runs, in order:
|
||||
2. `prepare:opencode-cli` to download/cache the pinned OpenCode CLI and copy it into `packages/electron/resources/opencode-cli`.
|
||||
3. `bundle:main` to create `packages/electron/dist-bundle/main.mjs`.
|
||||
4. `rebuild:native` to rebuild native modules for Electron.
|
||||
5. `package.mjs` to run `electron-builder`.
|
||||
5. `package.mjs` to run `electron-builder`; its `afterPack` hook stages the rebuilt `better-sqlite3` binary that Electron Builder's Bun dependency collector otherwise omits.
|
||||
|
||||
Build output goes to `packages/electron/dist`.
|
||||
|
||||
@@ -72,7 +77,7 @@ macOS builds produce `dmg` and `zip` artifacts. Windows builds produce an NSIS i
|
||||
|
||||
macOS packaging needs Xcode/build tools for notarized builds and icon asset compilation.
|
||||
|
||||
Windows packaging needs NSIS support through `electron-builder`. If no Windows signing env is set, `package.mjs` disables code signing and builds an unsigned installer.
|
||||
Windows packaging needs NSIS support through `electron-builder`. If no Windows signing env is set, `package.mjs` disables code signing and builds an unsigned installer. Windows updates use `latest.yml` for x64 and the `latest-arm64.yml` channel for ARM64 so each installation resolves an architecture-matching installer.
|
||||
|
||||
Linux AppImages must be built natively. Set `OPENCHAMBER_TARGET_ARCH=x64` or `OPENCHAMBER_TARGET_ARCH=arm64` when packaging; the build rejects a target that does not match the Linux host. The same target selects the bundled OpenCode CLI, native Electron rebuild, and Electron Builder architecture. Linux identity is stable across architectures: executable `openchamber`, desktop file `openchamber.desktop`, icon `openchamber`, and `StartupWMClass=openchamber`.
|
||||
|
||||
@@ -86,7 +91,9 @@ Linux updates are supported only when the packaged app is running from a writabl
|
||||
|
||||
A loopback-only updater fixture is available for contributor QA of N-to-N+1 AppImage replacement and restart behavior. It is test infrastructure, not a user-configurable update source. See [`scripts/updater-e2e-fixture.md`](./scripts/updater-e2e-fixture.md) for the controlled test procedure. Unit tests cover feed selection, check failures, no-update results, and fixture generation; actual AppImage replacement and restart remains a manual native N-to-N+1 release boundary because it requires executing two packaged versions on each supported architecture.
|
||||
|
||||
The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls and auto-update; system tray and launch-at-login remain macOS/Windows only. Some native discovery helpers are platform-specific. For example, app icon fetching and app filtering currently only work on macOS, while opening files in installed apps and installed-app discovery work on macOS and Windows (Linux returns an empty list without errors).
|
||||
The package supports macOS, Windows, and Linux desktop features. Linux AppImage builds include in-app window controls, auto-update, system tray (right-click Show / Hide / Close), and launch-at-login (XDG autostart). Opening files in installed apps, installed-app discovery, and FreeDesktop icon lookup (including the default file manager) work on macOS, Windows, and Linux.
|
||||
|
||||
The macOS menu bar item is enabled by default and can be disabled in General settings. The setting applies after restart; while disabled, Desktop does not create the native tray controller or start the renderer subscriptions, polling, quota refresh, or IPC updates that feed it.
|
||||
|
||||
## Bundled OpenCode CLI
|
||||
|
||||
@@ -94,9 +101,12 @@ Packaged Desktop builds include the official OpenCode CLI that matches the pinne
|
||||
|
||||
Managed local Desktop startup prefers OpenCode binaries in this order:
|
||||
|
||||
1. Explicit overrides: `settings.opencodeBinary`, `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`.
|
||||
2. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`.
|
||||
3. System installs discovered from PATH and known npm/Bun/Scoop/Chocolatey locations.
|
||||
1. `settings.opencodeBinary`.
|
||||
2. Environment overrides: `OPENCODE_BINARY`, `OPENCODE_PATH`, `OPENCHAMBER_OPENCODE_PATH`, or `OPENCHAMBER_OPENCODE_BIN`.
|
||||
3. The bundled Desktop CLI in `process.resourcesPath/opencode-cli`.
|
||||
4. System installs discovered from PATH.
|
||||
5. Known npm/Bun/Homebrew/Scoop/Chocolatey and other standard install locations.
|
||||
6. Platform discovery through `where opencode` on Windows or a login shell on macOS/Linux.
|
||||
|
||||
Use an explicit override when testing a different OpenCode CLI build or when a user needs to point Desktop at a custom binary. The configured path must point to the standalone CLI, not the OpenCode Desktop app executable.
|
||||
|
||||
@@ -106,6 +116,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
|----------|-----|
|
||||
| `OPENCHAMBER_ELECTRON_DEV=1` | Marks the runtime as desktop development mode |
|
||||
| `OPENCHAMBER_ELECTRON_USE_BUNDLED_UI=1` | Uses staged web assets instead of the HMR dev server |
|
||||
| `OPENCHAMBER_SKIP_LOCAL_SERVER=1` | Skips the in-process local OpenChamber server and uses the configured default remote instance; Desktop imports this from the user's login-shell environment, and packaged/bundled UI remains available for connection recovery |
|
||||
| `OPENCHAMBER_HMR_UI_PORT` | Preferred Vite UI port for desktop dev, default `5173` |
|
||||
| `OPENCHAMBER_HMR_API_PORT` | Preferred API port for desktop dev, default `3901` |
|
||||
| `OPENCHAMBER_RUNTIME=desktop` | Set by Electron before starting the web server |
|
||||
@@ -113,6 +124,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
| `OPENCHAMBER_TARGET_ARCH` | Explicit desktop package architecture (`x64` or `arm64`); Linux requires it to match the native host |
|
||||
| `OPENCHAMBER_DESKTOP_NOTIFY=true` | Enables desktop notification flow in the web server |
|
||||
| `OPENCHAMBER_SKIP_API_COMPRESSION=true` | Defaulted by Desktop to reduce local CPU overhead |
|
||||
| `OPENCHAMBER_STARTUP_PERF=1` | Enables privacy-safe startup phase timings in Desktop/server logs; disabled by default |
|
||||
| `OPENCODE_HOST` / `OPENCODE_PORT` / `OPENCODE_SKIP_START` | Connect Desktop to an external OpenCode server instead of starting one locally |
|
||||
|
||||
## Native Features Owned Here
|
||||
@@ -124,6 +136,7 @@ Use an explicit override when testing a different OpenCode CLI build or when a u
|
||||
- Desktop host switcher and deep-link imports.
|
||||
- Local and remote instance handling.
|
||||
- SSH host import, connections, logs, and port forwarding.
|
||||
- SSH uses OpenSSH ControlMaster on macOS/Linux. Windows uses independent hidden OpenSSH processes for setup commands and each long-lived forward because Win32 OpenSSH does not support ControlMaster reliably.
|
||||
- Tunnel lifecycle integration through the web server runtime.
|
||||
- Auto-update checks, downloads, and restart/apply flow.
|
||||
|
||||
|
||||
@@ -0,0 +1,539 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const DEFAULT_XDG_DATA_DIRS = ['/usr/local/share', '/usr/share'];
|
||||
const TARGET_FIELD_CODES = new Set(['f', 'F', 'u', 'U']);
|
||||
const TERMINAL_APP_IDS = new Set(['terminal', 'iterm2', 'ghostty']);
|
||||
|
||||
export const LINUX_CLI_BY_APP_ID = {
|
||||
vscode: 'code',
|
||||
cursor: 'cursor',
|
||||
vscodium: 'codium',
|
||||
windsurf: 'windsurf',
|
||||
zed: 'zed',
|
||||
'sublime-text': 'subl',
|
||||
};
|
||||
|
||||
const uniqueStrings = (values) => {
|
||||
const seen = new Set();
|
||||
const result = [];
|
||||
for (const value of values) {
|
||||
const candidate = typeof value === 'string' ? value.trim() : '';
|
||||
if (!candidate || seen.has(candidate)) continue;
|
||||
seen.add(candidate);
|
||||
result.push(candidate);
|
||||
}
|
||||
return result;
|
||||
};
|
||||
|
||||
const desktopBoolean = (value) => String(value || '').trim().toLowerCase() === 'true';
|
||||
const unescapeDesktopValue = (value) => String(value || '')
|
||||
.replace(/\\s/g, ' ')
|
||||
.replace(/\\n/g, '\n')
|
||||
.replace(/\\t/g, '\t')
|
||||
.replace(/\\r/g, '\r')
|
||||
.replace(/\\\\/g, '\\');
|
||||
const normalizeComparable = (value) => String(value || '')
|
||||
.toLowerCase()
|
||||
.replace(/\.desktop$/i, '')
|
||||
.replace(/[^a-z0-9]+/g, ' ')
|
||||
.trim();
|
||||
const normalizeCompactComparable = (value) => normalizeComparable(value).replace(/\s+/g, '');
|
||||
|
||||
export const stripDesktopExecFieldCodes = (execValue) => String(execValue || '')
|
||||
.replace(/%%/g, '\^@')
|
||||
.replace(/%[fFuUdDnNickvm]/g, '')
|
||||
.replace(/%./g, '')
|
||||
.replace(/\^@/g, '%')
|
||||
.replace(/\s+/g, ' ')
|
||||
.trim();
|
||||
|
||||
export const linuxApplicationDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => {
|
||||
const dataHome = typeof env.XDG_DATA_HOME === 'string' && env.XDG_DATA_HOME.trim()
|
||||
? env.XDG_DATA_HOME.trim()
|
||||
: path.join(homeDir || os.homedir(), '.local', 'share');
|
||||
const dataDirs = typeof env.XDG_DATA_DIRS === 'string' && env.XDG_DATA_DIRS.trim()
|
||||
? env.XDG_DATA_DIRS.split(':').filter(Boolean)
|
||||
: DEFAULT_XDG_DATA_DIRS;
|
||||
return uniqueStrings([
|
||||
path.join(dataHome, 'applications'),
|
||||
...dataDirs.map((dir) => path.join(dir, 'applications')),
|
||||
'/usr/local/share/applications',
|
||||
'/usr/share/applications',
|
||||
]).map((entry) => path.resolve(entry));
|
||||
};
|
||||
|
||||
const parseDesktopValues = (content) => {
|
||||
const values = new Map();
|
||||
let group = '';
|
||||
for (const rawLine of String(content || '').split(/\r?\n/)) {
|
||||
const line = rawLine.trim();
|
||||
if (!line || line.startsWith('#')) continue;
|
||||
if (line.startsWith('[') && line.endsWith(']')) {
|
||||
group = line.slice(1, -1).trim();
|
||||
continue;
|
||||
}
|
||||
if (group !== 'Desktop Entry') continue;
|
||||
const separator = line.indexOf('=');
|
||||
if (separator <= 0) continue;
|
||||
const key = line.slice(0, separator).trim();
|
||||
if (!key || key.includes('[') || values.has(key)) continue;
|
||||
values.set(key, unescapeDesktopValue(line.slice(separator + 1)));
|
||||
}
|
||||
return values;
|
||||
};
|
||||
|
||||
export const parseDesktopEntry = (content, filePath = '') => {
|
||||
const values = parseDesktopValues(content);
|
||||
if ((values.get('Type') || 'Application') !== 'Application') return null;
|
||||
if (desktopBoolean(values.get('NoDisplay')) || desktopBoolean(values.get('Hidden'))) return null;
|
||||
const name = String(values.get('Name') || '').trim();
|
||||
const rawExec = String(values.get('Exec') || '').trim();
|
||||
const exec = stripDesktopExecFieldCodes(rawExec);
|
||||
if (!name || !rawExec || !exec) return null;
|
||||
return {
|
||||
id: path.basename(filePath || '').replace(/\.desktop$/i, '') || name,
|
||||
name,
|
||||
exec,
|
||||
rawExec,
|
||||
icon: String(values.get('Icon') || '').trim() || null,
|
||||
categories: String(values.get('Categories') || '').split(';').map((entry) => entry.trim()).filter(Boolean),
|
||||
filePath,
|
||||
};
|
||||
};
|
||||
|
||||
const collectDesktopFiles = async (dir) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fsp.readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const files = [];
|
||||
for (const entry of entries) {
|
||||
const candidate = path.join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...await collectDesktopFiles(candidate));
|
||||
} else if (entry.isFile() && entry.name.toLowerCase().endsWith('.desktop')) {
|
||||
files.push(candidate);
|
||||
}
|
||||
}
|
||||
return files;
|
||||
};
|
||||
|
||||
export const readLinuxDesktopEntries = async (options = {}) => {
|
||||
const dirs = Array.isArray(options.applicationDirs) ? options.applicationDirs : linuxApplicationDirs(options);
|
||||
const files = [];
|
||||
for (const dir of dirs) files.push(...await collectDesktopFiles(dir));
|
||||
const seen = new Set();
|
||||
const entries = [];
|
||||
for (const filePath of files) {
|
||||
try {
|
||||
const parsed = parseDesktopEntry(await fsp.readFile(filePath, 'utf8'), filePath);
|
||||
if (!parsed || seen.has(parsed.id)) continue;
|
||||
seen.add(parsed.id);
|
||||
entries.push(parsed);
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return entries.sort((left, right) => left.name.localeCompare(right.name));
|
||||
};
|
||||
|
||||
export const discoverLinuxDesktopApps = readLinuxDesktopEntries;
|
||||
|
||||
export const desktopEntryMatchesApp = (entry, appName, appId = '') => {
|
||||
const needles = uniqueStrings([appName, appId]).flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]).filter(Boolean);
|
||||
const haystacks = [entry.name, entry.id, path.basename(entry.filePath || ''), entry.exec]
|
||||
.flatMap((value) => [normalizeComparable(value), normalizeCompactComparable(value)]);
|
||||
return needles.some((needle) => haystacks.some((haystack) => haystack === needle || haystack.includes(needle) || needle.includes(haystack)));
|
||||
};
|
||||
|
||||
const parseExecCommand = (exec) => {
|
||||
const args = [];
|
||||
let current = '';
|
||||
let quote = null;
|
||||
let escaped = false;
|
||||
for (const char of String(exec || '')) {
|
||||
if (escaped) {
|
||||
current += char;
|
||||
escaped = false;
|
||||
continue;
|
||||
}
|
||||
if (char === '\\') {
|
||||
escaped = true;
|
||||
continue;
|
||||
}
|
||||
if (quote) {
|
||||
if (char === quote) quote = null;
|
||||
else current += char;
|
||||
continue;
|
||||
}
|
||||
if (char === '"' || char === "'") {
|
||||
quote = char;
|
||||
continue;
|
||||
}
|
||||
if (/\s/.test(char)) {
|
||||
if (current) {
|
||||
args.push(current);
|
||||
current = '';
|
||||
}
|
||||
continue;
|
||||
}
|
||||
current += char;
|
||||
}
|
||||
if (escaped) current += '\\';
|
||||
if (current) args.push(current);
|
||||
return args;
|
||||
};
|
||||
|
||||
export const buildCommandFromDesktopExec = (entry, targetPath) => {
|
||||
const tokens = parseExecCommand(entry?.rawExec || entry?.exec || '');
|
||||
if (tokens.length === 0) return null;
|
||||
let targetInserted = false;
|
||||
const args = [];
|
||||
for (const token of tokens.slice(1)) {
|
||||
let rendered = token.replace(/%([a-zA-Z%])/g, (_match, code) => {
|
||||
if (TARGET_FIELD_CODES.has(code)) {
|
||||
targetInserted = true;
|
||||
return targetPath;
|
||||
}
|
||||
if (code === 'c') return entry.name || '';
|
||||
if (code === 'k') return entry.filePath || '';
|
||||
if (code === '%') return '%';
|
||||
return '';
|
||||
});
|
||||
rendered = rendered.trim();
|
||||
if (rendered) args.push(rendered);
|
||||
}
|
||||
if (!targetInserted) args.push(targetPath);
|
||||
return { program: tokens[0], args };
|
||||
};
|
||||
|
||||
const commandExists = (program, env = process.env) => {
|
||||
if (!program) return false;
|
||||
if (program.includes(path.sep)) {
|
||||
try {
|
||||
fs.accessSync(program, fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
for (const dir of String(env.PATH || '').split(':').filter(Boolean)) {
|
||||
try {
|
||||
fs.accessSync(path.join(dir, program), fs.constants.X_OK);
|
||||
return true;
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
return false;
|
||||
};
|
||||
|
||||
const findEntry = (entries, appId, appName) => entries.find((entry) => desktopEntryMatchesApp(entry, appName, appId)) || null;
|
||||
|
||||
export const buildLinuxOpenSpecs = ({ targetPath, appId, appName, targetKind = 'path', entries = [], env = process.env }) => {
|
||||
if (appId === 'finder') {
|
||||
return [{ kind: 'default', targetKind, targetPath }];
|
||||
}
|
||||
const specs = [];
|
||||
if (TERMINAL_APP_IDS.has(appId)) {
|
||||
const directory = targetKind === 'file' ? path.dirname(targetPath) : targetPath;
|
||||
const terminalEntry = findEntry(entries, appId, appName);
|
||||
if (terminalEntry) {
|
||||
const spec = buildCommandFromDesktopExec(terminalEntry, directory);
|
||||
if (spec) specs.push(spec);
|
||||
}
|
||||
specs.push({ program: 'xdg-terminal-exec', args: ['--working-directory', directory] });
|
||||
if (commandExists('gnome-terminal', env)) {
|
||||
specs.push({ program: 'gnome-terminal', args: [`--working-directory=${directory}`] });
|
||||
}
|
||||
if (commandExists('konsole', env)) {
|
||||
specs.push({ program: 'konsole', args: ['--workdir', directory] });
|
||||
}
|
||||
if (commandExists('xfce4-terminal', env)) {
|
||||
specs.push({ program: 'xfce4-terminal', args: [`--working-directory=${directory}`] });
|
||||
}
|
||||
if (commandExists('x-terminal-emulator', env)) {
|
||||
specs.push({ program: 'x-terminal-emulator', args: [] });
|
||||
}
|
||||
return specs;
|
||||
}
|
||||
const cli = LINUX_CLI_BY_APP_ID[appId];
|
||||
if (cli && commandExists(cli, env)) {
|
||||
specs.push({ program: cli, args: appId === 'zed' ? [targetPath] : ['-n', targetPath] });
|
||||
}
|
||||
const entry = findEntry(entries, appId, appName);
|
||||
if (entry) {
|
||||
const spec = buildCommandFromDesktopExec(entry, targetPath);
|
||||
if (spec) specs.push(spec);
|
||||
}
|
||||
return specs;
|
||||
};
|
||||
|
||||
export const filterLinuxInstalledApps = async (apps, options = {}) => {
|
||||
const entries = options.entries || await readLinuxDesktopEntries(options);
|
||||
const requested = Array.isArray(apps) ? apps : [];
|
||||
return requested
|
||||
.map((appName) => String(appName || '').trim())
|
||||
.filter((appName) => appName && entries.some((entry) => desktopEntryMatchesApp(entry, appName)));
|
||||
};
|
||||
|
||||
const FILE_MANAGER_FALLBACK_IDS = [
|
||||
'org.gnome.Nautilus',
|
||||
'org.xfce.thunar',
|
||||
'thunar',
|
||||
'nemo',
|
||||
'org.kde.dolphin',
|
||||
'dolphin',
|
||||
'pcmanfm',
|
||||
'caja',
|
||||
'nautilus',
|
||||
'xfce4-file-manager',
|
||||
];
|
||||
|
||||
const FILE_MANAGER_ICON_FALLBACKS = [
|
||||
'system-file-manager',
|
||||
'org.xfce.thunar',
|
||||
'org.gnome.Nautilus',
|
||||
'folder',
|
||||
];
|
||||
|
||||
const ICON_SIZE_DIRS = [
|
||||
'48x48', '48',
|
||||
'32x32', '32',
|
||||
'64x64', '64',
|
||||
'24x24', '24',
|
||||
'22x22', '22',
|
||||
'16x16', '16',
|
||||
'128x128', '128',
|
||||
'256x256', '256',
|
||||
'scalable',
|
||||
];
|
||||
|
||||
const ICON_CATEGORIES = ['apps', 'places', 'status', 'devices', 'mimetypes', 'legacy'];
|
||||
|
||||
const pathExistsSync = (candidate) => {
|
||||
try {
|
||||
fs.accessSync(candidate, fs.constants.R_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const linuxIconThemeDirs = ({ env = process.env, homeDir = os.homedir() } = {}) => {
|
||||
const dataHome = typeof env.XDG_DATA_HOME === 'string' && env.XDG_DATA_HOME.trim()
|
||||
? env.XDG_DATA_HOME.trim()
|
||||
: path.join(homeDir || os.homedir(), '.local', 'share');
|
||||
const dataDirs = typeof env.XDG_DATA_DIRS === 'string' && env.XDG_DATA_DIRS.trim()
|
||||
? env.XDG_DATA_DIRS.split(':').filter(Boolean)
|
||||
: DEFAULT_XDG_DATA_DIRS;
|
||||
return uniqueStrings([
|
||||
path.join(dataHome, 'icons'),
|
||||
path.join(homeDir || os.homedir(), '.icons'),
|
||||
...dataDirs.map((dir) => path.join(dir, 'icons')),
|
||||
'/usr/local/share/icons',
|
||||
'/usr/share/icons',
|
||||
]).map((entry) => path.resolve(entry));
|
||||
};
|
||||
|
||||
const listThemeNames = (iconsRoot) => {
|
||||
let entries;
|
||||
try {
|
||||
entries = fs.readdirSync(iconsRoot, { withFileTypes: true });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
const themes = entries
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name);
|
||||
// Prefer the freedesktop fallback theme first, then whatever else is installed.
|
||||
themes.sort((left, right) => {
|
||||
if (left === 'hicolor') return -1;
|
||||
if (right === 'hicolor') return 1;
|
||||
return left.localeCompare(right);
|
||||
});
|
||||
return themes;
|
||||
};
|
||||
|
||||
const lookForIconInTheme = (themeRoot, iconName) => {
|
||||
let pngMatch = null;
|
||||
let svgMatch = null;
|
||||
for (const size of ICON_SIZE_DIRS) {
|
||||
for (const category of ICON_CATEGORIES) {
|
||||
const pngPath = path.join(themeRoot, size, category, `${iconName}.png`);
|
||||
if (pathExistsSync(pngPath)) {
|
||||
// Prefer mid-size PNGs that UI list icons can display without SVG tooling.
|
||||
if (size !== 'scalable') return pngPath;
|
||||
pngMatch = pngMatch || pngPath;
|
||||
}
|
||||
const svgPath = path.join(themeRoot, size, category, `${iconName}.svg`);
|
||||
if (!svgMatch && pathExistsSync(svgPath)) svgMatch = svgPath;
|
||||
}
|
||||
}
|
||||
return pngMatch || svgMatch;
|
||||
};
|
||||
|
||||
export const resolveLinuxIconFile = (iconName, options = {}) => {
|
||||
const raw = typeof iconName === 'string' ? iconName.trim() : '';
|
||||
if (!raw) return null;
|
||||
if (path.isAbsolute(raw) && pathExistsSync(raw)) return raw;
|
||||
if (raw.includes(path.sep) && pathExistsSync(raw)) return path.resolve(raw);
|
||||
|
||||
const baseName = raw.replace(/\.(png|svg|xpm|ico)$/i, '');
|
||||
const iconRoots = linuxIconThemeDirs(options);
|
||||
for (const iconsRoot of iconRoots) {
|
||||
for (const theme of listThemeNames(iconsRoot)) {
|
||||
const match = lookForIconInTheme(path.join(iconsRoot, theme), baseName);
|
||||
if (match) return match;
|
||||
}
|
||||
}
|
||||
|
||||
const dataHome = typeof options.env?.XDG_DATA_HOME === 'string' && options.env.XDG_DATA_HOME.trim()
|
||||
? options.env.XDG_DATA_HOME.trim()
|
||||
: path.join(options.homeDir || os.homedir(), '.local', 'share');
|
||||
const dataDirs = typeof options.env?.XDG_DATA_DIRS === 'string' && options.env.XDG_DATA_DIRS.trim()
|
||||
? options.env.XDG_DATA_DIRS.split(':').filter(Boolean)
|
||||
: DEFAULT_XDG_DATA_DIRS;
|
||||
for (const pixmapsDir of uniqueStrings([
|
||||
path.join(dataHome, 'pixmaps'),
|
||||
...dataDirs.map((dir) => path.join(dir, 'pixmaps')),
|
||||
'/usr/share/pixmaps',
|
||||
'/usr/local/share/pixmaps',
|
||||
])) {
|
||||
for (const ext of ['.png', '.svg', '.xpm']) {
|
||||
const candidate = path.join(pixmapsDir, `${baseName}${ext}`);
|
||||
if (pathExistsSync(candidate)) return candidate;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
export const resolveDefaultLinuxFileManagerId = ({ env = process.env, execFileSyncImpl = execFileSync } = {}) => {
|
||||
try {
|
||||
const output = String(execFileSyncImpl('xdg-mime', ['query', 'default', 'inode/directory'], {
|
||||
encoding: 'utf8',
|
||||
timeout: 1500,
|
||||
env,
|
||||
}) || '').trim();
|
||||
if (!output) return null;
|
||||
return output.replace(/\.desktop$/i, '');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export const findLinuxFileManagerEntry = (entries, options = {}) => {
|
||||
const list = Array.isArray(entries) ? entries : [];
|
||||
const defaultId = resolveDefaultLinuxFileManagerId(options);
|
||||
if (defaultId) {
|
||||
const match = list.find((entry) => (
|
||||
entry.id === defaultId
|
||||
|| path.basename(entry.filePath || '', '.desktop') === defaultId
|
||||
|| normalizeComparable(entry.id) === normalizeComparable(defaultId)
|
||||
));
|
||||
if (match) return match;
|
||||
}
|
||||
for (const fallbackId of FILE_MANAGER_FALLBACK_IDS) {
|
||||
const match = list.find((entry) => (
|
||||
entry.id === fallbackId
|
||||
|| path.basename(entry.filePath || '', '.desktop') === fallbackId
|
||||
|| normalizeComparable(entry.id) === normalizeComparable(fallbackId)
|
||||
));
|
||||
if (match) return match;
|
||||
}
|
||||
return list.find((entry) => {
|
||||
const categories = Array.isArray(entry.categories) ? entry.categories : [];
|
||||
return categories.includes('FileManager') || categories.includes('FileTools');
|
||||
}) || null;
|
||||
};
|
||||
|
||||
const iconFileToDataUrl = (filePath) => {
|
||||
if (!filePath || !/\.png$/i.test(filePath)) return null;
|
||||
try {
|
||||
return `data:image/png;base64,${fs.readFileSync(filePath).toString('base64')}`;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveIconDataUrlForName = (iconNames, options = {}) => {
|
||||
for (const iconName of uniqueStrings(iconNames)) {
|
||||
const filePath = resolveLinuxIconFile(iconName, options);
|
||||
const dataUrl = iconFileToDataUrl(filePath);
|
||||
if (dataUrl) return dataUrl;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const isLinuxFileManagerName = (name) => {
|
||||
const normalized = normalizeComparable(name);
|
||||
return normalized === 'finder'
|
||||
|| normalized === 'file manager'
|
||||
|| normalized === 'file explorer';
|
||||
};
|
||||
|
||||
const knownLinuxAppIdForName = (name) => {
|
||||
const normalized = normalizeComparable(name);
|
||||
const knownIdByName = new Map([
|
||||
['visual studio code', 'vscode'],
|
||||
['cursor', 'cursor'],
|
||||
['vscodium', 'vscodium'],
|
||||
['windsurf', 'windsurf'],
|
||||
['zed', 'zed'],
|
||||
['sublime text', 'sublime-text'],
|
||||
]);
|
||||
if (knownIdByName.has(normalized)) return knownIdByName.get(normalized);
|
||||
return Object.entries(LINUX_CLI_BY_APP_ID).find(([, cli]) => {
|
||||
return normalized.includes(normalizeComparable(cli)) || normalizeCompactComparable(name).includes(cli);
|
||||
})?.[0] || null;
|
||||
};
|
||||
|
||||
export const buildLinuxInstalledApps = async (apps, options = {}) => {
|
||||
const entries = options.entries || await readLinuxDesktopEntries(options);
|
||||
const env = options.env || process.env;
|
||||
const names = uniqueStrings(Array.isArray(apps) ? apps.map(String) : []);
|
||||
const fileManagerEntry = findLinuxFileManagerEntry(entries, { ...options, env });
|
||||
return names
|
||||
.filter((name) => {
|
||||
const normalized = normalizeComparable(name);
|
||||
if (isLinuxFileManagerName(name)) return true;
|
||||
if (normalized === 'terminal') return true;
|
||||
if (entries.some((entry) => desktopEntryMatchesApp(entry, name))) return true;
|
||||
const mappedId = knownLinuxAppIdForName(name);
|
||||
const cli = mappedId ? LINUX_CLI_BY_APP_ID[mappedId] : '';
|
||||
return Boolean(cli && commandExists(cli, env));
|
||||
})
|
||||
.map((name) => {
|
||||
let iconDataUrl = null;
|
||||
if (isLinuxFileManagerName(name)) {
|
||||
iconDataUrl = resolveIconDataUrlForName([
|
||||
fileManagerEntry?.icon,
|
||||
...FILE_MANAGER_ICON_FALLBACKS,
|
||||
], { ...options, env });
|
||||
} else if (normalizeComparable(name) === 'terminal') {
|
||||
const terminalEntry = findEntry(entries, 'terminal', name)
|
||||
|| findEntry(entries, 'ghostty', 'Ghostty');
|
||||
iconDataUrl = resolveIconDataUrlForName([
|
||||
terminalEntry?.icon,
|
||||
'utilities-terminal',
|
||||
'org.gnome.Terminal',
|
||||
'terminal',
|
||||
], { ...options, env });
|
||||
} else {
|
||||
const entry = findEntry(entries, knownLinuxAppIdForName(name) || '', name);
|
||||
iconDataUrl = resolveIconDataUrlForName([entry?.icon], { ...options, env });
|
||||
}
|
||||
return { name, iconDataUrl };
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchLinuxAppIcons = async (apps = [], options = {}) => {
|
||||
const infos = await buildLinuxInstalledApps(apps, options);
|
||||
return infos
|
||||
.filter((entry) => typeof entry.iconDataUrl === 'string' && entry.iconDataUrl)
|
||||
.map((entry) => ({ app: entry.name, data_url: entry.iconDataUrl }));
|
||||
};
|
||||
@@ -0,0 +1,99 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
const AUTOSTART_FILE_NAME = 'openchamber.desktop';
|
||||
|
||||
export const resolveLinuxAutostartDirectory = ({
|
||||
env = process.env,
|
||||
homeDir = os.homedir(),
|
||||
} = {}) => {
|
||||
const configHome = typeof env.XDG_CONFIG_HOME === 'string' && env.XDG_CONFIG_HOME.trim()
|
||||
? env.XDG_CONFIG_HOME.trim()
|
||||
: path.join(homeDir || os.homedir(), '.config');
|
||||
return path.join(configHome, 'autostart');
|
||||
};
|
||||
|
||||
export const resolveLinuxAutostartFilePath = (options = {}) =>
|
||||
path.join(resolveLinuxAutostartDirectory(options), AUTOSTART_FILE_NAME);
|
||||
|
||||
export const resolveLinuxLaunchExecutable = ({
|
||||
env = process.env,
|
||||
execPath = process.execPath,
|
||||
} = {}) => {
|
||||
const appImage = typeof env.APPIMAGE === 'string' ? env.APPIMAGE.trim() : '';
|
||||
if (appImage && path.isAbsolute(appImage)) {
|
||||
return appImage;
|
||||
}
|
||||
return execPath;
|
||||
};
|
||||
|
||||
const quoteDesktopExecArg = (value) => {
|
||||
const text = String(value ?? '');
|
||||
if (!/[ \t\n"$\\]/.test(text)) {
|
||||
return text;
|
||||
}
|
||||
return `"${text.replace(/(["\\$`])/g, '\\$1')}"`;
|
||||
};
|
||||
|
||||
export const buildLinuxAutostartDesktopEntry = ({
|
||||
appName = 'OpenChamber',
|
||||
executable,
|
||||
backgroundArg,
|
||||
env = process.env,
|
||||
execPath = process.execPath,
|
||||
} = {}) => {
|
||||
const launchPath = executable || resolveLinuxLaunchExecutable({ env, execPath });
|
||||
const args = [quoteDesktopExecArg(launchPath)];
|
||||
if (typeof backgroundArg === 'string' && backgroundArg.trim()) {
|
||||
args.push(backgroundArg.trim());
|
||||
}
|
||||
return [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
`Name=${appName}`,
|
||||
`Exec=${args.join(' ')}`,
|
||||
'Terminal=false',
|
||||
'X-GNOME-Autostart-enabled=true',
|
||||
'StartupWMClass=openchamber',
|
||||
'',
|
||||
].join('\n');
|
||||
};
|
||||
|
||||
export const readLinuxAutostartEnabled = async (options = {}) => {
|
||||
const filePath = resolveLinuxAutostartFilePath(options);
|
||||
try {
|
||||
await fsp.access(filePath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
export const setLinuxAutostartEnabled = async ({
|
||||
enabled,
|
||||
appName = 'OpenChamber',
|
||||
backgroundArg,
|
||||
env = process.env,
|
||||
execPath = process.execPath,
|
||||
homeDir = os.homedir(),
|
||||
} = {}) => {
|
||||
const directory = resolveLinuxAutostartDirectory({ env, homeDir });
|
||||
const filePath = path.join(directory, AUTOSTART_FILE_NAME);
|
||||
|
||||
if (!enabled) {
|
||||
await fsp.rm(filePath, { force: true });
|
||||
return { supported: true, enabled: false, filePath };
|
||||
}
|
||||
|
||||
await fsp.mkdir(directory, { recursive: true });
|
||||
const contents = buildLinuxAutostartDesktopEntry({
|
||||
appName,
|
||||
backgroundArg,
|
||||
env,
|
||||
execPath,
|
||||
});
|
||||
await fsp.writeFile(filePath, contents, 'utf8');
|
||||
return { supported: true, enabled: true, filePath };
|
||||
};
|
||||
@@ -0,0 +1,66 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
|
||||
import {
|
||||
buildLinuxAutostartDesktopEntry,
|
||||
readLinuxAutostartEnabled,
|
||||
resolveLinuxAutostartFilePath,
|
||||
resolveLinuxLaunchExecutable,
|
||||
setLinuxAutostartEnabled,
|
||||
} from './linux-autostart.mjs';
|
||||
|
||||
test('prefers APPIMAGE path for Linux autostart Exec', () => {
|
||||
assert.equal(
|
||||
resolveLinuxLaunchExecutable({
|
||||
env: { APPIMAGE: '/home/user/OpenChamber.AppImage' },
|
||||
execPath: '/tmp/.mount_OpenChXXXX/openchamber',
|
||||
}),
|
||||
'/home/user/OpenChamber.AppImage',
|
||||
);
|
||||
});
|
||||
|
||||
test('builds a background autostart desktop entry', () => {
|
||||
const entry = buildLinuxAutostartDesktopEntry({
|
||||
executable: '/home/user/Open Chamber.AppImage',
|
||||
backgroundArg: '--background',
|
||||
});
|
||||
assert.match(entry, /Type=Application/);
|
||||
assert.match(entry, /Exec="\/home\/user\/Open Chamber\.AppImage" --background/);
|
||||
assert.match(entry, /X-GNOME-Autostart-enabled=true/);
|
||||
});
|
||||
|
||||
test('writes and removes the XDG autostart file', async () => {
|
||||
const homeDir = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-autostart-'));
|
||||
const env = { XDG_CONFIG_HOME: path.join(homeDir, 'config') };
|
||||
const filePath = resolveLinuxAutostartFilePath({ env, homeDir });
|
||||
|
||||
try {
|
||||
assert.equal(await readLinuxAutostartEnabled({ env, homeDir }), false);
|
||||
|
||||
const enabled = await setLinuxAutostartEnabled({
|
||||
enabled: true,
|
||||
backgroundArg: '--background',
|
||||
env: { ...env, APPIMAGE: '/opt/OpenChamber.AppImage' },
|
||||
homeDir,
|
||||
});
|
||||
assert.equal(enabled.enabled, true);
|
||||
assert.equal(enabled.filePath, filePath);
|
||||
assert.equal(await readLinuxAutostartEnabled({ env, homeDir }), true);
|
||||
|
||||
const contents = await fs.readFile(filePath, 'utf8');
|
||||
assert.match(contents, /Exec=\/opt\/OpenChamber\.AppImage --background/);
|
||||
|
||||
const disabled = await setLinuxAutostartEnabled({
|
||||
enabled: false,
|
||||
env,
|
||||
homeDir,
|
||||
});
|
||||
assert.equal(disabled.enabled, false);
|
||||
assert.equal(await readLinuxAutostartEnabled({ env, homeDir }), false);
|
||||
} finally {
|
||||
await fs.rm(homeDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
+411
-76
@@ -13,10 +13,24 @@ import updaterPkg from 'electron-updater';
|
||||
import { ElectronSshManager } from './ssh-manager.mjs';
|
||||
import { createTrayController } from './tray.mjs';
|
||||
import { resolveManagedOpenCodeCwd } from './opencode-cwd.mjs';
|
||||
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
|
||||
import { sanitizeRuntimeRequestHeaders } from './runtime-request-headers.mjs';
|
||||
import { assertUpdaterCapability } from './updater-capability.mjs';
|
||||
import { checkForDesktopUpdate } from './updater-check.mjs';
|
||||
import { resolveUpdaterChannel } from './updater-channel.mjs';
|
||||
import { resolveUpdaterFeed } from './updater-feed.mjs';
|
||||
import {
|
||||
buildLinuxInstalledApps,
|
||||
buildLinuxOpenSpecs,
|
||||
fetchLinuxAppIcons,
|
||||
filterLinuxInstalledApps,
|
||||
readLinuxDesktopEntries,
|
||||
} from './linux-app-discovery.mjs';
|
||||
import {
|
||||
readLinuxAutostartEnabled,
|
||||
setLinuxAutostartEnabled,
|
||||
} from './linux-autostart.mjs';
|
||||
import { unsupportedAppSpecificOpenError, validateLocalPath } from './path-open-utils.mjs';
|
||||
import { mintOutsideFileGrant } from '@openchamber/web/server/lib/fs/routes.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -24,6 +38,7 @@ const execFileAsync = promisify(execFile);
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const isDev = process.env.OPENCHAMBER_ELECTRON_DEV === '1' || !app.isPackaged;
|
||||
const electronStartupStartedAt = performance.now();
|
||||
|
||||
const DEEP_LINK_PROTOCOL = 'openchamber';
|
||||
const UI_PROTOCOL = 'openchamber-ui';
|
||||
@@ -44,6 +59,9 @@ const getLoginItemOptions = () => {
|
||||
};
|
||||
|
||||
const readLoginItemSettings = () => {
|
||||
if (process.platform === 'linux') {
|
||||
return null;
|
||||
}
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') return null;
|
||||
try {
|
||||
return app.getLoginItemSettings(getLoginItemOptions());
|
||||
@@ -71,6 +89,16 @@ if (isDev) {
|
||||
}
|
||||
app.setAppUserModelId(APP_USER_MODEL_ID);
|
||||
app.commandLine.appendSwitch('proxy-bypass-list', '<-loopback>');
|
||||
// Lift Chromium's per-host cap only for bundled UI. Applying this to Vite HMR
|
||||
// lets the renderer request most of the module graph at once, overwhelming the
|
||||
// dev server's transform pipeline and leaving the HTML splash visible for up
|
||||
// to a minute before React mounts.
|
||||
if (shouldIgnoreLoopbackConnectionLimit({
|
||||
development: isDev,
|
||||
packagedUi: process.env.OPENCHAMBER_ELECTRON_USE_BUNDLED_UI === '1',
|
||||
})) {
|
||||
app.commandLine.appendSwitch('ignore-connections-limit', '127.0.0.1,localhost');
|
||||
}
|
||||
|
||||
protocol.registerSchemesAsPrivileged([
|
||||
{
|
||||
@@ -107,6 +135,32 @@ log.transports.console.level = isDev ? 'debug' : 'warn';
|
||||
// diagnostics are persisted.
|
||||
Object.assign(console, log.functions);
|
||||
|
||||
const STARTUP_PERF_ENABLED_VALUES = new Set(['1', 'true']);
|
||||
const ELECTRON_STARTUP_PERF_PHASES = new Set([
|
||||
'electron.app.ready',
|
||||
'electron.server.start',
|
||||
'electron.server.ready',
|
||||
'electron.navigation.start',
|
||||
'electron.navigation.ready',
|
||||
'electron.renderer.dom-ready',
|
||||
'electron.renderer.loaded',
|
||||
'electron.window.ready-to-show',
|
||||
]);
|
||||
const ELECTRON_STARTUP_DOCUMENT_CLASSES = new Set(['splash', 'application']);
|
||||
const recordElectronStartupPerformance = (phase, details = {}) => {
|
||||
const enabled = STARTUP_PERF_ENABLED_VALUES.has(String(process.env.OPENCHAMBER_STARTUP_PERF ?? '').toLowerCase());
|
||||
if (!enabled || !ELECTRON_STARTUP_PERF_PHASES.has(phase)) return;
|
||||
const event = {
|
||||
phase,
|
||||
at: Date.now(),
|
||||
totalDurationMs: Math.max(0, performance.now() - electronStartupStartedAt),
|
||||
};
|
||||
if (Number.isFinite(details.durationMs) && details.durationMs >= 0) event.durationMs = details.durationMs;
|
||||
if (ELECTRON_STARTUP_DOCUMENT_CLASSES.has(details.documentClass)) event.documentClass = details.documentClass;
|
||||
log.info('[startup-performance]', event);
|
||||
};
|
||||
const classifyStartupDocument = (url) => String(url || '').startsWith('data:') ? 'splash' : 'application';
|
||||
|
||||
const LOG_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
try {
|
||||
const logPath = log.transports.file.getFile().path;
|
||||
@@ -182,8 +236,8 @@ const GITHUB_FEATURE_REQUEST_URL = 'https://github.com/openchamber/openchamber/i
|
||||
const DISCORD_INVITE_URL = 'https://discord.gg/ZYRSdnwwKA';
|
||||
const INSTALLED_APPS_CACHE_TTL_SECS = 60 * 60 * 24;
|
||||
const INSTALLED_APPS_CACHE_FILE = 'discovered-apps.json';
|
||||
const LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS = 30_000;
|
||||
const OPENCODE_SHUTDOWN_GRACE_MS = 100;
|
||||
|
||||
const { autoUpdater } = updaterPkg;
|
||||
|
||||
const state = {
|
||||
@@ -194,6 +248,7 @@ const state = {
|
||||
clientToken: null,
|
||||
requestHeaders: {},
|
||||
bootOutcome: null,
|
||||
startupResolved: false,
|
||||
initScript: null,
|
||||
mainWindow: null,
|
||||
quitRequested: false,
|
||||
@@ -213,6 +268,7 @@ const state = {
|
||||
sshStatuses: new Map(),
|
||||
sshLogs: new Map(),
|
||||
trayController: null,
|
||||
trayFocusListener: null,
|
||||
lastFocusedWindowId: null,
|
||||
keepAwakeBlockerId: null,
|
||||
};
|
||||
@@ -243,7 +299,7 @@ const readDesktopKeepAwakeStatus = () => {
|
||||
};
|
||||
|
||||
const readDesktopMinimizeToTrayStatus = () => {
|
||||
const supported = process.platform === 'win32';
|
||||
const supported = process.platform === 'win32' || process.platform === 'linux';
|
||||
return {
|
||||
supported,
|
||||
enabled: supported && readSettingsRoot().desktopMinimizeToTrayEnabled === true,
|
||||
@@ -251,7 +307,7 @@ const readDesktopMinimizeToTrayStatus = () => {
|
||||
};
|
||||
|
||||
const shouldHideMainWindowToTray = (browserWindow) => {
|
||||
if (process.platform !== 'win32') return false;
|
||||
if (process.platform !== 'win32' && process.platform !== 'linux') return false;
|
||||
if (!state.trayController) return false;
|
||||
if (!browserWindow || browserWindow.isDestroyed()) return false;
|
||||
if (browserWindow.__ocMiniChat === true) return false;
|
||||
@@ -327,6 +383,10 @@ const prepareForQuit = ({ installingUpdate = false } = {}) => {
|
||||
}
|
||||
state.trayController = null;
|
||||
}
|
||||
if (state.trayFocusListener) {
|
||||
app.removeListener('browser-window-focus', state.trayFocusListener);
|
||||
state.trayFocusListener = null;
|
||||
}
|
||||
|
||||
if (state.mainWindow && !state.mainWindow.isDestroyed()) {
|
||||
try {
|
||||
@@ -490,12 +550,16 @@ const readJsonFile = (filePath) => {
|
||||
};
|
||||
|
||||
const writeJsonFile = async (filePath, data) => {
|
||||
await fsp.mkdir(path.dirname(filePath), { recursive: true });
|
||||
const directory = path.dirname(filePath);
|
||||
await fsp.mkdir(directory, { recursive: true, mode: 0o700 });
|
||||
if (process.platform !== 'win32') await fsp.chmod(directory, 0o700);
|
||||
// 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));
|
||||
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);
|
||||
};
|
||||
|
||||
const readSettingsRoot = () => {
|
||||
@@ -836,6 +900,16 @@ const buildVersionUrl = (url) => {
|
||||
}
|
||||
};
|
||||
|
||||
const buildSessionStatusUrl = (url) => {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
parsed.pathname = `${parsed.pathname.replace(/\/$/, '') || ''}/auth/session`;
|
||||
return parsed.toString();
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const classifyVersionPayload = (payload) => {
|
||||
const compatibility = payload?.compatibility;
|
||||
if (!payload || payload.status !== 'ok' || !compatibility || typeof compatibility !== 'object') {
|
||||
@@ -870,7 +944,8 @@ const fetchVersionPayload = async (versionUrl, { headers, timeoutMs }) => {
|
||||
|
||||
const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHeaders = {}, expectedServerId = '') => {
|
||||
const versionUrl = buildVersionUrl(url);
|
||||
if (!versionUrl) {
|
||||
const sessionStatusUrl = buildSessionStatusUrl(url);
|
||||
if (!versionUrl || !sessionStatusUrl) {
|
||||
throw new Error('Invalid URL');
|
||||
}
|
||||
|
||||
@@ -914,8 +989,19 @@ const probeHostWithTimeout = async (url, timeoutMs, clientToken = '', requestHea
|
||||
return { status: 'unreachable', latencyMs: Date.now() - started };
|
||||
}
|
||||
const payload = await response.json().catch(() => null);
|
||||
const versionStatus = classifyVersionPayload(payload);
|
||||
if (versionStatus !== 'ok') {
|
||||
return { status: versionStatus, latencyMs: Date.now() - started };
|
||||
}
|
||||
const sessionResponse = await fetchVersionPayload(sessionStatusUrl, { headers, timeoutMs });
|
||||
if (sessionResponse.status === 401 || sessionResponse.status === 403) {
|
||||
return { status: 'auth', latencyMs: Date.now() - started };
|
||||
}
|
||||
if (!sessionResponse.ok) {
|
||||
return { status: 'unreachable', latencyMs: Date.now() - started };
|
||||
}
|
||||
return {
|
||||
status: classifyVersionPayload(payload),
|
||||
status: versionStatus,
|
||||
latencyMs: Date.now() - started,
|
||||
};
|
||||
} catch {
|
||||
@@ -1314,7 +1400,14 @@ const inheritUserShellEnv = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const shouldSkipLocalServer = () => {
|
||||
inheritUserShellEnv();
|
||||
return process.env.OPENCHAMBER_SKIP_LOCAL_SERVER === '1';
|
||||
};
|
||||
|
||||
const spawnLocalServer = async () => {
|
||||
const serverStartedAt = performance.now();
|
||||
recordElectronStartupPerformance('electron.server.start');
|
||||
inheritUserShellEnv();
|
||||
|
||||
const settings = readSettingsRoot();
|
||||
@@ -1398,6 +1491,9 @@ const spawnLocalServer = async () => {
|
||||
|
||||
state.serverHandle = handle;
|
||||
state.sidecarUrl = url;
|
||||
recordElectronStartupPerformance('electron.server.ready', {
|
||||
durationMs: performance.now() - serverStartedAt,
|
||||
});
|
||||
|
||||
await mutateSettingsRoot((root) => {
|
||||
root.desktopLocalPort = port;
|
||||
@@ -1526,7 +1622,18 @@ const buildInitScript = (localOrigin, bootOutcome, apiBaseUrl = '', clientToken
|
||||
].join('');
|
||||
};
|
||||
|
||||
// Keep the main window aligned with global host configuration without overwriting
|
||||
// the runtime-specific bootstrap retained by additional and Mini Chat windows.
|
||||
const syncMainWindowInitScript = (initScript = state.initScript) => {
|
||||
if (!initScript) return;
|
||||
const mainWindow = state.mainWindow;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
mainWindow.__ocInitScript = initScript;
|
||||
}
|
||||
};
|
||||
|
||||
const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) => {
|
||||
const availability = { localAvailable };
|
||||
if (envTargetUrl) {
|
||||
const status = probe?.status === 'unreachable'
|
||||
? 'unreachable'
|
||||
@@ -1535,23 +1642,23 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) =>
|
||||
: probe?.status === 'wrong-service'
|
||||
? 'wrong-service'
|
||||
: 'ok';
|
||||
return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl };
|
||||
return { target: 'remote', status, hostId: ENV_OVERRIDE_HOST_ID, url: envTargetUrl, ...availability };
|
||||
}
|
||||
|
||||
const defaultId = config.defaultHostId || '';
|
||||
if (!defaultId) {
|
||||
return { target: null, status: 'not-configured' };
|
||||
return { target: null, status: 'not-configured', ...availability };
|
||||
}
|
||||
|
||||
if (defaultId === LOCAL_HOST_ID) {
|
||||
return localAvailable
|
||||
? { target: 'local', status: 'ok' }
|
||||
: { target: 'local', status: 'unreachable' };
|
||||
? { target: 'local', status: 'ok', ...availability }
|
||||
: { target: 'local', status: 'unreachable', ...availability };
|
||||
}
|
||||
|
||||
const host = config.hosts.find((entry) => entry.id === defaultId);
|
||||
if (!host) {
|
||||
return { target: 'remote', status: 'missing', hostId: defaultId };
|
||||
return { target: 'remote', status: 'missing', hostId: defaultId, ...availability };
|
||||
}
|
||||
|
||||
const status = probe?.status === 'unreachable'
|
||||
@@ -1561,7 +1668,7 @@ const computeBootOutcome = ({ envTargetUrl, probe, config, localAvailable }) =>
|
||||
: probe?.status === 'wrong-service'
|
||||
? 'wrong-service'
|
||||
: 'ok';
|
||||
return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url };
|
||||
return { target: 'remote', status, hostId: host.id, url: host.apiUrl || host.url, ...availability };
|
||||
};
|
||||
|
||||
const buildStartupSplashHtml = () => {
|
||||
@@ -1676,8 +1783,19 @@ const isBenignNavigationAbort = (error) => {
|
||||
};
|
||||
|
||||
const navigateWindow = async (browserWindow, url, { allowAbort = false } = {}) => {
|
||||
const navigationStartedAt = performance.now();
|
||||
const documentClass = classifyStartupDocument(url);
|
||||
if (browserWindow.__ocLabel === 'main') {
|
||||
recordElectronStartupPerformance('electron.navigation.start', { documentClass });
|
||||
}
|
||||
try {
|
||||
await browserWindow.loadURL(url);
|
||||
if (browserWindow.__ocLabel === 'main') {
|
||||
recordElectronStartupPerformance('electron.navigation.ready', {
|
||||
documentClass,
|
||||
durationMs: performance.now() - navigationStartedAt,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
if (allowAbort && isBenignNavigationAbort(error)) {
|
||||
return;
|
||||
@@ -2214,6 +2332,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
const usesCustomTitleBar = process.platform === 'darwin' || usesFramelessChrome;
|
||||
// macOS vibrancy, on by default; users can disable it (Appearance settings).
|
||||
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
|
||||
const trayEnabled = process.platform !== 'darwin' || readSettingsRoot().desktopMacMenuBarEnabled !== false;
|
||||
const titleBarOverlayEnabled = false;
|
||||
const autoHidesNativeMenuBar = process.platform !== 'darwin';
|
||||
const windowIconPath = getWindowIconPath();
|
||||
@@ -2249,6 +2368,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
`--openchamber-home=${desktopHome}`,
|
||||
`--openchamber-macos-major=${desktopMacosMajor}`,
|
||||
`--openchamber-mac-vibrancy=${useVibrancy ? '1' : '0'}`,
|
||||
`--openchamber-tray-enabled=${trayEnabled ? '1' : '0'}`,
|
||||
`--openchamber-boot-outcome=${JSON.stringify(state.bootOutcome || null)}`,
|
||||
`--openchamber-relay-host-id=${rendererRuntimeConfig.relayHostId || ''}`,
|
||||
],
|
||||
@@ -2383,7 +2503,15 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
try {
|
||||
const url = new URL(raw);
|
||||
if (url.protocol === 'devtools:') return true;
|
||||
if (url.protocol === `${UI_PROTOCOL}:`) return true;
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
// In development the renderer is served by Vite while state.localOrigin
|
||||
// remains the separate local API server. Permit same-origin reloads from
|
||||
// the renderer itself so Vite full-reload fallbacks stay in Electron.
|
||||
try {
|
||||
if (new URL(browserWindow.webContents.getURL()).origin === url.origin) return true;
|
||||
} catch {
|
||||
}
|
||||
if (state.localOrigin) {
|
||||
try {
|
||||
if (new URL(state.localOrigin).origin === url.origin) return true;
|
||||
@@ -2430,13 +2558,23 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
});
|
||||
|
||||
browserWindow.webContents.on('dom-ready', () => {
|
||||
const initScript = browserWindow.__ocInitScript || state.initScript;
|
||||
if (browserWindow.__ocLabel === 'main') {
|
||||
recordElectronStartupPerformance('electron.renderer.dom-ready', {
|
||||
documentClass: classifyStartupDocument(browserWindow.webContents.getURL()),
|
||||
});
|
||||
}
|
||||
const initScript = browserWindow.__ocInitScript;
|
||||
if (initScript) {
|
||||
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
|
||||
}
|
||||
});
|
||||
|
||||
browserWindow.webContents.on('did-finish-load', () => {
|
||||
if (browserWindow.__ocLabel === 'main') {
|
||||
recordElectronStartupPerformance('electron.renderer.loaded', {
|
||||
documentClass: classifyStartupDocument(browserWindow.webContents.getURL()),
|
||||
});
|
||||
}
|
||||
browserWindow.webContents.setZoomFactor(1);
|
||||
if (state.mainWindow && browserWindow.id === state.mainWindow.id && pendingDeepLinks.length > 0) {
|
||||
const timer = setTimeout(flushPendingDeepLinks, 400);
|
||||
@@ -2445,6 +2583,11 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
});
|
||||
|
||||
browserWindow.once('ready-to-show', () => {
|
||||
if (browserWindow.__ocLabel === 'main') {
|
||||
recordElectronStartupPerformance('electron.window.ready-to-show', {
|
||||
documentClass: classifyStartupDocument(browserWindow.webContents.getURL()),
|
||||
});
|
||||
}
|
||||
browserWindow.show();
|
||||
browserWindow.focus();
|
||||
if (useVibrancy) applyMacVibrancy(browserWindow);
|
||||
@@ -2464,6 +2607,7 @@ const createBrowserWindow = ({ label, restoreGeometry, url, runtimeConfig = {} }
|
||||
};
|
||||
|
||||
const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig = {}) => {
|
||||
state.startupResolved = true;
|
||||
state.localOrigin = localOrigin;
|
||||
state.apiBaseUrl = typeof runtimeConfig.apiBaseUrl === 'string' ? runtimeConfig.apiBaseUrl : state.apiBaseUrl;
|
||||
state.clientToken = typeof runtimeConfig.clientToken === 'string' ? runtimeConfig.clientToken : '';
|
||||
@@ -2481,6 +2625,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig =
|
||||
rendererRuntimeConfig.clientToken,
|
||||
rendererRuntimeConfig.requestHeaders,
|
||||
);
|
||||
syncMainWindowInitScript(state.initScript);
|
||||
|
||||
const mainWindow = state.mainWindow;
|
||||
if (mainWindow && !mainWindow.isDestroyed()) {
|
||||
@@ -2502,7 +2647,7 @@ const activateMainWindow = async (url, localOrigin, bootOutcome, runtimeConfig =
|
||||
};
|
||||
|
||||
const openMainWindow = async () => {
|
||||
if (!state.localOrigin) {
|
||||
if (!state.startupResolved) {
|
||||
const { initialUrl, localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
|
||||
return activateMainWindow(initialUrl, localOrigin, bootOutcome, { apiBaseUrl, clientToken, requestHeaders });
|
||||
}
|
||||
@@ -2536,7 +2681,7 @@ const openMainWindow = async () => {
|
||||
};
|
||||
|
||||
const createAdditionalWindow = async (url, runtimeConfig = {}) => {
|
||||
if (!state.localOrigin) {
|
||||
if (!state.startupResolved || !url) {
|
||||
return null;
|
||||
}
|
||||
const browserWindow = createBrowserWindow({
|
||||
@@ -2549,12 +2694,14 @@ const createAdditionalWindow = async (url, runtimeConfig = {}) => {
|
||||
};
|
||||
|
||||
const buildMiniChatUrl = ({ mode, sessionId, directory, projectId }) => {
|
||||
const base = state.localOrigin || state.sidecarUrl;
|
||||
const base = shouldUsePackagedUi()
|
||||
? buildPackagedUiUrl('/mini-chat.html')
|
||||
: state.localOrigin || state.sidecarUrl;
|
||||
if (!base) {
|
||||
throw new Error('Local UI is not available');
|
||||
}
|
||||
|
||||
const url = new URL(shouldUsePackagedUi() ? buildPackagedUiUrl('/mini-chat.html') : '/mini-chat.html', base);
|
||||
const url = new URL(shouldUsePackagedUi() ? base : '/mini-chat.html', base);
|
||||
url.searchParams.set('mode', mode === 'session' ? 'session' : 'draft');
|
||||
if (sessionId) url.searchParams.set('sessionId', sessionId);
|
||||
if (directory) url.searchParams.set('directory', directory);
|
||||
@@ -2609,6 +2756,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
const usesFramelessChrome = process.platform === 'win32' || process.platform === 'linux';
|
||||
// macOS vibrancy, on by default; users can disable it (Appearance settings).
|
||||
const useVibrancy = process.platform === 'darwin' && readSettingsRoot().desktopVibrancy !== false;
|
||||
const trayEnabled = process.platform !== 'darwin' || readSettingsRoot().desktopMacMenuBarEnabled !== false;
|
||||
const browserWindow = new BrowserWindow({
|
||||
title: 'OpenChamber Mini Chat',
|
||||
width: MINI_CHAT_WINDOW_WIDTH,
|
||||
@@ -2634,6 +2782,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
`--openchamber-runtime-headers=${JSON.stringify(desktopRequestHeaders)}`,
|
||||
`--openchamber-home=${desktopHome}`,
|
||||
`--openchamber-macos-major=${desktopMacosMajor}`,
|
||||
`--openchamber-tray-enabled=${trayEnabled ? '1' : '0'}`,
|
||||
],
|
||||
preload: isDev ? path.join(__dirname, 'preload.mjs') : path.join(app.getAppPath(), 'preload.mjs'),
|
||||
backgroundThrottling: false,
|
||||
@@ -2700,7 +2849,7 @@ const createMiniChatWindow = async ({ mode, sessionId = '', directory = '', proj
|
||||
void shell.openExternal(url).catch(() => {});
|
||||
});
|
||||
browserWindow.webContents.on('dom-ready', () => {
|
||||
const initScript = browserWindow.__ocInitScript || state.initScript;
|
||||
const initScript = browserWindow.__ocInitScript;
|
||||
if (initScript) {
|
||||
void browserWindow.webContents.executeJavaScript(initScript).catch(() => {});
|
||||
}
|
||||
@@ -2750,23 +2899,32 @@ const resolveInitialUrl = async () => {
|
||||
const hmrUiPort = process.env.OPENCHAMBER_HMR_UI_PORT || '5173';
|
||||
const hmrApiUrl = `http://127.0.0.1:${hmrApiPort}`;
|
||||
const hmrUiUrl = `http://127.0.0.1:${hmrUiPort}`;
|
||||
const localUrl = isDev && await waitForHealth(hmrApiUrl, 5_000, 100)
|
||||
? hmrApiUrl
|
||||
: await spawnLocalServer();
|
||||
const usePackagedUi = shouldUsePackagedUi();
|
||||
const skipLocalServer = shouldSkipLocalServer();
|
||||
const startupProbePlan = resolveStartupUrlProbePlan({
|
||||
development: isDev,
|
||||
packagedUi: usePackagedUi,
|
||||
skipLocalServer,
|
||||
});
|
||||
const localUrl = skipLocalServer
|
||||
? null
|
||||
: startupProbePlan.probeHmrApi && await waitForHealth(hmrApiUrl, 5_000, 100)
|
||||
? hmrApiUrl
|
||||
: await spawnLocalServer();
|
||||
|
||||
const localUiUrl = shouldUsePackagedUi()
|
||||
const localUiUrl = usePackagedUi
|
||||
? buildPackagedUiUrl('/index.html')
|
||||
: isDev && await waitForHealth(hmrUiUrl, 8_000, 100)
|
||||
: startupProbePlan.probeHmrUi && await waitForHealth(hmrUiUrl, 8_000, 100)
|
||||
? hmrUiUrl
|
||||
: localUrl;
|
||||
|
||||
state.sidecarUrl = localUrl;
|
||||
const localAvailable = Boolean(localUrl);
|
||||
|
||||
const localOrigin = new URL(localUrl).origin;
|
||||
const localOrigin = localUrl ? new URL(localUrl).origin : null;
|
||||
let initialUrl = localUiUrl;
|
||||
let apiBaseUrl = localUrl;
|
||||
let clientToken = readDesktopLocalClientToken();
|
||||
let apiBaseUrl = localUrl || '';
|
||||
let clientToken = localUrl ? readDesktopLocalClientToken() : '';
|
||||
let requestHeaders = {};
|
||||
let remoteProbe = null;
|
||||
|
||||
@@ -2776,14 +2934,14 @@ const resolveInitialUrl = async () => {
|
||||
apiBaseUrl = envTarget;
|
||||
clientToken = '';
|
||||
requestHeaders = {};
|
||||
initialUrl = shouldUsePackagedUi() ? localUiUrl : envTarget;
|
||||
initialUrl = usePackagedUi ? localUiUrl : envTarget;
|
||||
} else if (config.defaultHostId && config.defaultHostId !== LOCAL_HOST_ID) {
|
||||
const host = config.hosts.find((entry) => entry.id === config.defaultHostId);
|
||||
if (host?.url) {
|
||||
apiBaseUrl = host.apiUrl || host.url;
|
||||
clientToken = host.clientToken || '';
|
||||
requestHeaders = sanitizeRuntimeRequestHeaders(host.requestHeaders || {});
|
||||
initialUrl = shouldUsePackagedUi() ? localUiUrl : host.url;
|
||||
initialUrl = usePackagedUi ? localUiUrl : host.url;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2794,13 +2952,22 @@ const resolveInitialUrl = async () => {
|
||||
}
|
||||
if (remoteProbe.status === 'unreachable') {
|
||||
state.unreachableHosts.add(apiBaseUrl);
|
||||
apiBaseUrl = localUrl;
|
||||
clientToken = readDesktopLocalClientToken();
|
||||
apiBaseUrl = localUrl || '';
|
||||
clientToken = localUrl ? readDesktopLocalClientToken() : '';
|
||||
requestHeaders = {};
|
||||
initialUrl = localUiUrl;
|
||||
}
|
||||
}
|
||||
|
||||
if (!initialUrl && apiBaseUrl && remoteProbe?.status !== 'unreachable') {
|
||||
initialUrl = apiBaseUrl;
|
||||
}
|
||||
if (!initialUrl) {
|
||||
throw new Error(
|
||||
'OPENCHAMBER_SKIP_LOCAL_SERVER=1 requires bundled UI, a running desktop HMR UI, or a reachable remote instance.',
|
||||
);
|
||||
}
|
||||
|
||||
const bootOutcome = computeBootOutcome({
|
||||
envTargetUrl: envTarget || null,
|
||||
probe: remoteProbe,
|
||||
@@ -2836,10 +3003,17 @@ const setupAutoUpdater = () => {
|
||||
const testBuild = typeof __OPENCHAMBER_UPDATER_E2E_BUILD__ !== 'undefined'
|
||||
&& __OPENCHAMBER_UPDATER_E2E_BUILD__ === true;
|
||||
const feed = resolveUpdaterFeed({ testBuild });
|
||||
const updaterChannel = feed.provider === 'github'
|
||||
? resolveUpdaterChannel({ platform: process.platform, architecture: process.arch })
|
||||
: null;
|
||||
if (updaterChannel) {
|
||||
autoUpdater.channel = updaterChannel;
|
||||
}
|
||||
autoUpdater.setFeedURL(feed);
|
||||
log.info('[electron] updater feed configured', {
|
||||
provider: feed.provider,
|
||||
target: feed.provider === 'github' ? `${feed.owner}/${feed.repo}` : feed.url,
|
||||
channel: updaterChannel || 'latest',
|
||||
});
|
||||
|
||||
autoUpdater.on('download-progress', (progress) => {
|
||||
@@ -2973,6 +3147,74 @@ const buildInstalledApps = async (apps) => {
|
||||
return results;
|
||||
};
|
||||
|
||||
let linuxDesktopEntriesCache = { expiresAt: 0, entries: null };
|
||||
|
||||
const getLinuxDesktopEntries = async () => {
|
||||
const now = Date.now();
|
||||
if (linuxDesktopEntriesCache.entries && linuxDesktopEntriesCache.expiresAt > now) {
|
||||
return linuxDesktopEntriesCache.entries;
|
||||
}
|
||||
const entries = await readLinuxDesktopEntries();
|
||||
linuxDesktopEntriesCache = { entries, expiresAt: now + LINUX_DESKTOP_ENTRIES_CACHE_TTL_MS };
|
||||
return entries;
|
||||
};
|
||||
|
||||
const buildPlatformInstalledApps = async (apps) => {
|
||||
if (process.platform === 'linux') {
|
||||
return buildLinuxInstalledApps(apps);
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
return buildWindowsInstalledApps(apps);
|
||||
}
|
||||
return buildInstalledApps(apps);
|
||||
};
|
||||
|
||||
const spawnDetachedLinux = (program, args) => new Promise((resolve, reject) => {
|
||||
const child = spawn(program, args, {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
});
|
||||
let settled = false;
|
||||
const finish = (callback, value) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
callback(value);
|
||||
};
|
||||
child.once('error', (error) => finish(reject, error));
|
||||
child.once('spawn', () => {
|
||||
child.unref();
|
||||
finish(resolve);
|
||||
});
|
||||
});
|
||||
|
||||
const runLinuxSpecChain = async (specs, appName) => {
|
||||
if (!Array.isArray(specs) || specs.length === 0) {
|
||||
throw new Error(`Failed to open in ${appName}: no launch candidates`);
|
||||
}
|
||||
|
||||
const failures = [];
|
||||
for (const spec of specs) {
|
||||
if (spec.kind === 'default') {
|
||||
if (spec.targetKind === 'file') {
|
||||
shell.showItemInFolder(spec.targetPath);
|
||||
return;
|
||||
}
|
||||
const errorMessage = await shell.openPath(spec.targetPath);
|
||||
if (!errorMessage) return;
|
||||
failures.push(`default opener: ${errorMessage}`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
await spawnDetachedLinux(spec.program, spec.args);
|
||||
return;
|
||||
} catch (error) {
|
||||
failures.push(`${spec.program}: ${error instanceof Error ? error.message : String(error)}`);
|
||||
}
|
||||
}
|
||||
throw new Error(`Failed to open in ${appName}: ${failures.join('; ')}`);
|
||||
};
|
||||
|
||||
const parseSshConfigImports = () => {
|
||||
const sshConfigPath = path.join(os.homedir(), '.ssh', 'config');
|
||||
if (!fs.existsSync(sshConfigPath)) return [];
|
||||
@@ -3454,12 +3696,23 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
return APP_VERSION;
|
||||
|
||||
case 'desktop_get_launch_at_login': {
|
||||
if (process.platform === 'linux') {
|
||||
return { supported: true, enabled: await readLinuxAutostartEnabled() };
|
||||
}
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
|
||||
const settings = app.getLoginItemSettings(getLoginItemOptions());
|
||||
return { supported: true, enabled: settings.openAtLogin === true };
|
||||
}
|
||||
|
||||
case 'desktop_set_launch_at_login': {
|
||||
if (process.platform === 'linux') {
|
||||
const enabled = args.enabled === true;
|
||||
return setLinuxAutostartEnabled({
|
||||
enabled,
|
||||
appName: app.getName(),
|
||||
backgroundArg: BACKGROUND_START_ARG,
|
||||
});
|
||||
}
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') return { supported: false, enabled: false };
|
||||
const enabled = args.enabled === true;
|
||||
const settingsArgs = {
|
||||
@@ -3478,7 +3731,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
|
||||
case 'desktop_set_minimize_to_tray': {
|
||||
if (process.platform !== 'win32') return { supported: false, enabled: false };
|
||||
if (process.platform !== 'win32' && process.platform !== 'linux') return { supported: false, enabled: false };
|
||||
const enabled = args.enabled === true;
|
||||
await mutateSettingsRoot((root) => {
|
||||
root.desktopMinimizeToTrayEnabled = enabled;
|
||||
@@ -3656,13 +3909,19 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
case 'desktop_open_path': {
|
||||
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
|
||||
const appName = typeof args.app === 'string' ? args.app.trim() : '';
|
||||
if (!targetPath) throw new Error('Path is required');
|
||||
const validated = await validateLocalPath(targetPath);
|
||||
if (process.platform === 'darwin') {
|
||||
const openArgs = appName ? ['-a', appName, targetPath] : [targetPath];
|
||||
const openArgs = appName ? ['-a', appName, validated.path] : [validated.path];
|
||||
spawn('open', openArgs, { detached: true, stdio: 'ignore' }).unref();
|
||||
return null;
|
||||
}
|
||||
await shell.openPath(targetPath);
|
||||
if (appName && process.platform !== 'linux' && process.platform !== 'win32') {
|
||||
throw new Error(unsupportedAppSpecificOpenError('paths'));
|
||||
}
|
||||
const errorMessage = await shell.openPath(validated.path);
|
||||
if (errorMessage) {
|
||||
throw new Error(`Failed to open path: ${errorMessage}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3680,18 +3939,16 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
|
||||
case 'desktop_reveal_path': {
|
||||
const targetPath = typeof args.path === 'string' ? args.path.trim() : '';
|
||||
if (!targetPath) {
|
||||
throw new Error('Path is required');
|
||||
}
|
||||
|
||||
const stats = await fsp.stat(targetPath).catch(() => null);
|
||||
if (stats?.isDirectory()) {
|
||||
await shell.openPath(targetPath);
|
||||
const validated = await validateLocalPath(typeof args.path === 'string' ? args.path.trim() : '');
|
||||
if (validated.stats.isDirectory()) {
|
||||
const errorMessage = await shell.openPath(validated.path);
|
||||
if (errorMessage) {
|
||||
throw new Error(`Failed to reveal path: ${errorMessage}`);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
shell.showItemInFolder(targetPath);
|
||||
shell.showItemInFolder(validated.path);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3702,19 +3959,31 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
if (!projectPath || !appId || !appName) {
|
||||
throw new Error('Project path, app id, and app name are required');
|
||||
}
|
||||
const validated = await validateLocalPath(projectPath, 'Project path');
|
||||
if (process.platform === 'win32') {
|
||||
if (appId === 'finder') {
|
||||
const error = await shell.openPath(projectPath);
|
||||
const error = await shell.openPath(validated.path);
|
||||
if (error) throw new Error(error);
|
||||
return null;
|
||||
}
|
||||
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath, appId, appName }), appName);
|
||||
runSpecChain(buildWindowsOpenProjectSpecs({ projectPath: validated.path, appId, appName }), appName);
|
||||
return null;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const entries = await getLinuxDesktopEntries();
|
||||
await runLinuxSpecChain(buildLinuxOpenSpecs({
|
||||
targetPath: validated.path,
|
||||
appId,
|
||||
appName,
|
||||
targetKind: 'project',
|
||||
entries,
|
||||
}), appName);
|
||||
return null;
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('desktop_open_in_app is only supported on macOS and Windows');
|
||||
throw new Error(unsupportedAppSpecificOpenError('projects'));
|
||||
}
|
||||
runSpecChain(buildOpenProjectSpecs({ projectPath, appId, appName }), appName);
|
||||
runSpecChain(buildOpenProjectSpecs({ projectPath: validated.path, appId, appName }), appName);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3725,14 +3994,26 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
if (!filePath || !appId || !appName) {
|
||||
throw new Error('File path, app id, and app name are required');
|
||||
}
|
||||
const validated = await validateLocalPath(filePath, 'File path');
|
||||
if (process.platform === 'win32') {
|
||||
runSpecChain(buildWindowsOpenFileSpecs({ filePath, appId, appName }), appName);
|
||||
runSpecChain(buildWindowsOpenFileSpecs({ filePath: validated.path, appId, appName }), appName);
|
||||
return null;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
const entries = await getLinuxDesktopEntries();
|
||||
await runLinuxSpecChain(buildLinuxOpenSpecs({
|
||||
targetPath: validated.path,
|
||||
appId,
|
||||
appName,
|
||||
targetKind: 'file',
|
||||
entries,
|
||||
}), appName);
|
||||
return null;
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('desktop_open_file_in_app is only supported on macOS and Windows');
|
||||
throw new Error(unsupportedAppSpecificOpenError('files'));
|
||||
}
|
||||
runSpecChain(buildOpenFileSpecs({ filePath, appId, appName }), appName);
|
||||
runSpecChain(buildOpenFileSpecs({ filePath: validated.path, appId, appName }), appName);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -3740,8 +4021,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
if (process.platform === 'win32') {
|
||||
return (await buildWindowsInstalledApps(args.apps)).map((app) => app.name);
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return filterLinuxInstalledApps(args.apps);
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('desktop_filter_installed_apps is only supported on macOS');
|
||||
throw new Error('desktop_filter_installed_apps is only supported on macOS, Windows, and Linux');
|
||||
}
|
||||
if (!Array.isArray(args.apps)) return [];
|
||||
const results = await Promise.all(
|
||||
@@ -3765,8 +4049,11 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
}
|
||||
return results;
|
||||
}
|
||||
if (process.platform === 'linux') {
|
||||
return fetchLinuxAppIcons(Array.isArray(args.apps) ? args.apps : []);
|
||||
}
|
||||
if (process.platform !== 'darwin') {
|
||||
throw new Error('desktop_fetch_app_icons is only supported on macOS');
|
||||
throw new Error('desktop_fetch_app_icons is only supported on macOS, Windows, and Linux');
|
||||
}
|
||||
const names = Array.isArray(args.apps) ? args.apps : [];
|
||||
const results = [];
|
||||
@@ -3791,14 +4078,12 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
const hasCache = Boolean(cache);
|
||||
const isCacheStale = !cache || (now - Number(cache.updatedAt || 0)) > INSTALLED_APPS_CACHE_TTL_SECS;
|
||||
const refresh = async () => {
|
||||
const apps = process.platform === 'win32'
|
||||
? await buildWindowsInstalledApps(args.apps)
|
||||
: await buildInstalledApps(Array.isArray(args.apps) ? args.apps : []);
|
||||
const apps = await buildPlatformInstalledApps(Array.isArray(args.apps) ? args.apps : []);
|
||||
await fsp.mkdir(path.dirname(cachePath), { recursive: true });
|
||||
await fsp.writeFile(cachePath, JSON.stringify({ updatedAt: now, apps }, null, 2));
|
||||
emitToAllWindows('openchamber:installed-apps-updated', apps);
|
||||
};
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32') {
|
||||
if (process.platform !== 'darwin' && process.platform !== 'win32' && process.platform !== 'linux') {
|
||||
return { apps: [], hasCache: false, isCacheStale: false, supported: false };
|
||||
}
|
||||
if (!hasCache || isCacheStale || args.force === true) {
|
||||
@@ -3828,7 +4113,7 @@ const handleInvoke = async (browserWindow, command, args = {}) => {
|
||||
localAvailable: Boolean(state.sidecarUrl || state.localOrigin),
|
||||
});
|
||||
state.initScript = buildInitScript(state.localOrigin, state.bootOutcome, state.apiBaseUrl, state.clientToken, state.requestHeaders || {});
|
||||
log.info('[electron] hosts config updated, recomputed bootOutcome', state.bootOutcome);
|
||||
syncMainWindowInitScript(state.initScript);
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -4486,6 +4771,9 @@ const isLocalSender = (webContents) => {
|
||||
if (!raw) return false;
|
||||
const url = new URL(raw);
|
||||
if (url.protocol === `${UI_PROTOCOL}:` && url.hostname === 'app') return true;
|
||||
// Electron dev renders from Vite while the local API is served on a
|
||||
// separate port. This exact loopback HMR origin is trusted only in dev.
|
||||
if (isDev && url.origin === `http://127.0.0.1:${process.env.OPENCHAMBER_HMR_UI_PORT || '5173'}`) return true;
|
||||
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
||||
if (state.localOrigin) {
|
||||
try {
|
||||
@@ -4605,23 +4893,14 @@ ipcMain.handle('openchamber:file:grant-existing', async (event, filePath) => {
|
||||
});
|
||||
|
||||
// --- Native tray / menu bar ---------------------------------------------------
|
||||
// Tray lives on macOS and Windows; the renderer streams a compact state snapshot via
|
||||
// the `desktop_tray_update` IPC command (see the command switch). Tray clicks
|
||||
// flow back through dispatchTrayAction → renderer (focus/respond) or native
|
||||
// handlers (show window / quit).
|
||||
// Tray lives on macOS, Windows, and Linux. The renderer streams a compact state
|
||||
// snapshot via the `desktop_tray_update` IPC command (see the command switch).
|
||||
// Tray clicks flow back through dispatchTrayAction → renderer (focus/respond) or
|
||||
// native handlers (show / hide / toggle / quit).
|
||||
|
||||
// Icon assets: a calm outline (idle), a statically filled cube (a finished
|
||||
// session left unread), and an eased sequence the busy state breathes through.
|
||||
const TRAY_BREATH_FRAME_COUNT = 16;
|
||||
// Track the most recently focused window (main or mini-chat) so tray actions
|
||||
// can target the surface the user was last using, even when the tray menu is
|
||||
// open and nothing is focused right now.
|
||||
app.on('browser-window-focus', (_event, browserWindow) => {
|
||||
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||
state.lastFocusedWindowId = browserWindow.id;
|
||||
}
|
||||
});
|
||||
|
||||
// The window the user is "on" for tray routing: the focused one, else the last
|
||||
// focused that is still alive.
|
||||
const resolveTraySurface = () => {
|
||||
@@ -4637,8 +4916,10 @@ const resolveTraySurface = () => {
|
||||
const trayIconAssets = () => {
|
||||
const dir = path.join(resourceRoot(), 'icons', 'tray');
|
||||
const statusDir = path.join(dir, 'status');
|
||||
if (process.platform === 'win32') {
|
||||
const iconPath = getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico');
|
||||
if (process.platform === 'win32' || process.platform === 'linux') {
|
||||
const iconPath = process.platform === 'linux'
|
||||
? (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.png'))
|
||||
: (getWindowIconPath() || path.join(resourceRoot(), 'icons', 'icon.ico'));
|
||||
return {
|
||||
idleIconPath: iconPath,
|
||||
unseenIconPath: iconPath,
|
||||
@@ -4670,7 +4951,8 @@ const trayIconAssets = () => {
|
||||
};
|
||||
|
||||
const setupTray = () => {
|
||||
if (!['darwin', 'win32'].includes(process.platform) || state.trayController) return;
|
||||
if (!['darwin', 'win32', 'linux'].includes(process.platform) || state.trayController) return;
|
||||
if (process.platform === 'darwin' && readSettingsRoot().desktopMacMenuBarEnabled === false) return;
|
||||
const assets = trayIconAssets();
|
||||
if (!fs.existsSync(assets.idleIconPath)) {
|
||||
log.warn('[electron] tray icon missing, skipping tray setup', { iconPath: assets.idleIconPath });
|
||||
@@ -4684,6 +4966,14 @@ const setupTray = () => {
|
||||
// Seed an empty snapshot so the icon appears immediately; the renderer
|
||||
// pushes the real state once the sync stores are mounted.
|
||||
state.trayController.update({ sessions: [], approvals: [] });
|
||||
if (!state.trayFocusListener) {
|
||||
state.trayFocusListener = (_event, browserWindow) => {
|
||||
if (browserWindow && !browserWindow.isDestroyed()) {
|
||||
state.lastFocusedWindowId = browserWindow.id;
|
||||
}
|
||||
};
|
||||
app.on('browser-window-focus', state.trayFocusListener);
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('[electron] failed to set up tray', error);
|
||||
state.trayController = null;
|
||||
@@ -4734,6 +5024,30 @@ const dispatchTrayAction = async (action) => {
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'hide-main-window') {
|
||||
const target = (state.mainWindow && !state.mainWindow.isDestroyed())
|
||||
? state.mainWindow
|
||||
: BrowserWindow.getFocusedWindow();
|
||||
if (target && !target.isDestroyed() && target.isVisible()) {
|
||||
debounceWindowStatePersist(target, true);
|
||||
target.hide();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if (action.type === 'toggle-main-window') {
|
||||
const target = (state.mainWindow && !state.mainWindow.isDestroyed())
|
||||
? state.mainWindow
|
||||
: null;
|
||||
if (target && target.isVisible() && !target.isMinimized()) {
|
||||
debounceWindowStatePersist(target, true);
|
||||
target.hide();
|
||||
return;
|
||||
}
|
||||
await revealMainWindow();
|
||||
return;
|
||||
}
|
||||
|
||||
// Responding to a permission doesn't need to steal focus — just deliver it.
|
||||
if (action.type === 'respond-permission') {
|
||||
const target = (state.mainWindow && !state.mainWindow.isDestroyed())
|
||||
@@ -4855,6 +5169,7 @@ app.on('activate', async () => {
|
||||
});
|
||||
|
||||
app.whenReady().then(async () => {
|
||||
recordElectronStartupPerformance('electron.app.ready');
|
||||
const loginItemSettings = readLoginItemSettings();
|
||||
const isBackgroundStart = shouldStartInBackground(loginItemSettings);
|
||||
log.info('[electron] app starting', {
|
||||
@@ -4886,12 +5201,32 @@ app.whenReady().then(async () => {
|
||||
});
|
||||
}
|
||||
|
||||
if (process.platform === 'linux' && app.isPackaged) {
|
||||
try {
|
||||
const enabled = await readLinuxAutostartEnabled();
|
||||
if (enabled) {
|
||||
await setLinuxAutostartEnabled({
|
||||
enabled: true,
|
||||
appName: app.getName(),
|
||||
backgroundArg: BACKGROUND_START_ARG,
|
||||
});
|
||||
}
|
||||
} catch (error) {
|
||||
log.warn('[electron] failed to reconcile Linux autostart entry', error);
|
||||
}
|
||||
}
|
||||
|
||||
if (isBackgroundStart) {
|
||||
const { localOrigin, bootOutcome, requestHeaders } = await resolveInitialUrl();
|
||||
const { localOrigin, bootOutcome, apiBaseUrl, clientToken, requestHeaders } = await resolveInitialUrl();
|
||||
state.localOrigin = localOrigin;
|
||||
state.apiBaseUrl = apiBaseUrl;
|
||||
state.clientToken = clientToken;
|
||||
state.bootOutcome = bootOutcome ?? null;
|
||||
state.requestHeaders = sanitizeRuntimeRequestHeaders(requestHeaders || {});
|
||||
state.initScript = buildInitScript(localOrigin, state.bootOutcome, '', '', state.requestHeaders);
|
||||
// Serverless background startup re-probes the remote when a window is
|
||||
// eventually opened instead of trusting reachability from login time.
|
||||
state.startupResolved = !shouldSkipLocalServer();
|
||||
state.initScript = buildInitScript(localOrigin, state.bootOutcome, apiBaseUrl, clientToken, state.requestHeaders);
|
||||
log.info('[electron] started in background without window');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/electron",
|
||||
"version": "1.16.1",
|
||||
"version": "1.17.2",
|
||||
"private": true,
|
||||
"description": "Electron desktop runtime for OpenChamber",
|
||||
"author": "OpenChamber",
|
||||
@@ -8,6 +8,7 @@
|
||||
"main": "./dist-bundle/main.mjs",
|
||||
"dependencies": {
|
||||
"@openchamber/web": "workspace:*",
|
||||
"better-sqlite3": "^12.10.0",
|
||||
"electron-context-menu": "^4.1.2",
|
||||
"electron-log": "^5.4.3",
|
||||
"electron-updater": "^6.8.3"
|
||||
@@ -17,6 +18,9 @@
|
||||
"electron": "^41.2.1",
|
||||
"electron-builder": "^26.0.0"
|
||||
},
|
||||
"trustedDependencies": [
|
||||
"electron"
|
||||
],
|
||||
"desktopPrerequisites": [
|
||||
"Electron runtime dependencies installed via bun install",
|
||||
"Bun available for sidecar compilation",
|
||||
@@ -35,8 +39,9 @@
|
||||
"bundle:main": "bun ./scripts/bundle-main.mjs",
|
||||
"generate:macos-icon": "node ./scripts/generate-macos-icon-assets.cjs",
|
||||
"rebuild:native": "node ./scripts/rebuild-native.mjs",
|
||||
"test:architecture": "node --test ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs",
|
||||
"test:updater": "node --test ./updater-capability.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
|
||||
"test:architecture": "node --test ./startup-url-selection.test.mjs ./scripts/target-architecture.test.mjs ./scripts/verify-linux-appimage.test.mjs ./scripts/verify-update-manifest.test.mjs",
|
||||
"test:updater": "node --test ./updater-capability.test.mjs ./updater-channel.test.mjs ./updater-check.test.mjs ./updater-feed.test.mjs ./scripts/finalize-latest-yml.test.mjs ./scripts/updater-e2e-fixture.test.mjs",
|
||||
"test:linux-desktop": "node --test ./linux-autostart.test.mjs && node ./scripts/smoke-linux-app-discovery.mjs && node ./scripts/smoke-path-open-utils.mjs",
|
||||
"updater:e2e:fixture": "node ./scripts/updater-e2e-fixture.mjs",
|
||||
"verify:update-manifest": "node ./scripts/verify-update-manifest.mjs",
|
||||
"package": "bun run build:web-assets && bun run prepare:opencode-cli && bun run bundle:main && bun run rebuild:native && node ./scripts/package.mjs",
|
||||
|
||||
@@ -0,0 +1,51 @@
|
||||
import fs from 'node:fs';
|
||||
import fsp from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
|
||||
const accessErrorMessage = (label, targetPath, error) => {
|
||||
if (error?.code === 'ENOENT' || error?.code === 'ENOTDIR') {
|
||||
return `${label} does not exist: ${targetPath}`;
|
||||
}
|
||||
if (error?.code === 'EACCES' || error?.code === 'EPERM') {
|
||||
return `${label} is not accessible: ${targetPath}`;
|
||||
}
|
||||
return `${label} could not be checked: ${error?.message || String(error)}`;
|
||||
};
|
||||
|
||||
export const normalizeRequiredPath = (rawPath, label = 'Path') => {
|
||||
const targetPath = typeof rawPath === 'string' ? rawPath.trim() : '';
|
||||
if (!targetPath) {
|
||||
throw new Error(`${label} is required`);
|
||||
}
|
||||
return path.resolve(targetPath);
|
||||
};
|
||||
|
||||
export const validateLocalPath = async (rawPath, label = 'Path') => {
|
||||
const targetPath = normalizeRequiredPath(rawPath, label);
|
||||
let stats;
|
||||
try {
|
||||
stats = await fsp.stat(targetPath);
|
||||
} catch (error) {
|
||||
throw new Error(accessErrorMessage(label, targetPath, error));
|
||||
}
|
||||
|
||||
const accessMode = stats.isDirectory()
|
||||
? fs.constants.R_OK | fs.constants.X_OK
|
||||
: fs.constants.R_OK;
|
||||
try {
|
||||
await fsp.access(targetPath, accessMode);
|
||||
} catch (error) {
|
||||
throw new Error(accessErrorMessage(label, targetPath, error));
|
||||
}
|
||||
|
||||
return { path: targetPath, stats };
|
||||
};
|
||||
|
||||
export const unsupportedAppSpecificOpenError = (targetKind, platform = process.platform) => {
|
||||
const platformName = platform === 'linux'
|
||||
? 'Linux'
|
||||
: platform === 'win32'
|
||||
? 'Windows'
|
||||
: platform;
|
||||
return `Opening ${targetKind} in a specific app is not supported on ${platformName} yet. Use the default open action instead.`;
|
||||
};
|
||||
@@ -22,6 +22,7 @@ const macVibrancySupported = process.platform === 'darwin';
|
||||
// Effective state for this window (main process resolves the saved preference
|
||||
// and passes it in). Defaults on when supported unless explicitly '0'.
|
||||
const hasMacVibrancy = macVibrancySupported && readArgValue('--openchamber-mac-vibrancy') !== '0';
|
||||
const trayEnabled = process.platform !== 'darwin' || readArgValue('--openchamber-tray-enabled') !== '0';
|
||||
|
||||
// Preload re-executes on every cross-origin navigation (we run with
|
||||
// sandbox:false, per-document). Two separate concerns to balance:
|
||||
@@ -95,8 +96,10 @@ if (Number.isFinite(macosMajor) && macosMajor > 0) {
|
||||
|
||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_ELECTRON__', {
|
||||
runtime: 'electron',
|
||||
arch: process.arch,
|
||||
macVibrancy: hasMacVibrancy,
|
||||
macVibrancySupported,
|
||||
trayEnabled,
|
||||
});
|
||||
|
||||
contextBridge.exposeInMainWorld('__OPENCHAMBER_PLATFORM__', process.platform);
|
||||
|
||||
@@ -2,11 +2,28 @@ const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
module.exports = (context) => {
|
||||
const resourcesPath = context.electronPlatformName === 'darwin'
|
||||
? path.join(context.appOutDir, `${context.packager.appInfo.productFilename}.app`, 'Contents', 'Resources')
|
||||
: path.join(context.appOutDir, 'resources');
|
||||
const betterSqliteDir = path.dirname(require.resolve('better-sqlite3/package.json'));
|
||||
const betterSqliteBinary = path.join(betterSqliteDir, 'build', 'Release', 'better_sqlite3.node');
|
||||
if (!fs.existsSync(betterSqliteBinary)) {
|
||||
throw new Error(`Missing rebuilt better-sqlite3 binary at ${betterSqliteBinary}`);
|
||||
}
|
||||
const packagedBetterSqliteBinary = path.join(
|
||||
resourcesPath,
|
||||
'app.asar.unpacked',
|
||||
'node_modules',
|
||||
'better-sqlite3',
|
||||
'build',
|
||||
'Release',
|
||||
'better_sqlite3.node',
|
||||
);
|
||||
fs.mkdirSync(path.dirname(packagedBetterSqliteBinary), { recursive: true });
|
||||
fs.copyFileSync(betterSqliteBinary, packagedBetterSqliteBinary);
|
||||
|
||||
if (context.electronPlatformName !== 'darwin') return;
|
||||
|
||||
const appName = context.packager.appInfo.productFilename;
|
||||
const appBundlePath = path.join(context.appOutDir, `${appName}.app`);
|
||||
const resourcesPath = path.join(appBundlePath, 'Contents', 'Resources');
|
||||
const sourceAssetsPath = path.join(__dirname, '..', 'resources', 'icons', 'Assets.car');
|
||||
|
||||
if (!fs.existsSync(sourceAssetsPath)) {
|
||||
|
||||
@@ -76,14 +76,11 @@ const output = {};
|
||||
|
||||
const winX64 = await read('latest-yml-x86_64-pc-windows-msvc', 'latest.yml');
|
||||
const winArm64 = await read('latest-yml-aarch64-pc-windows-msvc', 'latest.yml');
|
||||
if (winX64 || winArm64) {
|
||||
const base = winArm64 || winX64;
|
||||
output['latest.yml'] = serialize({
|
||||
version: base.version,
|
||||
files: [...(winArm64?.files || []), ...(winX64?.files || [])],
|
||||
releaseDate: base.releaseDate,
|
||||
});
|
||||
if (!winX64 || !winArm64) {
|
||||
throw new Error('Both x64 and arm64 Windows update manifests are required');
|
||||
}
|
||||
output['latest.yml'] = serialize(winX64);
|
||||
output['latest-arm64.yml'] = serialize(winArm64);
|
||||
|
||||
const macX64 = await read('latest-yml-x86_64-apple-darwin', 'latest-mac.yml');
|
||||
const macArm64 = await read('latest-yml-aarch64-apple-darwin', 'latest-mac.yml');
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import { execFileSync, spawnSync } from 'node:child_process';
|
||||
import fs from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const script = fileURLToPath(new URL('./finalize-latest-yml.mjs', import.meta.url));
|
||||
|
||||
const manifest = (architecture) => `version: 1.2.3
|
||||
files:
|
||||
- url: OpenChamber-1.2.3-win-${architecture}.exe
|
||||
sha512: ${architecture}-checksum
|
||||
size: 123
|
||||
releaseDate: '2026-07-30T00:00:00.000Z'
|
||||
`;
|
||||
|
||||
const createFixture = ({ includeArm64 = true } = {}) => {
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-latest-yml-'));
|
||||
const artifacts = path.join(root, 'artifacts');
|
||||
const output = path.join(root, 'output');
|
||||
fs.mkdirSync(path.join(artifacts, 'latest-yml-x86_64-pc-windows-msvc'), { recursive: true });
|
||||
fs.writeFileSync(path.join(artifacts, 'latest-yml-x86_64-pc-windows-msvc', 'latest.yml'), manifest('x64'));
|
||||
if (includeArm64) {
|
||||
fs.mkdirSync(path.join(artifacts, 'latest-yml-aarch64-pc-windows-msvc'), { recursive: true });
|
||||
fs.writeFileSync(path.join(artifacts, 'latest-yml-aarch64-pc-windows-msvc', 'latest.yml'), manifest('arm64'));
|
||||
}
|
||||
fs.mkdirSync(output);
|
||||
return { root, artifacts, output };
|
||||
};
|
||||
|
||||
const environment = ({ artifacts, output }) => ({
|
||||
...process.env,
|
||||
LATEST_YML_DIR: artifacts,
|
||||
RUNNER_TEMP: output,
|
||||
GH_REPO: 'openchamber/openchamber',
|
||||
OPENCHAMBER_VERSION: '1.2.3',
|
||||
});
|
||||
|
||||
test('writes separate x64 and ARM64 Windows update channels', (context) => {
|
||||
const fixture = createFixture();
|
||||
context.after(() => fs.rmSync(fixture.root, { recursive: true, force: true }));
|
||||
|
||||
execFileSync(process.execPath, [script], { env: environment(fixture) });
|
||||
|
||||
const x64 = fs.readFileSync(path.join(fixture.output, 'latest.yml'), 'utf8');
|
||||
const arm64 = fs.readFileSync(path.join(fixture.output, 'latest-arm64.yml'), 'utf8');
|
||||
assert.match(x64, /win-x64\.exe/);
|
||||
assert.doesNotMatch(x64, /win-arm64\.exe/);
|
||||
assert.match(arm64, /win-arm64\.exe/);
|
||||
assert.doesNotMatch(arm64, /win-x64\.exe/);
|
||||
});
|
||||
|
||||
test('fails instead of publishing an incomplete Windows channel set', (context) => {
|
||||
const fixture = createFixture({ includeArm64: false });
|
||||
context.after(() => fs.rmSync(fixture.root, { recursive: true, force: true }));
|
||||
|
||||
const result = spawnSync(process.execPath, [script], { env: environment(fixture), encoding: 'utf8' });
|
||||
|
||||
assert.notEqual(result.status, 0);
|
||||
assert.match(result.stderr, /Both x64 and arm64 Windows update manifests are required/);
|
||||
});
|
||||
@@ -47,7 +47,17 @@ const artifactForPlatform = (platform, targetArchitecture) => {
|
||||
if (arch === 'x64') return { name: 'opencode-darwin-x64-baseline.zip', binary: 'opencode' };
|
||||
}
|
||||
if (platform === 'win32') {
|
||||
if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' };
|
||||
// TEMPORARY WORKAROUND — Windows ARM64: native opencode.exe fails with a Bun
|
||||
// FFI/TinyCC dlopen error (https://github.com/anomalyco/opencode/issues/19130).
|
||||
// Bundle x64-baseline instead (runs under x64 emulation); OpenCode self-upgrade
|
||||
// is disabled elsewhere so it can't overwrite with the broken ARM64 build.
|
||||
// Remove this block and restore the original below when the upstream issue
|
||||
// is resolved.
|
||||
// --- ORIGINAL (restore when ARM64 is fixed) ---
|
||||
// if (arch === 'arm64') return { name: 'opencode-windows-arm64.zip', binary: 'opencode.exe' };
|
||||
// if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' };
|
||||
// --- END ORIGINAL ---
|
||||
if (arch === 'arm64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' };
|
||||
if (arch === 'x64') return { name: 'opencode-windows-x64-baseline.zip', binary: 'opencode.exe' };
|
||||
}
|
||||
if (platform === 'linux') {
|
||||
|
||||
@@ -133,6 +133,19 @@ const ensureWindowsNodeAddonApiForNodePty = async (rebuildRootPath) => {
|
||||
|
||||
console.log(`[electron] rebuilding native modules against Electron ${electronVersion}...`);
|
||||
|
||||
await rebuild({
|
||||
buildPath: electronDir,
|
||||
electronVersion,
|
||||
force: true,
|
||||
arch: targetArchitecture.electronBuilder,
|
||||
onlyModules: ['better-sqlite3'],
|
||||
});
|
||||
const betterSqliteDir = path.dirname(require.resolve('better-sqlite3/package.json'));
|
||||
const betterSqliteBinary = path.join(betterSqliteDir, 'build', 'Release', 'better_sqlite3.node');
|
||||
if (!existsSync(betterSqliteBinary)) {
|
||||
throw new Error(`better-sqlite3 rebuild did not produce ${betterSqliteBinary}`);
|
||||
}
|
||||
|
||||
// Rebuild against the hoisted root node_modules (bun workspace layout).
|
||||
// force=true re-links regardless of cached state; prebuild-install lookup is
|
||||
// bypassed by @electron/rebuild in favor of direct node-gyp builds.
|
||||
@@ -145,7 +158,7 @@ try {
|
||||
electronVersion,
|
||||
force: true,
|
||||
arch: targetArchitecture.electronBuilder,
|
||||
onlyModules: ['better-sqlite3', 'node-pty', 'bun-pty'],
|
||||
onlyModules: ['node-pty', 'bun-pty'],
|
||||
});
|
||||
} finally {
|
||||
try {
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import {
|
||||
buildCommandFromDesktopExec,
|
||||
buildLinuxInstalledApps,
|
||||
buildLinuxOpenSpecs,
|
||||
fetchLinuxAppIcons,
|
||||
filterLinuxInstalledApps,
|
||||
findLinuxFileManagerEntry,
|
||||
linuxApplicationDirs,
|
||||
parseDesktopEntry,
|
||||
readLinuxDesktopEntries,
|
||||
resolveLinuxIconFile,
|
||||
} from '../linux-app-discovery.mjs';
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) throw new Error(message);
|
||||
};
|
||||
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-linux-apps-'));
|
||||
try {
|
||||
const dataHome = path.join(tempRoot, 'data-home');
|
||||
const dataDir = path.join(tempRoot, 'system-data');
|
||||
const userApps = path.join(dataHome, 'applications');
|
||||
const systemApps = path.join(dataDir, 'applications');
|
||||
const iconsRoot = path.join(dataDir, 'icons');
|
||||
const thunarIcon = path.join(iconsRoot, 'hicolor', '48x48', 'apps', 'org.xfce.thunar.png');
|
||||
const codeIcon = path.join(iconsRoot, 'hicolor', '32x32', 'apps', 'code.png');
|
||||
await fs.mkdir(userApps, { recursive: true });
|
||||
await fs.mkdir(systemApps, { recursive: true });
|
||||
await fs.mkdir(path.dirname(thunarIcon), { recursive: true });
|
||||
await fs.mkdir(path.dirname(codeIcon), { recursive: true });
|
||||
// Minimal valid 1x1 PNG.
|
||||
const png = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==', 'base64');
|
||||
await fs.writeFile(thunarIcon, png);
|
||||
await fs.writeFile(codeIcon, png);
|
||||
|
||||
const codeDesktopPath = path.join(userApps, 'code.desktop');
|
||||
await fs.writeFile(codeDesktopPath, [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=Visual Studio Code',
|
||||
'Exec="/opt/Visual Studio Code/code" --new-window %F --reuse-window %i %c %k',
|
||||
'Icon=code',
|
||||
'Categories=Development;IDE;',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
await fs.writeFile(path.join(userApps, 'hidden.desktop'), '[Desktop Entry]\nType=Application\nName=Hidden App\nExec=hidden %f\nHidden=true\n', 'utf8');
|
||||
await fs.writeFile(path.join(userApps, 'nodisplay.desktop'), '[Desktop Entry]\nType=Application\nName=No Display App\nExec=nodisplay %f\nNoDisplay=true\n', 'utf8');
|
||||
await fs.writeFile(path.join(userApps, 'missing-name.desktop'), '[Desktop Entry]\nType=Application\nExec=missing %f\n', 'utf8');
|
||||
await fs.writeFile(path.join(userApps, 'missing-exec.desktop'), '[Desktop Entry]\nType=Application\nName=Missing Exec\nIcon=missing\n', 'utf8');
|
||||
await fs.writeFile(path.join(systemApps, 'ghostty.desktop'), '[Desktop Entry]\nType=Application\nName=Ghostty\nExec=ghostty --working-directory=%f --open-uri=%u\nIcon=ghostty\n', 'utf8');
|
||||
await fs.writeFile(path.join(systemApps, 'plain.desktop'), '[Desktop Entry]\nType=Application\nName=Plain Editor\nExec=plain-editor --flag\nIcon=plain\n', 'utf8');
|
||||
await fs.writeFile(path.join(systemApps, 'thunar.desktop'), [
|
||||
'[Desktop Entry]',
|
||||
'Type=Application',
|
||||
'Name=Thunar File Manager',
|
||||
'Exec=thunar %F',
|
||||
'Icon=org.xfce.thunar',
|
||||
'Categories=System;FileTools;FileManager;',
|
||||
'',
|
||||
].join('\n'), 'utf8');
|
||||
|
||||
const env = { XDG_DATA_HOME: dataHome, XDG_DATA_DIRS: dataDir, PATH: '/no/such/bin' };
|
||||
const dirs = linuxApplicationDirs({ env, homeDir: tempRoot });
|
||||
assert(dirs.includes(userApps), 'XDG_DATA_HOME applications dir should be included');
|
||||
assert(dirs.includes(systemApps), 'XDG_DATA_DIRS applications dir should be included');
|
||||
|
||||
const entries = await readLinuxDesktopEntries({ applicationDirs: [userApps, systemApps], env, homeDir: tempRoot });
|
||||
assert(entries.length === 4, `expected 4 visible valid entries, got ${entries.length}`);
|
||||
assert(entries.some((entry) => entry.name === 'Visual Studio Code'), 'valid desktop entry should be parsed');
|
||||
assert(entries.some((entry) => entry.name === 'Ghostty'), 'system desktop entry should be parsed');
|
||||
assert(entries.some((entry) => entry.name === 'Plain Editor'), 'no-placeholder entry should be parsed');
|
||||
assert(entries.some((entry) => entry.name === 'Thunar File Manager'), 'file manager entry should be parsed');
|
||||
assert(!entries.some((entry) => entry.name === 'Hidden App'), 'Hidden=true entry should be skipped');
|
||||
assert(!entries.some((entry) => entry.name === 'No Display App'), 'NoDisplay=true entry should be skipped');
|
||||
assert(!entries.some((entry) => entry.name === 'Missing Exec'), 'missing Exec entry should be skipped');
|
||||
|
||||
const codeEntry = parseDesktopEntry(await fs.readFile(codeDesktopPath, 'utf8'), codeDesktopPath);
|
||||
assert(codeEntry?.name === 'Visual Studio Code', 'parser should read Name');
|
||||
assert(codeEntry?.icon === 'code', 'parser should read Icon');
|
||||
assert(codeEntry?.categories.includes('Development'), 'parser should split Categories');
|
||||
assert(codeEntry?.rawExec?.includes('%F'), 'parser should preserve original Exec placeholders for launch construction');
|
||||
assert(codeEntry?.exec === '"/opt/Visual Studio Code/code" --new-window --reuse-window', `parser should expose stripped Exec metadata, got ${codeEntry?.exec}`);
|
||||
|
||||
const command = buildCommandFromDesktopExec(codeEntry, '/tmp/My Project');
|
||||
assert(command?.program === '/opt/Visual Studio Code/code', 'quoted Exec program should stay intact');
|
||||
assert(command.args.slice(0, 3).join('|') === '--new-window|/tmp/My Project|--reuse-window', `Exec %F should stay at original position, got ${command.args.join('|')}`);
|
||||
assert(!command.args.some((arg) => arg.includes('%')), 'Exec field codes should not leak into command args');
|
||||
|
||||
const ghosttyEntry = entries.find((entry) => entry.name === 'Ghostty');
|
||||
const ghosttyCommand = buildCommandFromDesktopExec(ghosttyEntry, '/tmp/My Project');
|
||||
assert(ghosttyCommand?.args.join('|') === '--working-directory=/tmp/My Project|--open-uri=/tmp/My Project', `embedded %f/%u should be substituted in place, got ${ghosttyCommand?.args.join('|')}`);
|
||||
|
||||
const urlEntry = parseDesktopEntry('[Desktop Entry]\nType=Application\nName=URL Handler\nExec=url-handler --url %U\n', '/tmp/url.desktop');
|
||||
const urlCommand = buildCommandFromDesktopExec(urlEntry, 'file:///tmp/My%20Project');
|
||||
assert(urlCommand?.args.join('|') === '--url|file:///tmp/My%20Project', `Exec %U should substitute URL targets, got ${urlCommand?.args.join('|')}`);
|
||||
|
||||
const plainEntry = entries.find((entry) => entry.name === 'Plain Editor');
|
||||
const plainCommand = buildCommandFromDesktopExec(plainEntry, '/tmp/My Project');
|
||||
assert(plainCommand?.args.join('|') === '--flag|/tmp/My Project', `target should append when Exec has no placeholder, got ${plainCommand?.args.join('|')}`);
|
||||
|
||||
const installed = await filterLinuxInstalledApps(['Visual Studio Code', 'Hidden App', 'Missing App'], { entries });
|
||||
assert(installed.length === 1 && installed[0] === 'Visual Studio Code', 'filter should return only visible installed apps');
|
||||
|
||||
const resolvedCodeIcon = resolveLinuxIconFile('code', { env, homeDir: tempRoot });
|
||||
assert(resolvedCodeIcon === codeIcon, `resolveLinuxIconFile should find themed PNG, got ${resolvedCodeIcon}`);
|
||||
|
||||
const fileManager = findLinuxFileManagerEntry(entries, {
|
||||
env,
|
||||
execFileSyncImpl: () => 'thunar.desktop',
|
||||
});
|
||||
assert(fileManager?.id === 'thunar', `default file manager should resolve via xdg-mime, got ${fileManager?.id}`);
|
||||
|
||||
const appInfos = await buildLinuxInstalledApps(['Finder', 'Visual Studio Code', 'Ghostty'], {
|
||||
entries,
|
||||
env,
|
||||
homeDir: tempRoot,
|
||||
execFileSyncImpl: () => 'thunar.desktop',
|
||||
});
|
||||
assert(appInfos.length === 3, 'installed app info should include matching entries');
|
||||
assert(appInfos.every((entry) => Object.hasOwn(entry, 'iconDataUrl')), 'installed app info should include iconDataUrl key');
|
||||
const finderInfo = appInfos.find((entry) => entry.name === 'Finder');
|
||||
assert(typeof finderInfo?.iconDataUrl === 'string' && finderInfo.iconDataUrl.startsWith('data:image/png;base64,'), 'Finder/file manager should use system PNG icon data URL');
|
||||
const codeInfo = appInfos.find((entry) => entry.name === 'Visual Studio Code');
|
||||
assert(typeof codeInfo?.iconDataUrl === 'string' && codeInfo.iconDataUrl.startsWith('data:image/png;base64,'), 'desktop app should resolve Icon= theme PNG to data URL');
|
||||
|
||||
const fetchedIcons = await fetchLinuxAppIcons(['Finder', 'Visual Studio Code'], {
|
||||
entries,
|
||||
env,
|
||||
homeDir: tempRoot,
|
||||
execFileSyncImpl: () => 'thunar.desktop',
|
||||
});
|
||||
assert(fetchedIcons.length === 2, 'fetchLinuxAppIcons should return resolved icons');
|
||||
assert(fetchedIcons.every((entry) => entry.data_url?.startsWith('data:image/png;base64,')), 'fetched icons should be PNG data URLs');
|
||||
|
||||
const specs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'vscode', appName: 'Visual Studio Code', targetKind: 'project', entries, env });
|
||||
assert(specs.length === 1, 'desktop entry should provide an opener when CLI is absent');
|
||||
assert(specs[0].program === '/opt/Visual Studio Code/code', 'desktop entry opener should use parsed program');
|
||||
assert(specs[0].args.includes('/tmp/My Project'), 'desktop entry opener should include target');
|
||||
|
||||
const terminalFileSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project/file.txt', appId: 'ghostty', appName: 'Ghostty', targetKind: 'file', entries, env });
|
||||
assert(terminalFileSpecs[0]?.program === 'ghostty', 'terminal desktop entry should be preferred when present');
|
||||
assert(terminalFileSpecs[0]?.args.join('|') === '--working-directory=/tmp/My Project|--open-uri=/tmp/My Project', `terminal file target should use dirname, got ${terminalFileSpecs[0]?.args.join('|')}`);
|
||||
assert(terminalFileSpecs[1]?.program === 'xdg-terminal-exec', 'terminal specs should include xdg-terminal-exec fallback after desktop entry');
|
||||
assert(terminalFileSpecs[1]?.args.join('|') === '--working-directory|/tmp/My Project', `terminal fallback should use file dirname, got ${terminalFileSpecs[1]?.args.join('|')}`);
|
||||
|
||||
const fallbackTerminalSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'terminal', appName: 'Terminal', targetKind: 'project', entries, env });
|
||||
assert(fallbackTerminalSpecs.length >= 1, 'missing terminal desktop entry should include xdg-terminal-exec fallback');
|
||||
assert(fallbackTerminalSpecs[0]?.program === 'xdg-terminal-exec', 'missing terminal entry should use xdg-terminal-exec first');
|
||||
assert(fallbackTerminalSpecs[0]?.args.join('|') === '--working-directory|/tmp/My Project', `xdg-terminal-exec fallback should keep working directory args, got ${fallbackTerminalSpecs[0]?.args.join('|')}`);
|
||||
|
||||
const defaultSpecs = buildLinuxOpenSpecs({ targetPath: '/tmp/My Project', appId: 'finder', appName: 'Finder', targetKind: 'project', entries, env });
|
||||
assert(defaultSpecs[0].kind === 'default', 'finder maps to safe default Linux opener spec');
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
dirs,
|
||||
entries: entries.map((entry) => entry.name),
|
||||
command,
|
||||
ghosttyCommand,
|
||||
plainCommand,
|
||||
installed,
|
||||
finderIcon: Boolean(finderInfo?.iconDataUrl),
|
||||
codeIcon: Boolean(codeInfo?.iconDataUrl),
|
||||
fetchedIcons: fetchedIcons.map((entry) => entry.app),
|
||||
specs,
|
||||
terminalFileSpecs,
|
||||
fallbackTerminalSpecs,
|
||||
defaultSpecs,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import fs from 'node:fs/promises';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { validateLocalPath, unsupportedAppSpecificOpenError } from '../path-open-utils.mjs';
|
||||
|
||||
const assert = (condition, message) => {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
};
|
||||
|
||||
const expectRejects = async (label, callback, expected) => {
|
||||
try {
|
||||
await callback();
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : String(error);
|
||||
assert(message.includes(expected), `${label}: expected "${expected}" in "${message}"`);
|
||||
return message;
|
||||
}
|
||||
throw new Error(`${label}: expected rejection`);
|
||||
};
|
||||
|
||||
const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'openchamber-path-open-'));
|
||||
try {
|
||||
const existingFile = path.join(tempRoot, 'existing.txt');
|
||||
await fs.writeFile(existingFile, 'ok', 'utf8');
|
||||
|
||||
const validated = await validateLocalPath(existingFile);
|
||||
assert(validated.path === existingFile, 'valid file path should resolve to the same absolute path');
|
||||
assert(validated.stats.isFile(), 'valid file path should return file stats');
|
||||
|
||||
const validatedDirectory = await validateLocalPath(tempRoot, 'Directory');
|
||||
assert(validatedDirectory.path === tempRoot, 'valid directory path should resolve to the same absolute path');
|
||||
assert(validatedDirectory.stats.isDirectory(), 'valid directory path should return directory stats');
|
||||
|
||||
const missingMessage = await expectRejects(
|
||||
'missing path',
|
||||
() => validateLocalPath(path.join(tempRoot, 'missing.txt')),
|
||||
'does not exist',
|
||||
);
|
||||
const emptyMessage = await expectRejects(
|
||||
'empty path',
|
||||
() => validateLocalPath(' '),
|
||||
'Path is required',
|
||||
);
|
||||
const inaccessiblePath = path.join(tempRoot, 'inaccessible');
|
||||
await fs.mkdir(inaccessiblePath, { mode: 0o700 });
|
||||
await fs.chmod(inaccessiblePath, 0o000);
|
||||
let inaccessibleMessage = '';
|
||||
try {
|
||||
inaccessibleMessage = await expectRejects(
|
||||
'inaccessible path',
|
||||
() => validateLocalPath(inaccessiblePath),
|
||||
'is not accessible',
|
||||
);
|
||||
} finally {
|
||||
await fs.chmod(inaccessiblePath, 0o700).catch(() => {});
|
||||
}
|
||||
|
||||
const unsupported = unsupportedAppSpecificOpenError('projects', 'linux');
|
||||
assert(
|
||||
unsupported.includes('not supported on Linux') && unsupported.includes('default open action'),
|
||||
'unsupported app-specific Linux message should point to default open action',
|
||||
);
|
||||
|
||||
console.log(JSON.stringify({
|
||||
ok: true,
|
||||
validated: validated.path,
|
||||
validatedDirectory: validatedDirectory.path,
|
||||
missingMessage,
|
||||
emptyMessage,
|
||||
inaccessibleMessage,
|
||||
unsupported,
|
||||
}, null, 2));
|
||||
} finally {
|
||||
await fs.rm(tempRoot, { recursive: true, force: true });
|
||||
}
|
||||
+285
-128
@@ -17,7 +17,9 @@ const MONITOR_INITIAL_POLL_MS = 2000;
|
||||
const MONITOR_STEADY_POLL_MS = 10000;
|
||||
const MONITOR_STABILIZE_TICKS = 5;
|
||||
const SSH_STATUS_EVENT = 'openchamber:ssh-instance-status';
|
||||
const WINDOWS_HIDDEN_SPAWN_OPTIONS = process.platform === 'win32' ? { windowsHide: true } : {};
|
||||
const MAX_PROCESS_ERROR_CHARS = 2000;
|
||||
const MAX_PROCESS_ERROR_CAPTURE_CHARS = MAX_PROCESS_ERROR_CHARS * 2;
|
||||
const childProcessDiagnostics = new WeakMap();
|
||||
|
||||
const nowMillis = () => Date.now();
|
||||
|
||||
@@ -218,71 +220,12 @@ const parseSshCommand = (raw) => {
|
||||
return { destination, args };
|
||||
};
|
||||
|
||||
const runOutput = async (command, args, options = {}) => {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = spawn(command, args, {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
|
||||
...options,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr += chunk.toString();
|
||||
});
|
||||
child.on('error', reject);
|
||||
child.on('close', (code) => {
|
||||
resolve({ code: typeof code === 'number' ? code : -1, stdout, stderr });
|
||||
});
|
||||
});
|
||||
};
|
||||
|
||||
const buildSshArgs = (parsed, preDestinationArgs = [], remoteCommand = null) => {
|
||||
const args = [...parsed.args, ...preDestinationArgs, parsed.destination];
|
||||
if (remoteCommand) args.push(remoteCommand);
|
||||
return args;
|
||||
};
|
||||
|
||||
const runRemoteCommand = async (parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) => {
|
||||
const args = buildSshArgs(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', `ConnectTimeout=${timeoutSec}`,
|
||||
'-T',
|
||||
], `sh -lc ${shellQuote(script)}`);
|
||||
const { code, stdout, stderr } = await runOutput('ssh', args);
|
||||
if (code !== 0) {
|
||||
throw new Error((stderr || stdout || 'Remote command failed').trim());
|
||||
}
|
||||
return stdout;
|
||||
};
|
||||
|
||||
const controlMasterOperation = async (parsed, controlPath, op) => {
|
||||
return await runOutput('ssh', buildSshArgs(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=3',
|
||||
'-O', op,
|
||||
]));
|
||||
};
|
||||
|
||||
const isControlMasterAlive = async (parsed, controlPath) => {
|
||||
const { code } = await controlMasterOperation(parsed, controlPath, 'check');
|
||||
return code === 0;
|
||||
};
|
||||
|
||||
const stopControlMasterBestEffort = async (parsed, controlPath) => {
|
||||
try {
|
||||
await controlMasterOperation(parsed, controlPath, 'exit');
|
||||
} catch {
|
||||
}
|
||||
};
|
||||
|
||||
const askpassScriptContent = () => `#!/bin/bash
|
||||
PROMPT="$1"
|
||||
|
||||
@@ -331,6 +274,27 @@ const writeAskpassScript = async (scriptPath) => {
|
||||
await fsp.chmod(scriptPath, 0o700);
|
||||
};
|
||||
|
||||
const windowsAskpassScriptContent = () => `$value = [Environment]::GetEnvironmentVariable('OPENCHAMBER_SSH_ASKPASS_VALUE')
|
||||
if ($null -ne $value) {
|
||||
[Console]::Out.WriteLine($value)
|
||||
}
|
||||
`;
|
||||
|
||||
const windowsAskpassWrapperContent = () => `@echo off\r
|
||||
"%SystemRoot%\\System32\\WindowsPowerShell\\v1.0\\powershell.exe" -NoLogo -NoProfile -NonInteractive -ExecutionPolicy Bypass -File "%~dp0askpass.ps1"\r
|
||||
`;
|
||||
|
||||
const sanitizeProcessDiagnostic = (value, secret = '') => {
|
||||
let sanitized = String(value || '')
|
||||
.replace(/[\u0000-\u0008\u000b\u000c\u000e-\u001f\u007f]/g, '')
|
||||
.trim();
|
||||
if (secret) sanitized = sanitized.split(secret).join('[redacted]');
|
||||
if (sanitized.length > MAX_PROCESS_ERROR_CHARS) {
|
||||
sanitized = `${sanitized.slice(0, MAX_PROCESS_ERROR_CHARS - 3)}...`;
|
||||
}
|
||||
return sanitized;
|
||||
};
|
||||
|
||||
const randomPortCandidate = (seed) => {
|
||||
let hash = 0;
|
||||
const source = `${seed}:${Date.now()}`;
|
||||
@@ -420,6 +384,8 @@ export class ElectronSshManager {
|
||||
this.settingsFilePath = options.settingsFilePath;
|
||||
this.appVersion = options.appVersion;
|
||||
this.emit = options.emit;
|
||||
this.platform = options.platform || process.platform;
|
||||
this.spawnProcess = options.spawn || spawn;
|
||||
this.logs = new Map();
|
||||
this.statuses = new Map();
|
||||
this.sessions = new Map();
|
||||
@@ -427,6 +393,162 @@ export class ElectronSshManager {
|
||||
this.reconnectAttempts = new Map();
|
||||
this.connectAttempts = new Map();
|
||||
this.connecting = new Map();
|
||||
this.sshAuth = new WeakMap();
|
||||
}
|
||||
|
||||
usesControlMaster() {
|
||||
return this.platform !== 'win32';
|
||||
}
|
||||
|
||||
hiddenSpawnOptions() {
|
||||
return this.platform === 'win32' ? { windowsHide: true } : {};
|
||||
}
|
||||
|
||||
authEnvironment(parsed) {
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
if (!auth) return process.env;
|
||||
return {
|
||||
...process.env,
|
||||
SSH_ASKPASS_REQUIRE: 'force',
|
||||
SSH_ASKPASS: auth.askpassPath,
|
||||
DISPLAY: '1',
|
||||
...(auth.sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: auth.sshPassword.trim() } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
independentConnectionArgs() {
|
||||
return [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', 'ControlPath=none',
|
||||
'-o', 'StrictHostKeyChecking=accept-new',
|
||||
];
|
||||
}
|
||||
|
||||
trackSshProcess(child, parsed) {
|
||||
const diagnostics = { stderr: '', error: null, parsed };
|
||||
childProcessDiagnostics.set(child, diagnostics);
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
auth?.children.add(child);
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
diagnostics.stderr = `${diagnostics.stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS);
|
||||
});
|
||||
child.on('error', (error) => {
|
||||
diagnostics.error = error;
|
||||
});
|
||||
child.on('close', () => {
|
||||
auth?.children.delete(child);
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
processErrorDetail(child, fallback) {
|
||||
const diagnostics = childProcessDiagnostics.get(child);
|
||||
const auth = diagnostics ? this.sshAuth.get(diagnostics.parsed) : null;
|
||||
const detail = sanitizeProcessDiagnostic(
|
||||
diagnostics?.error instanceof Error ? diagnostics.error.message : diagnostics?.stderr,
|
||||
auth?.sshPassword,
|
||||
);
|
||||
return detail || fallback;
|
||||
}
|
||||
|
||||
spawnSsh(parsed, preDestinationArgs, options, remoteCommand = null) {
|
||||
const child = this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), {
|
||||
...options,
|
||||
...this.hiddenSpawnOptions(),
|
||||
env: this.authEnvironment(parsed),
|
||||
});
|
||||
return this.trackSshProcess(child, parsed);
|
||||
}
|
||||
|
||||
async runSshOutput(parsed, preDestinationArgs, remoteCommand = null) {
|
||||
return await new Promise((resolve, reject) => {
|
||||
const child = this.trackSshProcess(this.spawnProcess('ssh', buildSshArgs(parsed, preDestinationArgs, remoteCommand), {
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
...this.hiddenSpawnOptions(),
|
||||
env: this.authEnvironment(parsed),
|
||||
}), parsed);
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
child.stdout?.on('data', (chunk) => {
|
||||
stdout += chunk.toString();
|
||||
});
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderr = `${stderr}${chunk.toString()}`.slice(-MAX_PROCESS_ERROR_CAPTURE_CHARS);
|
||||
});
|
||||
child.on('error', () => {
|
||||
reject(new Error(this.processErrorDetail(child, 'Failed to start SSH process')));
|
||||
});
|
||||
child.on('close', (code) => {
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
resolve({
|
||||
code: typeof code === 'number' ? code : -1,
|
||||
stdout,
|
||||
stderr: sanitizeProcessDiagnostic(stderr, auth?.sshPassword),
|
||||
});
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
async runRemoteCommand(parsed, controlPath, script, timeoutSec = DEFAULT_CONNECTION_TIMEOUT_SEC) {
|
||||
const connectionArgs = this.usesControlMaster()
|
||||
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`]
|
||||
: this.independentConnectionArgs();
|
||||
const { code, stdout, stderr } = await this.runSshOutput(parsed, [
|
||||
...connectionArgs,
|
||||
'-o', `ConnectTimeout=${timeoutSec}`,
|
||||
'-T',
|
||||
], `sh -lc ${shellQuote(script)}`);
|
||||
if (code !== 0) {
|
||||
const auth = this.sshAuth.get(parsed);
|
||||
throw new Error(sanitizeProcessDiagnostic(stderr || stdout, auth?.sshPassword) || 'Remote command failed');
|
||||
}
|
||||
return stdout;
|
||||
}
|
||||
|
||||
async controlMasterOperation(parsed, controlPath, op) {
|
||||
return await this.runSshOutput(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', 'BatchMode=yes',
|
||||
'-o', 'ConnectTimeout=3',
|
||||
'-O', op,
|
||||
]);
|
||||
}
|
||||
|
||||
async isControlMasterAlive(parsed, controlPath) {
|
||||
const { code } = await this.controlMasterOperation(parsed, controlPath, 'check');
|
||||
return code === 0;
|
||||
}
|
||||
|
||||
async stopControlMasterBestEffort(parsed, controlPath) {
|
||||
if (!this.usesControlMaster()) return;
|
||||
try {
|
||||
await this.controlMasterOperation(parsed, controlPath, 'exit');
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
|
||||
async writeAskpassFiles(sessionDir) {
|
||||
if (this.platform === 'win32') {
|
||||
const scriptPath = path.join(sessionDir, 'askpass.ps1');
|
||||
const wrapperPath = path.join(sessionDir, 'askpass.cmd');
|
||||
try {
|
||||
await fsp.writeFile(scriptPath, windowsAskpassScriptContent());
|
||||
await fsp.writeFile(wrapperPath, windowsAskpassWrapperContent());
|
||||
} catch (error) {
|
||||
await Promise.allSettled([
|
||||
fsp.rm(scriptPath, { force: true }),
|
||||
fsp.rm(wrapperPath, { force: true }),
|
||||
]);
|
||||
throw error;
|
||||
}
|
||||
return { askpassPath: wrapperPath, cleanupPaths: [wrapperPath, scriptPath] };
|
||||
}
|
||||
|
||||
const askpassPath = path.join(sessionDir, 'askpass.sh');
|
||||
await writeAskpassScript(askpassPath);
|
||||
return { askpassPath, cleanupPaths: [askpassPath] };
|
||||
}
|
||||
|
||||
appendLogWithLevel(id, level, message) {
|
||||
@@ -791,7 +913,7 @@ export class ElectronSshManager {
|
||||
}
|
||||
|
||||
async resolveSshConfig(parsed) {
|
||||
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, ['-G']));
|
||||
const { code, stdout, stderr } = await this.runSshOutput(parsed, ['-G']);
|
||||
if (code !== 0) {
|
||||
throw new Error(stderr.trim() || 'Failed to resolve SSH config');
|
||||
}
|
||||
@@ -820,41 +942,34 @@ export class ElectronSshManager {
|
||||
return path.join(os.tmpdir(), `ocssh-${Math.abs(hash).toString(16)}.sock`);
|
||||
}
|
||||
|
||||
async spawnMasterProcess(parsed, controlPath, askpassPath, sshPassword) {
|
||||
const child = spawn('ssh', buildSshArgs(parsed, [
|
||||
async spawnMasterProcess(parsed, controlPath) {
|
||||
return this.spawnSsh(parsed, [
|
||||
'-o', 'ControlMaster=yes',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-o', `ControlPersist=${DEFAULT_CONTROL_PERSIST_SEC}`,
|
||||
'-N',
|
||||
]), {
|
||||
], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
|
||||
env: {
|
||||
...process.env,
|
||||
SSH_ASKPASS_REQUIRE: 'force',
|
||||
SSH_ASKPASS: askpassPath,
|
||||
DISPLAY: '1',
|
||||
...(sshPassword ? { OPENCHAMBER_SSH_ASKPASS_VALUE: sshPassword.trim() } : {}),
|
||||
},
|
||||
});
|
||||
return child;
|
||||
}
|
||||
|
||||
async waitForMasterReady(parsed, controlPath, timeoutSec, master) {
|
||||
const deadline = Date.now() + (timeoutSec * 1000);
|
||||
let pollMs = 250;
|
||||
while (Date.now() < deadline) {
|
||||
const { code } = await runOutput('ssh', buildSshArgs(parsed, [
|
||||
const { code } = await this.runSshOutput(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-O', 'check',
|
||||
]));
|
||||
]);
|
||||
if (code === 0) return;
|
||||
|
||||
const exited = master.exitCode;
|
||||
if (typeof exited === 'number') {
|
||||
throw new Error('SSH master process exited before ready');
|
||||
throw new Error(this.processErrorDetail(master, 'SSH master process exited before ready'));
|
||||
}
|
||||
const spawnError = childProcessDiagnostics.get(master)?.error;
|
||||
if (spawnError) throw new Error(this.processErrorDetail(master, 'Failed to start SSH master process'));
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
pollMs = Math.min(pollMs * 2, 2000);
|
||||
}
|
||||
@@ -868,7 +983,7 @@ export class ElectronSshManager {
|
||||
|
||||
async remoteCommandExists(parsed, controlPath, commandName) {
|
||||
try {
|
||||
const output = await runRemoteCommand(parsed, controlPath, `command -v ${commandName} >/dev/null 2>&1 && echo yes || echo no`);
|
||||
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;
|
||||
@@ -877,7 +992,7 @@ export class ElectronSshManager {
|
||||
|
||||
async currentRemoteOpenChamberVersion(parsed, controlPath) {
|
||||
try {
|
||||
const output = await runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, 'openchamber --version 2>/dev/null || true');
|
||||
return parseVersionToken(output);
|
||||
} catch {
|
||||
return null;
|
||||
@@ -907,7 +1022,7 @@ export class ElectronSshManager {
|
||||
let lastError = null;
|
||||
for (const command of commands) {
|
||||
try {
|
||||
await runRemoteCommand(parsed, controlPath, command);
|
||||
await this.runRemoteCommand(parsed, controlPath, command);
|
||||
return;
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
@@ -920,7 +1035,7 @@ export class ElectronSshManager {
|
||||
const authPayload = openchamberPassword ? JSON.stringify({ password: openchamberPassword }) : '{}';
|
||||
const authEnabled = openchamberPassword ? '1' : '0';
|
||||
const script = `AUTH_STATUS=0; INFO_STATUS=0; HEALTH_STATUS=0; BODY_FILE="$(mktemp)"; COOKIE_FILE="$(mktemp)"; cleanup(){ rm -f "$BODY_FILE" "$COOKIE_FILE"; }; trap cleanup EXIT; if command -v curl >/dev/null 2>&1; then if [ "${authEnabled}" = "1" ]; then AUTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' -c "$COOKIE_FILE" -H 'content-type: application/json' --data ${shellQuote(authPayload)} http://127.0.0.1:${port}/auth/session || true)"; if [ "$AUTH_STATUS" = "200" ]; then INFO_STATUS="$(curl -sS --max-time 3 -b "$COOKIE_FILE" -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; else INFO_STATUS="$(curl -sS --max-time 3 -o "$BODY_FILE" -w '%{http_code}' http://127.0.0.1:${port}/api/system/info || true)"; fi; HEALTH_STATUS="$(curl -sS --max-time 3 -o /dev/null -w '%{http_code}' http://127.0.0.1:${port}/health || true)"; elif command -v wget >/dev/null 2>&1; then wget -qO "$BODY_FILE" http://127.0.0.1:${port}/api/system/info >/dev/null 2>&1; if [ $? -eq 0 ]; then INFO_STATUS=200; fi; wget -qO- http://127.0.0.1:${port}/health >/dev/null 2>&1; if [ $? -eq 0 ]; then HEALTH_STATUS=200; fi; else exit 127; fi; printf 'INFO_STATUS=%s\\nAUTH_STATUS=%s\\nHEALTH_STATUS=%s\\n' "$INFO_STATUS" "$AUTH_STATUS" "$HEALTH_STATUS"; cat "$BODY_FILE" 2>/dev/null || true`;
|
||||
const output = await runRemoteCommand(parsed, controlPath, script);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, script);
|
||||
const lines = output.split(/\r?\n/);
|
||||
const infoStatus = parseProbeStatusLine(lines[0], 'INFO_STATUS=') || 0;
|
||||
const authStatus = parseProbeStatusLine(lines[1], 'AUTH_STATUS=') || 0;
|
||||
@@ -963,14 +1078,14 @@ export class ElectronSshManager {
|
||||
if (secret) {
|
||||
envPrefix += ` OPENCHAMBER_UI_PASSWORD=${shellQuote(secret)}`;
|
||||
}
|
||||
const output = await runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --port ${desiredPort}`);
|
||||
const output = await this.runRemoteCommand(parsed, controlPath, `${envPrefix} openchamber serve --hostname 127.0.0.1 --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) {
|
||||
try {
|
||||
await runRemoteCommand(
|
||||
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`,
|
||||
@@ -980,23 +1095,23 @@ export class ElectronSshManager {
|
||||
}
|
||||
|
||||
async spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort) {
|
||||
return spawn('ssh', buildSshArgs(parsed, [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
const connectionArgs = this.usesControlMaster()
|
||||
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`]
|
||||
: this.independentConnectionArgs();
|
||||
return this.spawnSsh(parsed, [
|
||||
...connectionArgs,
|
||||
'-o', 'ExitOnForwardFailure=yes',
|
||||
'-N',
|
||||
'-L', `${bindHost}:${localPort}:127.0.0.1:${remotePort}`,
|
||||
]), {
|
||||
], {
|
||||
stdio: ['ignore', 'ignore', 'pipe'],
|
||||
...WINDOWS_HIDDEN_SPAWN_OPTIONS,
|
||||
});
|
||||
}
|
||||
|
||||
async spawnExtraForward(parsed, controlPath, forward) {
|
||||
const args = [
|
||||
'-o', 'ControlMaster=no',
|
||||
'-o', `ControlPath=${controlPath}`,
|
||||
'-O', 'forward',
|
||||
];
|
||||
const args = this.usesControlMaster()
|
||||
? ['-o', 'ControlMaster=no', '-o', `ControlPath=${controlPath}`, '-O', 'forward']
|
||||
: [...this.independentConnectionArgs(), '-o', 'ExitOnForwardFailure=yes', '-N'];
|
||||
if (forward.type === 'local') {
|
||||
args.push('-L', `${forward.localHost || '127.0.0.1'}:${forward.localPort}:${forward.remoteHost || '127.0.0.1'}:${forward.remotePort}`);
|
||||
} else if (forward.type === 'remote') {
|
||||
@@ -1004,10 +1119,20 @@ export class ElectronSshManager {
|
||||
} else {
|
||||
args.push('-D', `${forward.localHost || '127.0.0.1'}:${forward.localPort}`);
|
||||
}
|
||||
const { code, stdout, stderr } = await runOutput('ssh', buildSshArgs(parsed, args));
|
||||
if (!this.usesControlMaster()) {
|
||||
const child = this.spawnSsh(parsed, args, { stdio: ['ignore', 'ignore', 'pipe'] });
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
if (typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error) {
|
||||
throw new Error(this.processErrorDetail(child, `Failed to configure extra SSH forward ${forward.id}`));
|
||||
}
|
||||
return child;
|
||||
}
|
||||
|
||||
const { code, stdout, stderr } = await this.runSshOutput(parsed, args);
|
||||
if (code !== 0) {
|
||||
throw new Error((stderr || stdout || `Failed to configure extra SSH forward ${forward.id}`).trim());
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
async ensureRemoteServer(instance, parsed, controlPath) {
|
||||
@@ -1060,11 +1185,18 @@ export class ElectronSshManager {
|
||||
this.sessions.delete(id);
|
||||
|
||||
if (session) {
|
||||
if (session.startedByUs && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
|
||||
if (session.startedByUs && session.remotePort && session.instance.remoteOpenchamber.mode === 'managed' && !session.instance.remoteOpenchamber.keepRunning) {
|
||||
await this.stopRemoteServerBestEffort(session.parsed, session.controlPath, session.remotePort);
|
||||
}
|
||||
await stopControlMasterBestEffort(session.parsed, session.controlPath);
|
||||
for (const child of [session.mainForward, session.master]) {
|
||||
await this.stopControlMasterBestEffort(session.parsed, session.controlPath);
|
||||
const auth = this.sshAuth.get(session.parsed);
|
||||
const children = new Set([
|
||||
session.mainForward,
|
||||
session.master,
|
||||
...session.extraForwards.map((entry) => entry.child),
|
||||
...(auth?.children || []),
|
||||
].filter(Boolean));
|
||||
for (const child of children) {
|
||||
try {
|
||||
child.kill('SIGTERM');
|
||||
} catch {
|
||||
@@ -1074,10 +1206,13 @@ export class ElectronSshManager {
|
||||
await fsp.rm(session.controlPath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
try {
|
||||
await fsp.rm(path.join(session.sessionDir, 'askpass.sh'), { force: true });
|
||||
} catch {
|
||||
for (const askpassFilePath of session.askpassCleanupPaths) {
|
||||
try {
|
||||
await fsp.rm(askpassFilePath, { force: true });
|
||||
} catch {
|
||||
}
|
||||
}
|
||||
this.sshAuth.delete(session.parsed);
|
||||
}
|
||||
|
||||
this.clearRetryAttempt(id);
|
||||
@@ -1096,22 +1231,40 @@ export class ElectronSshManager {
|
||||
const sessionDir = this.ensureSessionDir(id);
|
||||
const controlPath = this.controlPathForInstance(id);
|
||||
try { await fsp.rm(controlPath, { force: true }); } catch {}
|
||||
const askpassPath = path.join(sessionDir, 'askpass.sh');
|
||||
await writeAskpassScript(askpassPath);
|
||||
const { askpassPath, cleanupPaths: askpassCleanupPaths } = await this.writeAskpassFiles(sessionDir);
|
||||
const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value?.trim() : null;
|
||||
this.sshAuth.set(parsed, { askpassPath, sshPassword, children: new Set() });
|
||||
const session = {
|
||||
instance,
|
||||
parsed,
|
||||
sessionDir,
|
||||
controlPath,
|
||||
askpassCleanupPaths,
|
||||
localPort: null,
|
||||
remotePort: null,
|
||||
startedByUs: false,
|
||||
master: null,
|
||||
mainForward: null,
|
||||
mainForwardDetached: false,
|
||||
extraForwards: [],
|
||||
};
|
||||
this.sessions.set(id, session);
|
||||
|
||||
this.setStatus(id, 'master_connecting', 'Establishing SSH ControlMaster');
|
||||
const sshPassword = instance.auth?.sshPassword?.enabled ? instance.auth.sshPassword.value : null;
|
||||
const master = await this.spawnMasterProcess(parsed, controlPath, askpassPath, sshPassword);
|
||||
await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, master);
|
||||
this.setStatus(id, 'master_connecting', this.usesControlMaster() ? 'Establishing SSH ControlMaster' : 'Checking SSH connectivity');
|
||||
if (this.usesControlMaster()) {
|
||||
session.master = await this.spawnMasterProcess(parsed, controlPath);
|
||||
await this.waitForMasterReady(parsed, controlPath, instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC, session.master);
|
||||
}
|
||||
|
||||
this.setStatus(id, 'remote_probe', 'Probing remote platform');
|
||||
const remoteOs = (await runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
|
||||
const remoteOs = (await this.runRemoteCommand(parsed, controlPath, 'uname -s', instance.connectionTimeoutSec || DEFAULT_CONNECTION_TIMEOUT_SEC)).trim().toLowerCase();
|
||||
if (!['linux', 'darwin'].includes(remoteOs)) {
|
||||
master.kill('SIGTERM');
|
||||
throw new Error(`Unsupported remote OS: ${remoteOs}`);
|
||||
}
|
||||
|
||||
const { remotePort, startedByUs } = await this.ensureRemoteServer(instance, parsed, controlPath);
|
||||
session.remotePort = remotePort;
|
||||
session.startedByUs = startedByUs;
|
||||
this.setStatus(id, 'forwarding', 'Setting up port forwards', null, null, remotePort, startedByUs, 0, false);
|
||||
|
||||
const bindHost = sanitizeBindHost(instance.localForward?.bindHost);
|
||||
@@ -1124,22 +1277,24 @@ export class ElectronSshManager {
|
||||
}
|
||||
|
||||
const mainForward = await this.spawnMainForward(parsed, controlPath, bindHost, localPort, remotePort);
|
||||
session.mainForward = mainForward;
|
||||
let mainForwardDetached = false;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
if (typeof mainForward.exitCode === 'number') {
|
||||
if (mainForward.exitCode === 0) {
|
||||
if (typeof mainForward.exitCode === 'number' || childProcessDiagnostics.get(mainForward)?.error) {
|
||||
if (this.usesControlMaster() && mainForward.exitCode === 0) {
|
||||
mainForwardDetached = true;
|
||||
this.appendLogWithLevel(id, 'INFO', 'Main tunnel helper exited after ControlMaster handoff');
|
||||
} else {
|
||||
master.kill('SIGTERM');
|
||||
throw new Error(`Failed to start main port forward (status: ${mainForward.exitCode})`);
|
||||
throw new Error(this.processErrorDetail(mainForward, `Failed to start main port forward (status: ${mainForward.exitCode ?? 'spawn error'})`));
|
||||
}
|
||||
}
|
||||
session.mainForwardDetached = mainForwardDetached;
|
||||
|
||||
const extraErrors = [];
|
||||
for (const forward of instance.portForwards.filter((item) => item.enabled)) {
|
||||
try {
|
||||
await this.spawnExtraForward(parsed, controlPath, forward);
|
||||
const extraForward = await this.spawnExtraForward(parsed, controlPath, forward);
|
||||
if (extraForward) session.extraForwards.push({ id: forward.id, child: extraForward });
|
||||
if (forward.type === 'local' && forward.localPort) {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
if (!(await isLocalTunnelReachable(forward.localPort))) {
|
||||
@@ -1161,19 +1316,7 @@ export class ElectronSshManager {
|
||||
await this.persistLocalPort(id, localPort);
|
||||
}
|
||||
|
||||
this.sessions.set(id, {
|
||||
instance,
|
||||
parsed,
|
||||
sessionDir,
|
||||
controlPath,
|
||||
localPort,
|
||||
remotePort,
|
||||
startedByUs,
|
||||
master,
|
||||
masterDetached: false,
|
||||
mainForward,
|
||||
mainForwardDetached,
|
||||
});
|
||||
session.localPort = localPort;
|
||||
|
||||
this.clearRetryAttempt(id);
|
||||
this.setStatus(
|
||||
@@ -1206,12 +1349,26 @@ export class ElectronSshManager {
|
||||
|
||||
if (!session.mainForwardDetached) {
|
||||
if (typeof session.mainForward.exitCode === 'number') {
|
||||
if (session.mainForward.exitCode === 0) {
|
||||
if (this.usesControlMaster() && session.mainForward.exitCode === 0) {
|
||||
session.mainForwardDetached = true;
|
||||
detachedNotice = 'Main tunnel helper exited after ControlMaster handoff';
|
||||
} else {
|
||||
droppedReason = `Main SSH forward exited (${session.mainForward.exitCode})`;
|
||||
droppedReason = this.processErrorDetail(session.mainForward, `Main SSH forward exited (${session.mainForward.exitCode})`);
|
||||
}
|
||||
} else if (childProcessDiagnostics.get(session.mainForward)?.error) {
|
||||
droppedReason = this.processErrorDetail(session.mainForward, 'Main SSH forward failed');
|
||||
}
|
||||
}
|
||||
|
||||
if (!droppedReason) {
|
||||
const stoppedExtraForward = session.extraForwards.find(({ child }) => (
|
||||
typeof child.exitCode === 'number' || childProcessDiagnostics.get(child)?.error
|
||||
));
|
||||
if (stoppedExtraForward) {
|
||||
droppedReason = this.processErrorDetail(
|
||||
stoppedExtraForward.child,
|
||||
`Extra SSH forward ${stoppedExtraForward.id} exited`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1220,7 +1377,7 @@ export class ElectronSshManager {
|
||||
// Fast path: cheap TCP probe before expensive SSH subprocess
|
||||
if (await isLocalTunnelReachable(session.localPort)) {
|
||||
// Tunnel alive — skip SSH check
|
||||
} else if (!await isControlMasterAlive(session.parsed, session.controlPath)) {
|
||||
} else if (!await this.isControlMasterAlive(session.parsed, session.controlPath)) {
|
||||
droppedReason = 'SSH ControlMaster is not reachable';
|
||||
} else {
|
||||
detachedNotice = 'Local tunnel unreachable but ControlMaster is alive';
|
||||
|
||||
@@ -4,12 +4,27 @@ import fsp from 'node:fs/promises';
|
||||
import http from 'node:http';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { PassThrough } from 'node:stream';
|
||||
|
||||
import { ElectronSshManager } from './ssh-manager.mjs';
|
||||
|
||||
const servers = [];
|
||||
const tempDirs = [];
|
||||
|
||||
const createChild = () => {
|
||||
const child = new EventEmitter();
|
||||
child.stdin = new PassThrough();
|
||||
child.stdout = new PassThrough();
|
||||
child.stderr = new PassThrough();
|
||||
child.exitCode = null;
|
||||
child.kill = () => {
|
||||
child.exitCode = 0;
|
||||
return true;
|
||||
};
|
||||
return child;
|
||||
};
|
||||
|
||||
const listen = async (server) => {
|
||||
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
||||
servers.push(server);
|
||||
@@ -35,6 +50,213 @@ afterEach(async () => {
|
||||
});
|
||||
|
||||
describe('ElectronSshManager', () => {
|
||||
test('runs Windows SSH commands without ControlMaster and hides the process window', async () => {
|
||||
const calls = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
const child = createChild();
|
||||
queueMicrotask(() => {
|
||||
child.stdout.end('Linux\n');
|
||||
child.exitCode = 0;
|
||||
child.emit('close', 0);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
|
||||
await expect(manager.runRemoteCommand(parsed, 'C:\\Temp\\unused.sock', 'uname -s')).resolves.toBe('Linux\n');
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].command).toBe('ssh');
|
||||
expect(calls[0].options.windowsHide).toBe(true);
|
||||
expect(calls[0].args).toContain('ControlMaster=no');
|
||||
expect(calls[0].args).toContain('ControlPath=none');
|
||||
expect(calls[0].args).toContain('StrictHostKeyChecking=accept-new');
|
||||
expect(calls[0].args).not.toContain('ControlPath=C:\\Temp\\unused.sock');
|
||||
});
|
||||
|
||||
test('creates a PowerShell-backed askpass helper on Windows', async () => {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'openchamber-ssh-askpass-test-'));
|
||||
tempDirs.push(tempDir);
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(tempDir, 'settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
});
|
||||
|
||||
const result = await manager.writeAskpassFiles(tempDir);
|
||||
|
||||
expect(path.basename(result.askpassPath)).toBe('askpass.cmd');
|
||||
expect(result.cleanupPaths.map((filePath) => path.basename(filePath))).toEqual(['askpass.cmd', 'askpass.ps1']);
|
||||
expect(await fsp.readFile(path.join(tempDir, 'askpass.cmd'), 'utf8')).toContain('WindowsPowerShell');
|
||||
expect(await fsp.readFile(path.join(tempDir, 'askpass.ps1'), 'utf8')).toContain('OPENCHAMBER_SSH_ASKPASS_VALUE');
|
||||
});
|
||||
|
||||
test('runs each Windows port forward as an independent hidden SSH process', async () => {
|
||||
const calls = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return createChild();
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
manager.sshAuth.set(parsed, {
|
||||
askpassPath: 'C:\\OpenChamber\\askpass.cmd',
|
||||
sshPassword: 'secret-value',
|
||||
children: new Set(),
|
||||
});
|
||||
|
||||
await manager.spawnMainForward(parsed, 'C:\\Temp\\unused.sock', '127.0.0.1', 3000, 4000);
|
||||
await manager.spawnExtraForward(parsed, 'C:\\Temp\\unused.sock', {
|
||||
id: 'dynamic-1',
|
||||
type: 'dynamic',
|
||||
localHost: '127.0.0.1',
|
||||
localPort: 5000,
|
||||
});
|
||||
|
||||
expect(calls).toHaveLength(2);
|
||||
for (const call of calls) {
|
||||
expect(call.command).toBe('ssh');
|
||||
expect(call.args).toContain('ControlPath=none');
|
||||
expect(call.args).toContain('-N');
|
||||
expect(call.options.windowsHide).toBe(true);
|
||||
expect(call.options.env.SSH_ASKPASS).toBe('C:\\OpenChamber\\askpass.cmd');
|
||||
expect(call.options.env.OPENCHAMBER_SSH_ASKPASS_VALUE).toBe('secret-value');
|
||||
}
|
||||
expect(calls[0].args).toContain('-L');
|
||||
expect(calls[1].args).toContain('-D');
|
||||
});
|
||||
|
||||
test('keeps ControlMaster-backed forwarding on non-Windows platforms', async () => {
|
||||
const calls = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'darwin',
|
||||
spawn: (command, args, options) => {
|
||||
calls.push({ command, args, options });
|
||||
return createChild();
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
|
||||
await manager.spawnMainForward(parsed, '/tmp/control.sock', '127.0.0.1', 3000, 4000);
|
||||
|
||||
expect(calls).toHaveLength(1);
|
||||
expect(calls[0].args).toContain('ControlPath=/tmp/control.sock');
|
||||
expect(calls[0].args).not.toContain('ControlPath=none');
|
||||
expect(calls[0].options.windowsHide).toBeUndefined();
|
||||
});
|
||||
|
||||
test('stops in-flight commands and forwards when disconnecting Windows SSH', async () => {
|
||||
const killedChildren = [];
|
||||
const spawnedChildren = [];
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
platform: 'win32',
|
||||
spawn: () => {
|
||||
const child = createChild();
|
||||
child.kill = () => {
|
||||
killedChildren.push(child);
|
||||
child.exitCode = 1;
|
||||
child.emit('close', 1);
|
||||
return true;
|
||||
};
|
||||
spawnedChildren.push(child);
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
const mainForward = createChild();
|
||||
const extraForward = createChild();
|
||||
for (const child of [mainForward, extraForward]) {
|
||||
child.kill = () => {
|
||||
killedChildren.push(child);
|
||||
child.exitCode = 0;
|
||||
return true;
|
||||
};
|
||||
}
|
||||
manager.sshAuth.set(parsed, {
|
||||
askpassPath: 'C:\\OpenChamber\\askpass.cmd',
|
||||
sshPassword: null,
|
||||
children: new Set(),
|
||||
});
|
||||
manager.sessions.set('ssh-1', {
|
||||
instance: { remoteOpenchamber: { mode: 'external', keepRunning: true } },
|
||||
parsed,
|
||||
controlPath: 'C:\\Temp\\unused.sock',
|
||||
askpassCleanupPaths: [],
|
||||
startedByUs: false,
|
||||
remotePort: null,
|
||||
master: null,
|
||||
mainForward,
|
||||
extraForwards: [{ id: 'dynamic-1', child: extraForward }],
|
||||
});
|
||||
|
||||
let commandError = null;
|
||||
const command = manager.runRemoteCommand(parsed, 'C:\\Temp\\unused.sock', 'uname -s').catch((error) => {
|
||||
commandError = error;
|
||||
});
|
||||
await manager.disconnectInternal('ssh-1', false);
|
||||
|
||||
await command;
|
||||
expect(commandError?.message).toBe('Remote command failed');
|
||||
expect(spawnedChildren).toHaveLength(1);
|
||||
expect(new Set(killedChildren)).toEqual(new Set([spawnedChildren[0], mainForward, extraForward]));
|
||||
expect(manager.sessions.has('ssh-1')).toBe(false);
|
||||
});
|
||||
|
||||
test('reports bounded, sanitized, and redacted SSH master stderr when startup fails', async () => {
|
||||
const manager = new ElectronSshManager({
|
||||
settingsFilePath: path.join(os.tmpdir(), 'unused-settings.json'),
|
||||
appVersion: '0.0.0-test',
|
||||
emit: () => undefined,
|
||||
spawn: () => {
|
||||
const child = createChild();
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 1;
|
||||
child.emit('close', 1);
|
||||
});
|
||||
return child;
|
||||
},
|
||||
});
|
||||
const parsed = { destination: 'user@example.test', args: [] };
|
||||
const master = createChild();
|
||||
manager.sshAuth.set(parsed, {
|
||||
askpassPath: '/tmp/askpass.sh',
|
||||
sshPassword: 'secret-value',
|
||||
children: new Set(),
|
||||
});
|
||||
manager.trackSshProcess(master, parsed);
|
||||
master.stderr.write(`muxclient socket failed: secret-value\u0007${'x'.repeat(3000)}`);
|
||||
master.exitCode = 255;
|
||||
|
||||
try {
|
||||
await manager.waitForMasterReady(parsed, '/tmp/control.sock', 1, master);
|
||||
throw new Error('Expected SSH master startup to fail');
|
||||
} catch (error) {
|
||||
expect(error.message).toStartWith('muxclient socket failed: [redacted]');
|
||||
expect(error.message).not.toContain('secret-value');
|
||||
expect(error.message).not.toContain('\u0007');
|
||||
expect(error.message.length).toBeLessThanOrEqual(2000);
|
||||
}
|
||||
});
|
||||
|
||||
test('stores a client token for forwarded OpenChamber hosts when UI password is configured', async () => {
|
||||
let loginPayload = null;
|
||||
const server = http.createServer(async (req, res) => {
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
export const resolveStartupUrlProbePlan = ({ development, packagedUi, skipLocalServer }) => ({
|
||||
probeHmrApi: development === true && packagedUi !== true && skipLocalServer !== true,
|
||||
probeHmrUi: development === true && packagedUi !== true,
|
||||
});
|
||||
|
||||
export const shouldIgnoreLoopbackConnectionLimit = ({ development, packagedUi }) => (
|
||||
development !== true || packagedUi === true
|
||||
);
|
||||
@@ -0,0 +1,54 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
|
||||
import { resolveStartupUrlProbePlan, shouldIgnoreLoopbackConnectionLimit } from './startup-url-selection.mjs';
|
||||
|
||||
test('bundled development never probes HMR endpoints', () => {
|
||||
assert.deepEqual(resolveStartupUrlProbePlan({
|
||||
development: true,
|
||||
packagedUi: true,
|
||||
skipLocalServer: false,
|
||||
}), {
|
||||
probeHmrApi: false,
|
||||
probeHmrUi: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('HMR development probes both API and UI endpoints', () => {
|
||||
assert.deepEqual(resolveStartupUrlProbePlan({
|
||||
development: true,
|
||||
packagedUi: false,
|
||||
skipLocalServer: false,
|
||||
}), {
|
||||
probeHmrApi: true,
|
||||
probeHmrUi: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('serverless HMR development skips only the local API probe', () => {
|
||||
assert.deepEqual(resolveStartupUrlProbePlan({
|
||||
development: true,
|
||||
packagedUi: false,
|
||||
skipLocalServer: true,
|
||||
}), {
|
||||
probeHmrApi: false,
|
||||
probeHmrUi: true,
|
||||
});
|
||||
});
|
||||
|
||||
test('production does not probe HMR endpoints', () => {
|
||||
assert.deepEqual(resolveStartupUrlProbePlan({
|
||||
development: false,
|
||||
packagedUi: false,
|
||||
skipLocalServer: false,
|
||||
}), {
|
||||
probeHmrApi: false,
|
||||
probeHmrUi: false,
|
||||
});
|
||||
});
|
||||
|
||||
test('keeps Chromium connection limits for the Vite HMR module graph', () => {
|
||||
assert.equal(shouldIgnoreLoopbackConnectionLimit({ development: true, packagedUi: false }), false);
|
||||
assert.equal(shouldIgnoreLoopbackConnectionLimit({ development: true, packagedUi: true }), true);
|
||||
assert.equal(shouldIgnoreLoopbackConnectionLimit({ development: false, packagedUi: false }), true);
|
||||
});
|
||||
@@ -18,6 +18,10 @@
|
||||
import { Tray, Menu, nativeImage } from 'electron';
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isLinux = process.platform === 'linux';
|
||||
// Linux StatusNotifier hosts often blank or drop oversized tray images; keep
|
||||
// the icon at a panel-typical size so AppImage trays stay visible.
|
||||
const LINUX_TRAY_ICON_PX = 22;
|
||||
|
||||
const MAX_SESSIONS = 8;
|
||||
const MAX_APPROVALS = 10;
|
||||
@@ -88,8 +92,19 @@ const computeTooltip = (counts, sessionCount) => {
|
||||
const ANIM_INTERVAL_MS = 75;
|
||||
|
||||
const toTemplateImage = (p) => {
|
||||
const image = nativeImage.createFromPath(p);
|
||||
let image = nativeImage.createFromPath(p);
|
||||
if (image.isEmpty()) return image;
|
||||
if (isMac) image.setTemplateImage(true);
|
||||
if (isLinux) {
|
||||
const { width, height } = image.getSize();
|
||||
if (width > LINUX_TRAY_ICON_PX || height > LINUX_TRAY_ICON_PX) {
|
||||
image = image.resize({
|
||||
width: LINUX_TRAY_ICON_PX,
|
||||
height: LINUX_TRAY_ICON_PX,
|
||||
quality: 'best',
|
||||
});
|
||||
}
|
||||
}
|
||||
return image;
|
||||
};
|
||||
|
||||
@@ -99,6 +114,8 @@ const toTemplateImage = (p) => {
|
||||
export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconPaths, statusIconPaths, onAction }) => {
|
||||
let tray = null;
|
||||
let lastTitle = null;
|
||||
let lastTooltip = null;
|
||||
let lastMenuKey = null;
|
||||
|
||||
// macOS auto-picks the @2x file next to each path and tints the alpha.
|
||||
// Windows uses the regular app icon and ignores template tinting.
|
||||
@@ -159,7 +176,11 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
tray = new Tray(idleFrame);
|
||||
tray.setIgnoreDoubleClickEvents(true);
|
||||
if (!isMac) {
|
||||
tray.on('click', () => onAction({ type: 'show-main-window' }));
|
||||
// Windows: left-click shows. Linux: left-click toggles show/hide so the
|
||||
// panel icon stays useful when the window is already open.
|
||||
tray.on('click', () => onAction({
|
||||
type: isLinux ? 'toggle-main-window' : 'show-main-window',
|
||||
}));
|
||||
}
|
||||
return tray;
|
||||
};
|
||||
@@ -266,14 +287,45 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
{ type: 'separator' },
|
||||
{ label: 'New Session', click: () => onAction({ type: 'new-session' }) },
|
||||
{ label: 'New Mini Chat', click: () => onAction({ type: 'new-mini-chat' }) },
|
||||
{ label: 'Show OpenChamber', click: () => onAction({ type: 'show-main-window' }) },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit OpenChamber', click: () => onAction({ type: 'quit' }) },
|
||||
);
|
||||
|
||||
if (isLinux || process.platform === 'win32') {
|
||||
// Right-click context menu: show / hide / close (quit). Matches the
|
||||
// expected AppImage / Windows tray controls.
|
||||
template.push(
|
||||
{ type: 'separator' },
|
||||
{ label: 'Show Window', click: () => onAction({ type: 'show-main-window' }) },
|
||||
{ label: 'Hide Window', click: () => onAction({ type: 'hide-main-window' }) },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Close', click: () => onAction({ type: 'quit' }) },
|
||||
);
|
||||
} else {
|
||||
template.push(
|
||||
{ label: 'Show OpenChamber', click: () => onAction({ type: 'show-main-window' }) },
|
||||
{ type: 'separator' },
|
||||
{ label: 'Quit OpenChamber', click: () => onAction({ type: 'quit' }) },
|
||||
);
|
||||
}
|
||||
|
||||
return Menu.buildFromTemplate(template);
|
||||
};
|
||||
|
||||
// Lightweight signature of the menu-affecting content — skips nativeImage
|
||||
// and click handlers that can't be serialized. Cheaper than buildMenu itself.
|
||||
const menuKey = (snapshot) => {
|
||||
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
||||
const approvals = Array.isArray(snapshot.approvals) ? snapshot.approvals : [];
|
||||
const usage = snapshot.usage && typeof snapshot.usage === 'object' ? snapshot.usage : {};
|
||||
const groups = Array.isArray(usage.groups) ? usage.groups : [];
|
||||
return JSON.stringify({
|
||||
h: typeof snapshot.instanceName === 'string' ? snapshot.instanceName : '',
|
||||
s: sessions.map((s) => `${s.id}|${s.title}|${s.status}|${s.unseen}|${s.hasError}|${s.subtitle}|${s.directory}`),
|
||||
a: approvals.map((a) => `${a.id}|${a.kind}|${a.sessionId}|${a.sessionTitle}|${a.label}|${a.directory}`),
|
||||
u: usage.mode || '',
|
||||
g: groups.map((g) => `${g.provider}|${g.status}|${(Array.isArray(g.rows) ? g.rows : []).map((r) => `${r.label}|${r.value}`).join(',')}`),
|
||||
});
|
||||
};
|
||||
|
||||
const update = (rawSnapshot) => {
|
||||
const snapshot = rawSnapshot && typeof rawSnapshot === 'object' ? rawSnapshot : {};
|
||||
const sessions = Array.isArray(snapshot.sessions) ? snapshot.sessions : [];
|
||||
@@ -293,8 +345,16 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
lastTitle = title;
|
||||
}
|
||||
applyIconState(computeIconState(counts));
|
||||
widget.setToolTip(computeTooltip(counts, sessions.length));
|
||||
widget.setContextMenu(buildMenu(snapshot));
|
||||
const tooltip = computeTooltip(counts, sessions.length);
|
||||
if (tooltip !== lastTooltip) {
|
||||
widget.setToolTip(tooltip);
|
||||
lastTooltip = tooltip;
|
||||
}
|
||||
const key = menuKey(snapshot);
|
||||
if (key !== lastMenuKey) {
|
||||
widget.setContextMenu(buildMenu(snapshot));
|
||||
lastMenuKey = key;
|
||||
}
|
||||
};
|
||||
|
||||
const destroy = () => {
|
||||
@@ -304,6 +364,8 @@ export const createTrayController = ({ idleIconPath, unseenIconPath, breathIconP
|
||||
}
|
||||
tray = null;
|
||||
lastTitle = null;
|
||||
lastTooltip = null;
|
||||
lastMenuKey = null;
|
||||
iconState = null;
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
export const resolveUpdaterChannel = ({ platform, architecture }) => (
|
||||
platform === 'win32' && architecture === 'arm64' ? 'latest-arm64' : null
|
||||
);
|
||||
@@ -0,0 +1,13 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import test from 'node:test';
|
||||
import { resolveUpdaterChannel } from './updater-channel.mjs';
|
||||
|
||||
test('uses an architecture-specific Windows ARM64 update channel', () => {
|
||||
assert.equal(resolveUpdaterChannel({ platform: 'win32', architecture: 'arm64' }), 'latest-arm64');
|
||||
});
|
||||
|
||||
test('keeps the default channel for other desktop targets', () => {
|
||||
assert.equal(resolveUpdaterChannel({ platform: 'win32', architecture: 'x64' }), null);
|
||||
assert.equal(resolveUpdaterChannel({ platform: 'darwin', architecture: 'arm64' }), null);
|
||||
assert.equal(resolveUpdaterChannel({ platform: 'linux', architecture: 'arm64' }), null);
|
||||
});
|
||||
@@ -81,9 +81,10 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s
|
||||
- **Connection onboarding** — server URL entry, password unlock for locked servers, client-token
|
||||
issuance, saved connections, `Instances` management sheet, auto-connect to the last instance on
|
||||
launch. Deleting the active instance resets the runtime to the connect screen.
|
||||
- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android's Google code scanner module is
|
||||
downloaded on first scan (needs Play Services + network); `mobileQrScan.ts` installs/awaits it
|
||||
and retries. CAMERA permission + `NSCameraUsageDescription` declared.
|
||||
- **QR pairing** — `@capacitor-mlkit/barcode-scanning`. Android uses the CameraX-backed
|
||||
`startScan()` flow with the barcode model bundled in the app, so scanning works offline and
|
||||
without Google Play Services. iOS uses the plugin's native scanner. CAMERA permission +
|
||||
`NSCameraUsageDescription` declared.
|
||||
- **Secure storage** — `@aparajita/capacitor-secure-storage` for connection tokens.
|
||||
- **Deep links** — `openchamber://` URL scheme; a reusable intent vocabulary (`apps/deepLinks.ts`)
|
||||
used by notification taps, widgets, and Control Center. Cold-launch intents are stashed.
|
||||
@@ -128,8 +129,7 @@ iOS Simulator helpers: `mobile:sim:{boot,install,launch,run,serve,list,kill}` (s
|
||||
brings `firebase-messaging`.
|
||||
- Manifest: permissions `INTERNET`, `CAMERA` (+ optional camera feature), `POST_NOTIFICATIONS`
|
||||
(Android 13+; older versions allow notifications by default). `windowSoftInputMode=adjustResize`.
|
||||
ML Kit `com.google.mlkit.vision.DEPENDENCIES=barcode_ui` meta (preloads the code scanner). FCM
|
||||
`default_notification_icon=@drawable/ic_stat_notify`.
|
||||
FCM `default_notification_icon=@drawable/ic_stat_notify`.
|
||||
- Adaptive launcher icon: full-bleed color background + `ic_launcher_foreground` (sources under
|
||||
`packages/mobile/assets/`, regenerable with `@capacitor/assets`).
|
||||
|
||||
|
||||
@@ -8,21 +8,26 @@ The mobile package reuses the web build, then rewrites `mobile.html` to `index.h
|
||||
|
||||
- The native app bundles the mobile UI only; it does not embed the OpenChamber web server or OpenCode server.
|
||||
- On first launch in Capacitor, the app shows a connection screen for an existing OpenChamber server.
|
||||
- Connections are saved locally in the app and can be managed from the mobile overflow menu under `Instances`.
|
||||
- The connection screen and `Instances` menu item are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior.
|
||||
- Connections are saved locally in the app and can be managed from `Instances` in the sessions drawer footer (a persistent left sidebar on tablets).
|
||||
- The connection screen and the `Instances` entry are Capacitor-only. Hosted `mobile.html` in a normal browser keeps the regular web behavior.
|
||||
- Phones and tablets share one navigation model: a sessions drawer/sidebar on the left, the workspace drawer (Changes / Files / Terminal / Notes / MCP) on the right, and no overflow menu. Tablets differ only in that the sessions list is a resizable persistent sidebar and the header dropdowns are anchored popovers.
|
||||
- The tablet layout is a live size class (`useTabletLayout`), not a device check: any surface whose short side is at least 600px gets it, and the workspace only becomes a side panel where the width can host the sidebar, the panel and a readable chat at once. Book foldables therefore pick it up when unfolded, keep the portrait layout in both orientations (their long side is barely wider than a tablet's short one), and drop back to the phone layout when folded shut. The Android activity declares the matching `configChanges`, so folding resizes the WebView instead of recreating it.
|
||||
- Password-protected OpenChamber servers can be unlocked from the mobile app. The app stores the issued client token with the saved connection.
|
||||
- The Terminal workspace surface runs its PTY on the active OpenChamber server over the shared authenticated runtime transport; it never opens a local shell on the phone or tablet. Closing the surface detaches the renderer while the server session remains available for reattachment. On touch devices, dragging scrolls the buffer while long-pressing and dragging selects terminal text.
|
||||
|
||||
## Commands
|
||||
|
||||
Run these from `packages/mobile`, or use the root `mobile:*` aliases.
|
||||
|
||||
- `bun run build`: builds `packages/web` and prepares mobile web assets.
|
||||
- `bun run build:assets`: prepares mobile assets from an existing `packages/web/dist` build; the root workspace build uses this to avoid rebuilding web.
|
||||
- `bun run sync`: prepares assets and runs `cap sync`.
|
||||
- `bun run add:ios`: creates the native iOS project.
|
||||
- `bun run add:android`: creates the native Android project.
|
||||
- `bun run build:android:debug`: builds a debug Android APK without launching an emulator.
|
||||
- `bun run build:ios:simulator`: builds an iOS Simulator app without launching Xcode or Simulator.
|
||||
- `bun run sim:run`: boots a simulator if needed, installs the built iOS app, and launches it.
|
||||
- `bun run sim:dev`: one-command dev loop — builds the simulator app, installs + launches it, starts the `serve-sim` stream, and prints the preview URL; Ctrl+C stops the stream. Pass `--no-build` to skip the build step.
|
||||
- `bun run sim:serve`: starts `serve-sim` in detached JSON mode and prints the browser preview URL.
|
||||
- `bun run sim:list`: lists running `serve-sim` streams.
|
||||
- `bun run sim:kill`: stops running `serve-sim` streams.
|
||||
|
||||
@@ -35,13 +35,6 @@
|
||||
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- Tell Play Services to download the ML Kit barcode "code scanner" module (used by
|
||||
@capacitor-mlkit/barcode-scanning's scan()) at install time, so the QR pairing scan
|
||||
works without a per-scan module download. -->
|
||||
<meta-data
|
||||
android:name="com.google.mlkit.vision.DEPENDENCIES"
|
||||
android:value="barcode_ui" />
|
||||
|
||||
<!-- Small icon shown in the status bar / notification shade for FCM notifications the
|
||||
system displays while the app is backgrounded. Must be a monochrome silhouette. -->
|
||||
<meta-data
|
||||
|
||||
Vendored
+94
-94
@@ -1,94 +1,94 @@
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
@rem
|
||||
@rem Copyright 2015 the original author or authors.
|
||||
@rem
|
||||
@rem Licensed under the Apache License, Version 2.0 (the "License");
|
||||
@rem you may not use this file except in compliance with the License.
|
||||
@rem You may obtain a copy of the License at
|
||||
@rem
|
||||
@rem https://www.apache.org/licenses/LICENSE-2.0
|
||||
@rem
|
||||
@rem Unless required by applicable law or agreed to in writing, software
|
||||
@rem distributed under the License is distributed on an "AS IS" BASIS,
|
||||
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
@rem See the License for the specific language governing permissions and
|
||||
@rem limitations under the License.
|
||||
@rem
|
||||
@rem SPDX-License-Identifier: Apache-2.0
|
||||
@rem
|
||||
|
||||
@if "%DEBUG%"=="" @echo off
|
||||
@rem ##########################################################################
|
||||
@rem
|
||||
@rem Gradle startup script for Windows
|
||||
@rem
|
||||
@rem ##########################################################################
|
||||
|
||||
@rem Set local scope for the variables with windows NT shell
|
||||
if "%OS%"=="Windows_NT" setlocal
|
||||
|
||||
set DIRNAME=%~dp0
|
||||
if "%DIRNAME%"=="" set DIRNAME=.
|
||||
@rem This is normally unused
|
||||
set APP_BASE_NAME=%~n0
|
||||
set APP_HOME=%DIRNAME%
|
||||
|
||||
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
|
||||
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
|
||||
|
||||
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
|
||||
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
|
||||
|
||||
@rem Find java.exe
|
||||
if defined JAVA_HOME goto findJavaFromJavaHome
|
||||
|
||||
set JAVA_EXE=java.exe
|
||||
%JAVA_EXE% -version >NUL 2>&1
|
||||
if %ERRORLEVEL% equ 0 goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:findJavaFromJavaHome
|
||||
set JAVA_HOME=%JAVA_HOME:"=%
|
||||
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
|
||||
|
||||
if exist "%JAVA_EXE%" goto execute
|
||||
|
||||
echo. 1>&2
|
||||
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
|
||||
echo. 1>&2
|
||||
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
|
||||
echo location of your Java installation. 1>&2
|
||||
|
||||
goto fail
|
||||
|
||||
:execute
|
||||
@rem Setup the command line
|
||||
|
||||
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
|
||||
|
||||
|
||||
@rem Execute Gradle
|
||||
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
|
||||
|
||||
:end
|
||||
@rem End local scope for the variables with windows NT shell
|
||||
if %ERRORLEVEL% equ 0 goto mainEnd
|
||||
|
||||
:fail
|
||||
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
|
||||
rem the _cmd.exe /c_ return code!
|
||||
set EXIT_CODE=%ERRORLEVEL%
|
||||
if %EXIT_CODE% equ 0 set EXIT_CODE=1
|
||||
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
|
||||
exit /b %EXIT_CODE%
|
||||
|
||||
:mainEnd
|
||||
if "%OS%"=="Windows_NT" endlocal
|
||||
|
||||
:omega
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import UIKit
|
||||
import Capacitor
|
||||
import GameController
|
||||
import UserNotifications
|
||||
import WebKit
|
||||
import WidgetKit
|
||||
|
||||
@UIApplicationMain
|
||||
@@ -60,6 +62,111 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
|
||||
|
||||
}
|
||||
|
||||
/// APNs environment of this build: "development" for Xcode/dev-signed installs,
|
||||
/// "production" for TestFlight/App Store. Read from the embedded provisioning profile's
|
||||
/// aps-environment entitlement; App Store builds carry no embedded profile and are
|
||||
/// production. Exposed to the web layer so the server can deliver each device token to
|
||||
/// the APNs endpoint that actually knows it (sandbox vs production).
|
||||
let apnsEnvironment: String = {
|
||||
guard let path = Bundle.main.path(forResource: "embedded", ofType: "mobileprovision"),
|
||||
let data = FileManager.default.contents(atPath: path),
|
||||
// isoLatin1, not ascii/utf8: the profile is a binary CMS envelope around the XML
|
||||
// plist, and only Latin-1 decodes arbitrary bytes without returning nil.
|
||||
let profile = String(data: data, encoding: .isoLatin1) else {
|
||||
return "production"
|
||||
}
|
||||
let pattern = "<key>aps-environment</key>\\s*<string>development</string>"
|
||||
return profile.range(of: pattern, options: .regularExpression) != nil ? "development" : "production"
|
||||
}()
|
||||
|
||||
/// Bridge subclass (referenced from Main.storyboard) whose job is to expose native-only
|
||||
/// facts to the web layer as document-start user scripts. These run before any page JS,
|
||||
/// so consumers always see them — injecting later from the scene lifecycle raced the
|
||||
/// consumer (push registration) and lost on first launch.
|
||||
///
|
||||
/// The scripts must be added in capacitorDidLoad(), NOT webViewConfiguration(for:): Capacitor's
|
||||
/// prepareWebView replaces the configuration's userContentController with its own right after
|
||||
/// calling webViewConfiguration(for:), which silently discards any user script added there.
|
||||
/// capacitorDidLoad() runs after that swap but before loadWebView() starts the initial page load.
|
||||
class BridgeViewController: CAPBridgeViewController {
|
||||
private var keyboardObservers: [NSObjectProtocol] = []
|
||||
|
||||
override func capacitorDidLoad() {
|
||||
super.capacitorDidLoad()
|
||||
// GCKeyboard is the only authoritative answer to "is a hardware keyboard
|
||||
// attached?". The web layer can otherwise only INFER it from a keyboard
|
||||
// that never appears, which costs the user one focus before the layout
|
||||
// settles — so the state is stamped at document start and kept live.
|
||||
//
|
||||
// At this point GameController has usually NOT finished discovery yet, so
|
||||
// an already-attached keyboard still reads as nil here. The stamp is only
|
||||
// the optimistic first answer; refreshHardwareKeyboardState() below is
|
||||
// what actually settles it once the page exists.
|
||||
let attached = GCKeyboard.coalesced != nil
|
||||
let source = """
|
||||
window.__OPENCHAMBER_APNS_ENV__ = '\(apnsEnvironment)';
|
||||
window.__OPENCHAMBER_HARDWARE_KEYBOARD__ = \(attached ? "true" : "false");
|
||||
"""
|
||||
webView?.configuration.userContentController.addUserScript(
|
||||
WKUserScript(source: source, injectionTime: .atDocumentStart, forMainFrameOnly: true)
|
||||
)
|
||||
observeHardwareKeyboard()
|
||||
}
|
||||
|
||||
override func viewDidAppear(_ animated: Bool) {
|
||||
super.viewDidAppear(animated)
|
||||
// Two races make a single early publish unreliable for a keyboard that was
|
||||
// ALREADY attached at launch, which is why it only ever worked when the
|
||||
// user plugged one in afterwards:
|
||||
// - GCKeyboardDidConnect for a pre-attached keyboard fires during launch,
|
||||
// before the web page exists, so its evaluateJavaScript lands in a
|
||||
// context the page load then throws away;
|
||||
// - GameController can populate `coalesced` a beat after launch anyway.
|
||||
// Re-publishing across the first seconds covers both; the web side adopts
|
||||
// idempotently, so repeats are free.
|
||||
refreshHardwareKeyboardState()
|
||||
for delay in [0.3, 1.0, 2.5] {
|
||||
DispatchQueue.main.asyncAfter(deadline: .now() + delay) { [weak self] in
|
||||
self?.refreshHardwareKeyboardState()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Re-read GameController and push the current answer to the web layer.
|
||||
/// Also called when the app returns to the foreground — a keyboard can be
|
||||
/// attached or detached while backgrounded, with no notification delivered.
|
||||
func refreshHardwareKeyboardState() {
|
||||
publishHardwareKeyboardState(GCKeyboard.coalesced != nil)
|
||||
}
|
||||
|
||||
private func observeHardwareKeyboard() {
|
||||
let center = NotificationCenter.default
|
||||
keyboardObservers = [
|
||||
center.addObserver(forName: .GCKeyboardDidConnect, object: nil, queue: .main) { [weak self] _ in
|
||||
self?.publishHardwareKeyboardState(true)
|
||||
},
|
||||
center.addObserver(forName: .GCKeyboardDidDisconnect, object: nil, queue: .main) { [weak self] _ in
|
||||
// A second keyboard may still be attached (Stage Manager, dock swaps).
|
||||
self?.publishHardwareKeyboardState(GCKeyboard.coalesced != nil)
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
private func publishHardwareKeyboardState(_ attached: Bool) {
|
||||
let value = attached ? "true" : "false"
|
||||
webView?.evaluateJavaScript("""
|
||||
window.__OPENCHAMBER_HARDWARE_KEYBOARD__ = \(value);
|
||||
window.dispatchEvent(new CustomEvent('oc:hardware-keyboard', { detail: { attached: \(value) } }));
|
||||
""")
|
||||
}
|
||||
|
||||
deinit {
|
||||
for observer in keyboardObservers {
|
||||
NotificationCenter.default.removeObserver(observer)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// iOS 26 (TN3187) requires apps built with the latest SDK to adopt the UIScene
|
||||
// lifecycle. Capacitor 7's template still uses the legacy window setup, so we host a
|
||||
// minimal scene delegate here that loads the Main storyboard (CAPBridgeViewController)
|
||||
@@ -100,6 +207,10 @@ class SceneDelegate: UIResponder, UIWindowSceneDelegate {
|
||||
UIApplication.shared.applicationIconBadgeNumber = 0
|
||||
}
|
||||
|
||||
// A keyboard can be attached or detached while the app is backgrounded,
|
||||
// with no GameController notification delivered to it.
|
||||
(window?.rootViewController as? BridgeViewController)?.refreshHardwareKeyboardState()
|
||||
|
||||
// Refresh the widgets' session overview now that the WebView is loaded and state is fresh.
|
||||
writeWidgetSnapshot()
|
||||
}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<!--Bridge View Controller-->
|
||||
<scene sceneID="tne-QT-ifu">
|
||||
<objects>
|
||||
<viewController id="BYZ-38-t0r" customClass="CAPBridgeViewController" customModule="Capacitor" sceneMemberID="viewController"/>
|
||||
<viewController id="BYZ-38-t0r" customClass="BridgeViewController" customModule="App" customModuleProvider="target" sceneMemberID="viewController"/>
|
||||
<placeholder placeholderIdentifier="IBFirstResponder" id="dkx-z0-nzr" sceneMemberID="firstResponder"/>
|
||||
</objects>
|
||||
</scene>
|
||||
|
||||
@@ -37,6 +37,7 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
guard let defaults = UserDefaults(suiteName: Self.appGroup) else { return }
|
||||
|
||||
var snapshot: [String: Any] = [
|
||||
"runtimeKey": request.content.userInfo["runtimeKey"] as? String ?? "",
|
||||
"attentionCount": 0,
|
||||
"recentSessions": [],
|
||||
]
|
||||
@@ -46,6 +47,16 @@ class NotificationService: UNNotificationServiceExtension {
|
||||
snapshot = stored
|
||||
}
|
||||
|
||||
if let pushRuntimeKey = request.content.userInfo["runtimeKey"] as? String,
|
||||
!pushRuntimeKey.isEmpty,
|
||||
snapshot["runtimeKey"] as? String != pushRuntimeKey {
|
||||
snapshot = [
|
||||
"runtimeKey": pushRuntimeKey,
|
||||
"attentionCount": 0,
|
||||
"recentSessions": [],
|
||||
]
|
||||
}
|
||||
|
||||
// Attention count: authoritative server value carried in aps.badge.
|
||||
if let badge = request.content.badge as? Int {
|
||||
snapshot["attentionCount"] = badge
|
||||
|
||||
@@ -61,7 +61,7 @@ struct OverviewWidgetView: View {
|
||||
VStack(spacing: 16) {
|
||||
HStack(spacing: 16) {
|
||||
actionButton(systemImage: "plus", url: WidgetDeepLink.newSession())
|
||||
actionButton(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status())
|
||||
actionButton(systemImage: "list.bullet", url: WidgetDeepLink.status())
|
||||
}
|
||||
HStack(spacing: 16) {
|
||||
actionButton(systemImage: "server.rack", url: WidgetDeepLink.instances())
|
||||
@@ -120,7 +120,7 @@ struct QuickActionsWidgetView: View {
|
||||
|
||||
// Two round secondary actions.
|
||||
HStack(spacing: 10) {
|
||||
quickCircle(systemImage: "square.stack.3d.up", url: WidgetDeepLink.status())
|
||||
quickCircle(systemImage: "list.bullet", url: WidgetDeepLink.status())
|
||||
quickCircle(systemImage: "server.rack", url: WidgetDeepLink.instances())
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
|
||||
@@ -17,10 +17,11 @@ struct WidgetSession: Codable, Identifiable, Hashable {
|
||||
/// The session overview snapshot. Mirrors MobileWidgetSnapshot (same field names) so the
|
||||
/// JSON the app stores decodes directly.
|
||||
struct WidgetSnapshot: Codable {
|
||||
var runtimeKey: String?
|
||||
let attentionCount: Int
|
||||
let recentSessions: [WidgetSession]
|
||||
|
||||
static let empty = WidgetSnapshot(attentionCount: 0, recentSessions: [])
|
||||
static let empty = WidgetSnapshot(runtimeKey: nil, attentionCount: 0, recentSessions: [])
|
||||
}
|
||||
|
||||
enum WidgetStore {
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"build": "bun run --cwd ../web build && node scripts/prepare-web-assets.mjs",
|
||||
"build": "bun run --cwd ../web build && bun run build:assets",
|
||||
"build:assets": "node scripts/prepare-web-assets.mjs",
|
||||
"sync": "node scripts/with-mobile-env.mjs \"bun run build && cap sync\"",
|
||||
"add:ios": "cap add ios",
|
||||
"add:android": "cap add android",
|
||||
@@ -19,6 +20,7 @@
|
||||
"sim:install": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs install\"",
|
||||
"sim:launch": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs launch\"",
|
||||
"sim:run": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim.mjs run\"",
|
||||
"sim:dev": "node scripts/with-mobile-env.mjs \"node scripts/ios-sim-dev.mjs\"",
|
||||
"sim:serve": "node scripts/with-mobile-env.mjs \"serve-sim --detach -q\"",
|
||||
"sim:list": "node scripts/with-mobile-env.mjs \"serve-sim --list -q\"",
|
||||
"sim:kill": "node scripts/with-mobile-env.mjs \"serve-sim --kill\"",
|
||||
@@ -41,7 +43,7 @@
|
||||
"@capacitor/cli": "^8.4.1",
|
||||
"@capacitor/ios": "^8.4.1",
|
||||
"@types/node": "^24.3.1",
|
||||
"serve-sim": "^0.1.34",
|
||||
"serve-sim": "^0.1.45",
|
||||
"typescript": "~5.9.0"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// One-command simulator dev loop: build the simulator app, install + launch it,
|
||||
// start the serve-sim browser stream, and print the preview URL.
|
||||
// The process then stays in the foreground so Ctrl+C stops the stream helpers.
|
||||
//
|
||||
// Pass --no-build to skip the (slow) web + xcodebuild step and just relaunch the
|
||||
// previously built app with a fresh stream.
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const mobileRoot = join(dirname(fileURLToPath(import.meta.url)), '..');
|
||||
// serve-sim resolves from the package bin dir; plain `serve-sim` is not on PATH
|
||||
// when this script is invoked outside `bun run`.
|
||||
const env = {
|
||||
...process.env,
|
||||
PATH: `${join(mobileRoot, 'node_modules', '.bin')}:${process.env.PATH || ''}`,
|
||||
};
|
||||
|
||||
const run = (command, args, options = {}) => {
|
||||
const result = spawnSync(command, args, {
|
||||
cwd: mobileRoot,
|
||||
env,
|
||||
encoding: 'utf8',
|
||||
stdio: options.capture ? ['ignore', 'pipe', 'inherit'] : 'inherit',
|
||||
});
|
||||
if (result.status !== 0) {
|
||||
console.error(`[ios-sim-dev] ${command} ${args.join(' ')} exited with ${result.status ?? result.signal}`);
|
||||
process.exit(result.status ?? 1);
|
||||
}
|
||||
return result.stdout?.trim() ?? '';
|
||||
};
|
||||
|
||||
if (!process.argv.includes('--no-build')) {
|
||||
run('node', ['scripts/ios-sim-build.mjs']);
|
||||
}
|
||||
|
||||
run('node', ['scripts/ios-sim.mjs', 'run']);
|
||||
|
||||
const serveOutput = run('serve-sim', ['--detach', '-q'], { capture: true });
|
||||
let url;
|
||||
try {
|
||||
url = JSON.parse(serveOutput).url;
|
||||
} catch {
|
||||
// fall through to the guard below
|
||||
}
|
||||
if (!url) {
|
||||
console.error(`[ios-sim-dev] Unexpected serve-sim output: ${serveOutput}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(`[ios-sim-dev] Stream ready at ${url}`);
|
||||
console.log('[ios-sim-dev] Press Ctrl+C to stop the stream (the simulator stays running).');
|
||||
|
||||
const stop = () => {
|
||||
spawnSync('serve-sim', ['--kill'], { cwd: mobileRoot, env, stdio: 'inherit' });
|
||||
process.exit(0);
|
||||
};
|
||||
process.on('SIGINT', stop);
|
||||
process.on('SIGTERM', stop);
|
||||
// Keep the foreground process alive until a signal arrives.
|
||||
setInterval(() => {}, 1 << 30);
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@openchamber/ui",
|
||||
"version": "1.16.1",
|
||||
"version": "1.17.2",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/main.tsx",
|
||||
@@ -43,7 +43,7 @@
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"@opencode-ai/sdk": "1.17.18",
|
||||
"@opencode-ai/sdk": "1.18.11",
|
||||
"@pierre/diffs": "1.3.0-beta.6",
|
||||
"@replit/codemirror-vim": "^6.3.0",
|
||||
"@simplewebauthn/browser": "13.3.0",
|
||||
@@ -58,6 +58,7 @@
|
||||
"cron-parser": "^5.5.0",
|
||||
"dompurify": "^3.2.7",
|
||||
"express": "^5.1.0",
|
||||
"fflate": "^0.8.3",
|
||||
"fuse.js": "^7.1.0",
|
||||
"ghostty-web": "^0.4.0",
|
||||
"heic2any": "^0.0.4",
|
||||
@@ -76,7 +77,7 @@
|
||||
"remark-math": "^6.0.0",
|
||||
"remend": "^1.2.1",
|
||||
"shiki": "^3.23.0",
|
||||
"simple-git": "^3.28.0",
|
||||
"simple-git": "^3.36.0",
|
||||
"sonner": "^2.0.7",
|
||||
"strip-json-comments": "^5.0.3",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
|
||||
@@ -833,10 +833,11 @@ function App({ apis }: AppProps) {
|
||||
if (bootView.screen === 'chooser') {
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<OnboardingScreen
|
||||
mode="first-launch"
|
||||
localAvailable={bootView.localAvailable !== false}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
onChooseRemote={() => {
|
||||
// Switch to remote tab - handled internally by OnboardingScreen
|
||||
@@ -854,13 +855,14 @@ function App({ apis }: AppProps) {
|
||||
|
||||
return (
|
||||
<ErrorBoundary>
|
||||
<div className="h-full text-foreground bg-transparent">
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<React.Suspense fallback={<div className="h-full" />}>
|
||||
<OnboardingScreen
|
||||
mode="recovery"
|
||||
recoveryVariant={recoveryVariant}
|
||||
recoveryHostUrl={hostUrl}
|
||||
recoveryHostLabel={undefined}
|
||||
localAvailable={bootView.localAvailable !== false}
|
||||
onCliAvailable={handleDesktopBootDismiss}
|
||||
/>
|
||||
</React.Suspense>
|
||||
|
||||
@@ -19,7 +19,11 @@ import { useSync } from '@/sync/use-sync';
|
||||
import { SyncRuntimeEffects } from './AppEffects';
|
||||
import { useAppFontEffects } from './useAppFontEffects';
|
||||
import { useMiniChatKeyboardShortcuts } from '@/hooks/useMiniChatKeyboardShortcuts';
|
||||
import { listProjectWorktrees, worktreeMapsEqual } from '@/lib/worktrees/worktreeManager';
|
||||
import {
|
||||
listProjectWorktrees,
|
||||
partitionWorktreesByRegisteredProject,
|
||||
worktreeMapsEqual,
|
||||
} from '@/lib/worktrees/worktreeManager';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
const MINI_CHAT_PRESENCE_CHANNEL = 'openchamber:mini-chat-presence';
|
||||
@@ -175,7 +179,6 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
|
||||
const discoverWorktrees = async () => {
|
||||
const worktreesByProject = new Map<string, WorktreeMetadata[]>();
|
||||
const allWorktrees: WorktreeMetadata[] = [];
|
||||
|
||||
await Promise.all(projects.map(async (project) => {
|
||||
const projectPath = project.path.replace(/\\/g, '/').replace(/\/+$/, '');
|
||||
@@ -187,7 +190,6 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
const worktrees = await listProjectWorktrees({ id: project.id, path: projectPath });
|
||||
if (cancelled || worktrees.length === 0) return;
|
||||
worktreesByProject.set(projectPath, worktrees);
|
||||
allWorktrees.push(...worktrees);
|
||||
} catch {
|
||||
// Worktree discovery is best-effort; draft selector falls back to the project root.
|
||||
}
|
||||
@@ -195,12 +197,14 @@ const MiniChatBootstrap: React.FC<{ config: MiniChatConfig }> = ({ config }) =>
|
||||
|
||||
if (cancelled) return;
|
||||
|
||||
const partitionedWorktreesByProject = partitionWorktreesByRegisteredProject(projects, worktreesByProject);
|
||||
|
||||
// Skip update if nothing changed — see worktreeMapsEqual JSDoc.
|
||||
const currentByProject = useSessionUIStore.getState().availableWorktreesByProject;
|
||||
if (!worktreeMapsEqual(worktreesByProject, currentByProject)) {
|
||||
if (!worktreeMapsEqual(partitionedWorktreesByProject, currentByProject)) {
|
||||
useSessionUIStore.setState({
|
||||
availableWorktrees: allWorktrees,
|
||||
availableWorktreesByProject: worktreesByProject,
|
||||
availableWorktrees: [...partitionedWorktreesByProject.values()].flat(),
|
||||
availableWorktreesByProject: partitionedWorktreesByProject,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import React from 'react';
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const IpadSidebarResizeHandle: React.FC<{
|
||||
side: 'left' | 'right';
|
||||
isResizing: boolean;
|
||||
ariaLabel: string;
|
||||
handleProps: React.HTMLAttributes<HTMLDivElement>;
|
||||
}> = ({ side, isResizing, ariaLabel, handleProps }) => (
|
||||
<div
|
||||
// z-50 AND rendered after the panel content: panes bring their own
|
||||
// full-cover overlays at z-50 (the file editor, for one), and a handle
|
||||
// underneath them is simply not there for the finger.
|
||||
className={cn(
|
||||
'absolute inset-y-0 z-50 w-6 cursor-col-resize touch-none',
|
||||
side === 'left' ? 'right-0' : 'left-0',
|
||||
)}
|
||||
role="separator"
|
||||
aria-orientation="vertical"
|
||||
aria-label={ariaLabel}
|
||||
{...handleProps}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'absolute inset-y-0 w-[3px] transition-colors',
|
||||
side === 'left' ? 'right-0' : 'left-0',
|
||||
isResizing && 'bg-[var(--interactive-border)]',
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
+497
-2397
File diff suppressed because it is too large
Load Diff
@@ -21,6 +21,7 @@ import {
|
||||
useIsGitRepo,
|
||||
useGitLoadingStatus,
|
||||
} from '@/stores/useGitStore';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
type SyncAction = 'fetch' | 'pull' | 'push' | 'sync' | null;
|
||||
type CommitAction = 'commit' | 'commitAndPush' | null;
|
||||
@@ -41,7 +42,7 @@ const isUnstagedStatusFile = (file: GitStatus['files'][number]): boolean => {
|
||||
const diffCacheKey = (path: string, staged: boolean): string => staged ? `${path}\u0000staged` : path;
|
||||
|
||||
type MobileChangesSurfaceProps = {
|
||||
/** When provided, the list header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
/** When provided, the list header gets a close X that calls this. */
|
||||
onClose?: () => void;
|
||||
/**
|
||||
* When set (and non-null), the surface opens directly into the per-file diff view for this
|
||||
@@ -202,6 +203,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
const runtimeKey = getRuntimeKey();
|
||||
setDiffLoadError(null);
|
||||
void git.getGitFileDiff(currentDirectory, { path: route.path, staged: route.staged || undefined })
|
||||
.then((response) => {
|
||||
@@ -210,7 +212,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
original: response.original ?? '',
|
||||
modified: response.modified ?? '',
|
||||
isBinary: response.isBinary,
|
||||
});
|
||||
}, runtimeKey);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (cancelled) return;
|
||||
@@ -539,7 +541,7 @@ export const MobileChangesSurface: React.FC<MobileChangesSurfaceProps> = ({ onCl
|
||||
onVisiblePathsChange={setVisibleChangePaths}
|
||||
/>
|
||||
</div>
|
||||
<div className="shrink-0 border-t border-border/50 px-4 pb-4 pt-3">
|
||||
<div className="shrink-0 border-t border-border/70 px-4 pb-4 pt-3">
|
||||
<CommitSection
|
||||
stagedCount={stagedChangeEntries.length}
|
||||
commitMessage={commitMessage}
|
||||
@@ -594,7 +596,7 @@ const MobileDiffDetail: React.FC<{
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/70 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
|
||||
@@ -0,0 +1,322 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { connectionDisplayUrl, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||
|
||||
export type MobileConnectionNotice = {
|
||||
kind: 'unreachable' | 'auth-expired';
|
||||
label: string;
|
||||
};
|
||||
|
||||
export const MobileConnectionWelcome: React.FC<{
|
||||
onConnected: () => void;
|
||||
/** Why the user landed here (failed cold-launch auto-connect) — shown as a banner. */
|
||||
notice?: MobileConnectionNotice | null;
|
||||
}> = ({ onConnected, notice = null }) => {
|
||||
const { t } = useI18n();
|
||||
const conn = useMobileConnection(onConnected);
|
||||
const { connections, isBusy, isPasswordBusy, error, pendingConnection } = conn;
|
||||
const [serverUrl, setServerUrl] = React.useState('');
|
||||
const [connectionName, setConnectionName] = React.useState('');
|
||||
const [clientToken, setClientToken] = React.useState('');
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||
// QR pairing is the primary flow; the manual URL form stays collapsed unless
|
||||
// scanning is unavailable (web build) or the user asks for it.
|
||||
const [manualOpen, setManualOpen] = React.useState(() => !isQrScanSupported());
|
||||
// Which saved connection is being connected to, for the per-row spinner.
|
||||
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
||||
const [password, setPassword] = React.useState('');
|
||||
|
||||
const handleSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void conn.connect({ url: serverUrl, clientToken, label: connectionName });
|
||||
}, [clientToken, conn, connectionName, serverUrl]);
|
||||
|
||||
// Accept a pasted pairing link (openchamber://connect?...) in the URL field and
|
||||
// split it back into the server URL + token.
|
||||
const handleUrlChange = React.useCallback((value: string) => {
|
||||
if (/^openchamber:\/\//i.test(value.trim())) {
|
||||
const payload = parseConnectionPayload(value);
|
||||
if (payload) {
|
||||
if ('pairing' in payload) {
|
||||
void conn.redeemPairingConnection(payload.pairing);
|
||||
return;
|
||||
}
|
||||
setServerUrl(payload.url);
|
||||
if (payload.label) setConnectionName(payload.label);
|
||||
if (payload.clientToken) setClientToken(payload.clientToken);
|
||||
return;
|
||||
}
|
||||
}
|
||||
setServerUrl(value);
|
||||
}, [conn]);
|
||||
|
||||
const handleScanQr = React.useCallback(async () => {
|
||||
if (scanAbortRef.current || isBusy) return;
|
||||
conn.setError(null);
|
||||
setIsScanning(true);
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
try {
|
||||
const result = await scanConnectionQr({ signal: controller.signal });
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
setIsCompletingScan(true);
|
||||
setServerUrl(result.url);
|
||||
if (result.label) setConnectionName(result.label);
|
||||
if (result.clientToken) setClientToken(result.clientToken);
|
||||
await conn.connect({ url: result.url, clientToken: result.clientToken, label: result.label });
|
||||
break;
|
||||
case 'pairing':
|
||||
setIsCompletingScan(true);
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
conn.setError(t('mobile.connect.scan.permissionDenied'));
|
||||
break;
|
||||
case 'invalid':
|
||||
conn.setError(t('mobile.connect.scan.invalid'));
|
||||
break;
|
||||
case 'unsupported':
|
||||
conn.setError(t('mobile.connect.scan.unsupported'));
|
||||
break;
|
||||
case 'failed':
|
||||
conn.setError(t('mobile.connect.scan.failed'));
|
||||
break;
|
||||
case 'cancelled':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsCompletingScan(false);
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
}, [conn, isBusy, t]);
|
||||
|
||||
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void conn.submitPassword(password);
|
||||
}, [conn, password]);
|
||||
|
||||
const cancelPassword = React.useCallback(() => {
|
||||
setPassword('');
|
||||
conn.cancelPassword();
|
||||
}, [conn]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||
<main className="oc-keyboard-fill-screen flex min-h-dvh flex-col overflow-y-auto bg-background px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+28px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+28px)] text-foreground">
|
||||
<div className="m-auto flex w-full max-w-[360px] shrink-0 flex-col items-center gap-9 py-8">
|
||||
<div className="flex flex-col items-center gap-5 text-center">
|
||||
<OpenChamberLogo width={72} height={72} className="size-[72px]" />
|
||||
<h1 className="typography-h2 text-foreground">{t('mobile.connect.welcome.title')}</h1>
|
||||
</div>
|
||||
|
||||
{notice ? (
|
||||
<div
|
||||
role="status"
|
||||
className="flex w-full items-center gap-3 rounded-[18px] border border-[color-mix(in_srgb,var(--status-warning)_35%,transparent)] bg-[color-mix(in_srgb,var(--status-warning)_10%,transparent)] px-3.5 py-3"
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-[color-mix(in_srgb,var(--status-warning)_16%,transparent)] text-[var(--status-warning)]">
|
||||
<Icon name={notice.kind === 'auth-expired' ? 'lock' : 'cloud-off'} className="size-[18px]" />
|
||||
</span>
|
||||
<p className="min-w-0 flex-1 typography-small text-foreground">
|
||||
{notice.kind === 'auth-expired'
|
||||
? t('mobile.connect.notice.authExpired', { label: notice.label })
|
||||
: t('mobile.connect.notice.unreachable', { label: notice.label })}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{pendingConnection ? (
|
||||
<form className="flex w-full flex-col gap-3" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex items-center gap-3 rounded-[18px] border border-border/70 bg-surface-elevated px-3.5 py-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="lock" className="size-[18px]" />
|
||||
</span>
|
||||
<div className="min-w-0 text-left">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('mobile.connect.password.placeholder')}
|
||||
aria-label={t('mobile.connect.password.label')}
|
||||
type="password"
|
||||
autoFocus
|
||||
className={mobileConnectionInputClass}
|
||||
/>
|
||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isPasswordBusy || !password.trim()}>
|
||||
{isPasswordBusy ? t('mobile.connect.connecting') : t('mobile.connect.unlockButton')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={cancelPassword}
|
||||
>
|
||||
{t('mobile.connect.cancelPassword')}
|
||||
</Button>
|
||||
</form>
|
||||
) : (
|
||||
<div className="flex w-full flex-col gap-6">
|
||||
{/* Primary path: scan the pairing QR from "Add a device" on the server. */}
|
||||
{qrScanSupported ? (
|
||||
<div className="flex w-full flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="h-12 w-full"
|
||||
onClick={() => void handleScanQr()}
|
||||
disabled={isScanning || isBusy}
|
||||
>
|
||||
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
||||
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.scanQr')}
|
||||
</Button>
|
||||
<p className="px-2 text-center typography-small text-muted-foreground">
|
||||
{t('mobile.connect.welcome.scanHint')}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{error && !manualOpen ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
|
||||
{connections.length > 0 ? (
|
||||
<section className="flex w-full flex-col gap-2.5">
|
||||
<h2 className="text-center typography-micro uppercase tracking-[0.14em] text-muted-foreground">
|
||||
{t('mobile.connect.saved.title')}
|
||||
</h2>
|
||||
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
||||
{connections.map((connection) => {
|
||||
const isConnectingRow = connectingId === connection.id;
|
||||
return (
|
||||
<button
|
||||
key={connection.id}
|
||||
type="button"
|
||||
disabled={isBusy}
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/70 px-3.5 py-2.5 text-left last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-70"
|
||||
onClick={() => {
|
||||
setConnectingId(connection.id);
|
||||
void conn.connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
||||
.finally(() => setConnectingId(null));
|
||||
}}
|
||||
>
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className={cn('block truncate typography-small', isConnectingRow ? 'text-foreground' : 'text-muted-foreground')}>
|
||||
{isConnectingRow
|
||||
? t('mobile.connect.connecting')
|
||||
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge')}
|
||||
</span>
|
||||
</span>
|
||||
{isConnectingRow
|
||||
? <Icon name="loader-4" className="size-5 animate-spin text-muted-foreground" />
|
||||
: <Icon name="arrow-right-s" className="size-5 text-muted-foreground" />}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{/* Manual URL entry, collapsed by default — most people pair by QR. */}
|
||||
<div className="flex w-full flex-col">
|
||||
{qrScanSupported ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setManualOpen((value) => !value)}
|
||||
aria-expanded={manualOpen}
|
||||
className="mx-auto flex items-center gap-1 rounded-full px-2 py-1 typography-small text-muted-foreground transition-colors hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
>
|
||||
<span>{t('mobile.connect.manual.toggle')}</span>
|
||||
<Icon name="arrow-down-s" className={cn('size-4 transition-transform duration-200', manualOpen && 'rotate-180')} />
|
||||
</button>
|
||||
) : null}
|
||||
<div
|
||||
className="grid transition-[grid-template-rows] duration-200 ease-out"
|
||||
style={{ gridTemplateRows: manualOpen ? '1fr' : '0fr' }}
|
||||
>
|
||||
<div className="min-h-0 overflow-hidden">
|
||||
<form className="flex w-full flex-col gap-3 pt-3" onSubmit={handleSubmit}>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={serverUrl}
|
||||
onChange={(event) => handleUrlChange(event.target.value)}
|
||||
placeholder={t('mobile.connect.url.placeholder')}
|
||||
aria-label={t('mobile.connect.url.label')}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
tabIndex={manualOpen ? undefined : -1}
|
||||
className={cn(mobileConnectionInputClass, 'text-center')}
|
||||
/>
|
||||
<input
|
||||
value={connectionName}
|
||||
onChange={(event) => setConnectionName(event.target.value)}
|
||||
placeholder={t('mobile.instances.label.placeholder')}
|
||||
aria-label={t('mobile.instances.label.label')}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
tabIndex={manualOpen ? undefined : -1}
|
||||
className={cn(mobileConnectionInputClass, 'text-center')}
|
||||
/>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={clientToken}
|
||||
onChange={(event) => setClientToken(event.target.value)}
|
||||
placeholder={t('mobile.connect.token.placeholder')}
|
||||
aria-label={t('mobile.connect.token.label')}
|
||||
tabIndex={manualOpen ? undefined : -1}
|
||||
autoCapitalize="none"
|
||||
className={cn(mobileConnectionInputClass, 'text-center')}
|
||||
/>
|
||||
<p className="px-1 text-center typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" variant={qrScanSupported ? 'outline' : 'default'} size="lg" className="h-12 w-full" disabled={isBusy || isScanning || !serverUrl.trim()}>
|
||||
{isBusy ? t('mobile.connect.connecting') : t('mobile.connect.connectButton')}
|
||||
</Button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</main>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -169,7 +169,7 @@ export const MobileDeleteWorktreeDialog: React.FC<MobileDeleteWorktreeDialogProp
|
||||
disabled={disabled}
|
||||
onClick={() => onChange(!checked)}
|
||||
className={cn(
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl border border-border/50 px-3.5 py-3 text-left transition-colors',
|
||||
'flex w-full items-center justify-between gap-3 rounded-xl border border-border/70 px-3.5 py-3 text-left transition-colors',
|
||||
'hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
disabled && 'pointer-events-none opacity-40',
|
||||
)}
|
||||
|
||||
@@ -1,11 +1,8 @@
|
||||
import React from 'react';
|
||||
import { File as PierreFile } from '@pierre/diffs/react';
|
||||
import {
|
||||
RiArrowLeftLine,
|
||||
RiArrowRightSLine,
|
||||
RiClipboardLine,
|
||||
RiCloseLine,
|
||||
RiFileCopyLine,
|
||||
RiFolder3Fill,
|
||||
RiFolderOpenFill,
|
||||
RiLoader4Line,
|
||||
@@ -13,34 +10,28 @@ import {
|
||||
RiSearchLine,
|
||||
} from '@remixicon/react';
|
||||
|
||||
import { toast } from '@/components/ui';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { ScrollShadow } from '@/components/ui/ScrollShadow';
|
||||
import { FileTypeIcon } from '@/components/icons/FileTypeIcon';
|
||||
import { JsonTreeView } from '@/components/ui/JsonTreeView';
|
||||
import { SimpleMarkdownRenderer } from '@/components/chat/MarkdownRenderer';
|
||||
import { PIERRE_RUNTIME_BASE_CSS } from '@/components/views/PierreDiffViewer';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs';
|
||||
import { useEffectiveDirectory } from '@/hooks/useEffectiveDirectory';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { ensurePierreThemeRegistered } from '@/lib/shiki/appThemeRegistry';
|
||||
import { getDefaultTheme } from '@/lib/theme/themes';
|
||||
import { getImageMimeType, getLanguageFromExtension, isImageFile } from '@/lib/toolHelpers';
|
||||
import type { FileListEntry, FileSearchResult } from '@/lib/api/types';
|
||||
import { getRuntimeUrlResolver } from '@/lib/runtime-url';
|
||||
import { refreshRuntimeUrlAuthToken } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl } from '@/lib/runtime-switch';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
// The full desktop file editor, loaded on demand — it's a heavy chunk and only
|
||||
// needed once a file is actually opened.
|
||||
const LazyFilesEditor = React.lazy(() =>
|
||||
import('@/components/views/FilesView').then((module) => ({ default: module.FilesView })),
|
||||
);
|
||||
|
||||
type MobileFilesRoute =
|
||||
| { type: 'browser'; directory: string }
|
||||
| { type: 'file'; path: string; returnDirectory: string };
|
||||
|
||||
const MAX_MOBILE_FILE_CHARS = 250_000;
|
||||
|
||||
const normalizePath = (value?: string | null): string => (value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
const getNameFromPath = (path: string): string => {
|
||||
@@ -77,24 +68,15 @@ const formatFileSize = (size?: number): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const getImageSrc = (path: string): string => {
|
||||
if (path.toLowerCase().endsWith('.svg')) {
|
||||
return '';
|
||||
}
|
||||
return getRuntimeUrlResolver().authenticatedAsset('/api/fs/raw', { path });
|
||||
};
|
||||
|
||||
const isMarkdownFile = (path: string): boolean => /\.(md|mdx|markdown)$/i.test(path);
|
||||
const isJsonFile = (path: string): boolean => /\.(json|jsonc)$/i.test(path);
|
||||
|
||||
type MobileFilesSurfaceProps = {
|
||||
/** When provided, header gets a close X that calls this; used when the surface is hosted in MobileSurfaceShell. */
|
||||
/** When provided, the header gets a close X that calls this. */
|
||||
onClose?: () => void;
|
||||
};
|
||||
|
||||
export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose }) => {
|
||||
const { t } = useI18n();
|
||||
const { files } = useRuntimeAPIs();
|
||||
const setSelectedPath = useFilesViewTabsStore((state) => state.setSelectedPath);
|
||||
const root = normalizePath(useEffectiveDirectory() ?? null);
|
||||
const [route, setRoute] = React.useState<MobileFilesRoute>(() => ({ type: 'browser', directory: root }));
|
||||
const [entries, setEntries] = React.useState<FileListEntry[]>([]);
|
||||
@@ -103,9 +85,6 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
const [query, setQuery] = React.useState('');
|
||||
const [searchResults, setSearchResults] = React.useState<FileSearchResult[]>([]);
|
||||
const [isSearching, setIsSearching] = React.useState(false);
|
||||
const [fileContent, setFileContent] = React.useState('');
|
||||
const [fileError, setFileError] = React.useState<string | null>(null);
|
||||
const [isLoadingFile, setIsLoadingFile] = React.useState(false);
|
||||
const directoryLoadRequestIdRef = React.useRef(0);
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -177,79 +156,64 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
};
|
||||
}, [files, query, route]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (route.type !== 'file') return;
|
||||
setFileContent('');
|
||||
setFileError(null);
|
||||
|
||||
if (isImageFile(route.path) && !route.path.toLowerCase().endsWith('.svg')) {
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!files.readFile) {
|
||||
setFileError(t('mobile.files.error.readUnavailable'));
|
||||
setIsLoadingFile(false);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setIsLoadingFile(true);
|
||||
void files.readFile(route.path)
|
||||
.then((result) => {
|
||||
if (cancelled) return;
|
||||
setFileContent(result.content.length > MAX_MOBILE_FILE_CHARS
|
||||
? `${result.content.slice(0, MAX_MOBILE_FILE_CHARS)}\n\n${t('mobile.files.file.truncated')}`
|
||||
: result.content);
|
||||
})
|
||||
.catch((error) => {
|
||||
if (!cancelled) setFileError(error instanceof Error ? error.message : t('filesView.error.readFileFailed'));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setIsLoadingFile(false);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [files, route, t]);
|
||||
|
||||
const openDirectory = (directory: string) => {
|
||||
setQuery('');
|
||||
setRoute({ type: 'browser', directory });
|
||||
};
|
||||
|
||||
const openFile = (path: string) => {
|
||||
// FilesView (editor-only) reads its target from the files-view tabs store.
|
||||
setSelectedPath(root, path);
|
||||
setRoute({ type: 'file', path, returnDirectory: currentDirectory || root });
|
||||
};
|
||||
|
||||
const handleCopyPath = async (path: string) => {
|
||||
const result = await copyTextToClipboard(path);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.pathCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
|
||||
const handleCopyContent = async () => {
|
||||
const result = await copyTextToClipboard(fileContent);
|
||||
if (result.ok) toast.success(t('mobile.files.toast.contentCopied'));
|
||||
else toast.error(t('mobile.files.toast.copyFailed'));
|
||||
};
|
||||
// Chat tool rows (read/skill/edit) stage a pending file focus/navigation in
|
||||
// the UI store — the same channel desktop's context panel consumes. Route
|
||||
// straight to the editor for targets inside this workspace; the editor
|
||||
// itself consumes pendingFileNavigation to jump to the requested line.
|
||||
const pendingFileFocusPath = useUIStore((state) => state.pendingFileFocusPath);
|
||||
const pendingFileNavigation = useUIStore((state) => state.pendingFileNavigation);
|
||||
React.useEffect(() => {
|
||||
const target = normalizePath(pendingFileNavigation?.path ?? pendingFileFocusPath ?? '');
|
||||
if (!target || !root) return;
|
||||
if (target !== root && !target.startsWith(`${root}/`)) return;
|
||||
setSelectedPath(root, target);
|
||||
setRoute({ type: 'file', path: target, returnDirectory: root });
|
||||
if (pendingFileFocusPath) useUIStore.getState().setPendingFileFocusPath(null);
|
||||
}, [pendingFileFocusPath, pendingFileNavigation, root, setSelectedPath]);
|
||||
|
||||
if (!root) {
|
||||
return <MobileFilesState message={t('mobile.files.empty.noDirectory')} />;
|
||||
}
|
||||
|
||||
if (route.type === 'file') {
|
||||
// Full desktop file editor (toolbar, dirty/save, wrap, search, md/html
|
||||
// preview, open-file tabs) — FilesView is already mobile-aware (keyboard
|
||||
// nudge, touch menus); this host only adds the back row.
|
||||
return (
|
||||
<MobileFileDetail
|
||||
path={route.path}
|
||||
content={fileContent}
|
||||
error={fileError}
|
||||
isLoading={isLoadingFile}
|
||||
onBack={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||
onCopyPath={() => void handleCopyPath(route.path)}
|
||||
onCopyContent={() => void handleCopyContent()}
|
||||
/>
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 border-b border-border/70 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={() => setRoute({ type: 'browser', directory: route.returnDirectory })}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(route.path)}</h2>
|
||||
</div>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
<ErrorBoundary>
|
||||
<React.Suspense fallback={<MobileFilesState loading message={t('filesView.state.loading')} />}>
|
||||
<LazyFilesEditor mode="editor-only" />
|
||||
</React.Suspense>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -320,7 +284,7 @@ export const MobileFilesSurface: React.FC<MobileFilesSurfaceProps> = ({ onClose
|
||||
) : query.trim() ? (
|
||||
<MobileSearchResults results={visibleSearchResults} isSearching={isSearching} onOpenFile={openFile} />
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-[var(--surface-elevated)]">
|
||||
{entries.length === 0 && !isLoadingDirectory ? (
|
||||
<div className="px-4 py-8 text-center typography-body text-muted-foreground">{t('mobile.files.empty.directory')}</div>
|
||||
) : null}
|
||||
@@ -350,7 +314,7 @@ const MobileFileRow: React.FC<{
|
||||
}> = ({ name, path, directory, meta, onClick }) => (
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/30 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
className="flex min-h-14 w-full items-center gap-3 border-b border-border/70 px-3 py-2.5 text-left transition-colors last:border-b-0 hover:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-inset"
|
||||
onClick={onClick}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -375,7 +339,7 @@ const MobileSearchResults: React.FC<{
|
||||
if (isSearching) return <MobileFilesState loading message={t('common.loading')} />;
|
||||
if (results.length === 0) return <MobileFilesState message={t('mobile.files.search.empty')} />;
|
||||
return (
|
||||
<div className="overflow-hidden rounded-2xl border border-border/40 bg-[var(--surface-elevated)]">
|
||||
<div className="overflow-hidden rounded-2xl border border-border/70 bg-[var(--surface-elevated)]">
|
||||
{results.map((result) => (
|
||||
<MobileFileRow
|
||||
key={result.path}
|
||||
@@ -390,139 +354,6 @@ const MobileSearchResults: React.FC<{
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFileDetail: React.FC<{
|
||||
path: string;
|
||||
content: string;
|
||||
error: string | null;
|
||||
isLoading: boolean;
|
||||
onBack: () => void;
|
||||
onCopyPath: () => void;
|
||||
onCopyContent: () => void;
|
||||
}> = ({ path, content, error, isLoading, onBack, onCopyPath, onCopyContent }) => {
|
||||
const { t } = useI18n();
|
||||
const imageAuthKey = isImageFile(path) && !path.toLowerCase().endsWith('.svg') ? path : '';
|
||||
const [imageAuthReadyKey, setImageAuthReadyKey] = React.useState('');
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!imageAuthKey) {
|
||||
setImageAuthReadyKey('');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
setImageAuthReadyKey('');
|
||||
void refreshRuntimeUrlAuthToken(getRuntimeApiBaseUrl())
|
||||
.then((token) => {
|
||||
if (!cancelled && token) setImageAuthReadyKey(imageAuthKey);
|
||||
})
|
||||
.catch(() => {});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [imageAuthKey]);
|
||||
|
||||
const imageAuthLoading = Boolean(imageAuthKey && imageAuthReadyKey !== imageAuthKey);
|
||||
const imageSrc = imageAuthLoading ? '' : getImageSrc(path);
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden bg-background text-foreground">
|
||||
<header className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-3 border-b border-border/50 px-3 text-foreground">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-9 shrink-0 items-center justify-center rounded-lg text-muted-foreground hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1">
|
||||
<h2 className="truncate typography-ui-header text-foreground">{getNameFromPath(path)}</h2>
|
||||
</div>
|
||||
{!isImageFile(path) ? (
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyContent} aria-label={t('mobile.files.copyContentAria')}>
|
||||
<RiFileCopyLine className="size-4" />
|
||||
</Button>
|
||||
) : null}
|
||||
<Button type="button" variant="ghost" size="icon" onClick={onCopyPath} aria-label={t('mobile.files.copyPathAria')}>
|
||||
<RiClipboardLine className="size-4" />
|
||||
</Button>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{isLoading || imageAuthLoading ? (
|
||||
<MobileFilesState loading message={t('filesView.state.loading')} />
|
||||
) : error ? (
|
||||
<MobileFilesState message={error} />
|
||||
) : isImageFile(path) && imageSrc ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={imageSrc} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : isImageFile(path) ? (
|
||||
<ScrollShadow className="h-full overflow-auto p-4">
|
||||
<img src={`data:${getImageMimeType(path)};utf8,${encodeURIComponent(content)}`} alt={getNameFromPath(path)} className="mx-auto max-h-full max-w-full rounded-lg object-contain" />
|
||||
</ScrollShadow>
|
||||
) : (
|
||||
<MobileTextFile path={path} content={content} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileTextFile: React.FC<{ path: string; content: string }> = ({ path, content }) => {
|
||||
const { currentTheme, availableThemes, lightThemeId, darkThemeId } = useThemeSystem();
|
||||
const lightTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === lightThemeId) ?? getDefaultTheme(false),
|
||||
[availableThemes, lightThemeId],
|
||||
);
|
||||
const darkTheme = React.useMemo(
|
||||
() => availableThemes.find((theme) => theme.metadata.id === darkThemeId) ?? getDefaultTheme(true),
|
||||
[availableThemes, darkThemeId],
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
ensurePierreThemeRegistered(lightTheme);
|
||||
ensurePierreThemeRegistered(darkTheme);
|
||||
}, [darkTheme, lightTheme]);
|
||||
|
||||
const pierreTheme = React.useMemo(
|
||||
() => ({ light: lightTheme.metadata.id, dark: darkTheme.metadata.id }),
|
||||
[darkTheme.metadata.id, lightTheme.metadata.id],
|
||||
);
|
||||
|
||||
if (isMarkdownFile(path)) {
|
||||
return (
|
||||
<ScrollShadow className="h-full overflow-y-auto px-4 py-4">
|
||||
<SimpleMarkdownRenderer content={content} enableFileReferences={false} />
|
||||
</ScrollShadow>
|
||||
);
|
||||
}
|
||||
if (isJsonFile(path)) {
|
||||
return <JsonTreeView jsonString={content} className="h-full overflow-auto" />;
|
||||
}
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<ScrollShadow className="min-h-0 flex-1 overflow-auto bg-[var(--syntax-base-background)]">
|
||||
<PierreFile
|
||||
file={{
|
||||
name: getNameFromPath(path),
|
||||
contents: content,
|
||||
lang: getLanguageFromExtension(path) || undefined,
|
||||
}}
|
||||
options={{
|
||||
disableFileHeader: true,
|
||||
overflow: 'wrap',
|
||||
theme: pierreTheme,
|
||||
themeType: currentTheme.metadata.variant === 'dark' ? 'dark' : 'light',
|
||||
unsafeCSS: PIERRE_RUNTIME_BASE_CSS,
|
||||
}}
|
||||
className="block min-h-full w-full"
|
||||
style={{ minHeight: '100%' }}
|
||||
/>
|
||||
</ScrollShadow>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileFilesState: React.FC<{ message: string; loading?: boolean }> = ({ message, loading = false }) => (
|
||||
<div className="flex h-full items-center justify-center px-6 text-center">
|
||||
|
||||
@@ -0,0 +1,270 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||
const ENTER_DELAY_MS = 16;
|
||||
// Enter-slide duration. Heavy content is revealed when this transition actually
|
||||
// ends (transitionend); this also feeds the fallback timer.
|
||||
const ENTER_DURATION_MS = 200;
|
||||
|
||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let root = document.getElementById(SURFACE_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = SURFACE_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
export type MobileFullscreenSurfaceProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** If true, leave Escape available to nested content instead of dismissing the surface. */
|
||||
disableEscapeDismiss?: boolean;
|
||||
/** If true, render no header and let the child render its own (with its own back button). */
|
||||
headerless?: boolean;
|
||||
/** Drop the header's bottom divider (quiet single-page surfaces). */
|
||||
noHeaderBorder?: boolean;
|
||||
ariaLabel?: string;
|
||||
/**
|
||||
* `dialog` packs the same surface into a centered card over a scrim instead
|
||||
* of covering the app. Tablets use it: a settings or instances page stretched
|
||||
* across a 13" screen is mostly empty space, and losing the chat entirely for
|
||||
* an app-level page is a heavier context switch than the content deserves.
|
||||
*/
|
||||
variant?: 'fullscreen' | 'dialog';
|
||||
/**
|
||||
* What the dialog centers on. App-level pages (settings, instances) belong to
|
||||
* the whole window; content that came out of the chat column stays with it.
|
||||
*/
|
||||
dialogAlign?: 'chat' | 'app';
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
/** Fullscreen overlay surface for the phone layout: covers the whole app
|
||||
(including the header), slides in from the right like a navigation push,
|
||||
and closes via the header back arrow, Escape, or the Android back button. */
|
||||
export const MobileFullscreenSurface: React.FC<MobileFullscreenSurfaceProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
disableEscapeDismiss = false,
|
||||
headerless = false,
|
||||
noHeaderBorder = false,
|
||||
ariaLabel,
|
||||
variant = 'fullscreen',
|
||||
dialogAlign = 'chat',
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
const [contentReady, setContentReady] = React.useState(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep onClose in a ref so the focus/keydown effect below depends only on `open`.
|
||||
// The parent passes a fresh inline onClose on every render; if the effect depended
|
||||
// on it, each parent re-render (e.g. an SSE store update) would re-run it and
|
||||
// refocus the first element — stealing focus from whatever input the user is in
|
||||
// and collapsing the keyboard mid-edit.
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setEntered(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Defer mounting heavy children until the enter slide finishes, so the
|
||||
// animation stays smooth instead of competing with a large content render.
|
||||
// Primary trigger is the slide's transitionend (below); this is just a
|
||||
// fallback in case it never fires (reduced motion / interrupted transition).
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setContentReady(true), ENTER_DELAY_MS + ENTER_DURATION_MS + 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const focusFirstElement = () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = surface.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(focusable ?? surface).focus({ preventScroll: true });
|
||||
};
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && !disableEscapeDismiss) {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
surface.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [disableEscapeDismiss, open]);
|
||||
|
||||
if (!open || !rootRef.current) return null;
|
||||
|
||||
const isDialog = variant === 'dialog';
|
||||
|
||||
const surface = (
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
tabIndex={-1}
|
||||
className={cn(
|
||||
'flex flex-col bg-background text-foreground',
|
||||
isDialog
|
||||
? 'h-[min(88dvh,860px)] w-full max-w-[720px] overflow-hidden rounded-2xl border border-border/70 shadow-[0_24px_64px_rgb(0_0_0_/_0.32)]'
|
||||
: 'oc-keyboard-inset-surface fixed inset-0 z-50',
|
||||
)}
|
||||
style={isDialog ? {
|
||||
// Scale/fade instead of the push slide: the card is not a navigation
|
||||
// step, and a settled `transform: none` keeps it off its own
|
||||
// compositing layer (iOS clips those to the safe-area viewport).
|
||||
opacity: entered ? 1 : 0,
|
||||
transform: entered ? 'none' : 'scale(0.97)',
|
||||
transition: `opacity ${ENTER_DURATION_MS}ms ease-out, transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
} : {
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Push-style enter: slide in from the right edge; settled state drops
|
||||
// the transform entirely so the surface isn't kept on a compositing
|
||||
// layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
}}
|
||||
onTransitionEnd={(event) => {
|
||||
// Reveal content exactly when the enter transition ends — not on a fixed timer.
|
||||
if (entered && event.target === event.currentTarget && event.propertyName === 'transform') {
|
||||
setContentReady(true);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{!headerless ? (
|
||||
<header
|
||||
className={cn(
|
||||
'flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3',
|
||||
!noHeaderBorder && 'border-b border-border/70',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="close" className="size-5" />
|
||||
</button>
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ? (
|
||||
typeof title === 'string' ? (
|
||||
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
subtitle
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{contentReady ? (
|
||||
<div className="h-full" style={{ animation: 'oc-surface-content-in 200ms ease-out' }}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||
</section>
|
||||
);
|
||||
|
||||
if (!isDialog) return createPortal(surface, rootRef.current);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex items-center justify-center p-4 transition-opacity duration-200 ease-out"
|
||||
style={{
|
||||
background: 'rgb(0 0 0 / 0.45)',
|
||||
opacity: entered ? 1 : 0,
|
||||
// 'chat' matches every other mobile overlay (the sessions sidebar keeps
|
||||
// its width); 'app' ignores the panels and centers on the window.
|
||||
paddingLeft: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-left, 0px))' : '1rem',
|
||||
paddingRight: dialogAlign === 'chat' ? 'max(1rem, var(--oc-chat-inset-right, 0px))' : '1rem',
|
||||
paddingTop: 'max(1rem, var(--oc-safe-area-top, 0px))',
|
||||
}}
|
||||
onClick={onClose}
|
||||
>
|
||||
<div className="flex w-full max-w-[720px] justify-center" onClick={(event) => event.stopPropagation()}>
|
||||
{surface}
|
||||
</div>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,147 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useSession } from '@/sync/sync-context';
|
||||
|
||||
import { MobileSessionMetadataButton } from './MobileSessionMetadata';
|
||||
import { MobileSessionSwitcher } from './MobileSessionSwitcher';
|
||||
|
||||
export const MobileHeader: React.FC<{
|
||||
onOpenSessions: () => void;
|
||||
/** Opens the right workspace drawer (Changes / Files / Terminal / Notes / MCP). */
|
||||
onOpenWorkspace: () => void;
|
||||
/** Tablet: size the title trigger to its text instead of the free width, so
|
||||
a wide header doesn't turn the switcher into a full-width tap target. */
|
||||
compactTitle?: boolean;
|
||||
}> = ({ onOpenSessions, onOpenWorkspace, compactTitle = false }) => {
|
||||
const { t } = useI18n();
|
||||
const [metadataOpen, setMetadataOpen] = React.useState(false);
|
||||
const [switcherOpen, setSwitcherOpen] = React.useState(false);
|
||||
const titleRef = React.useRef<HTMLButtonElement>(null);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const currentSessionDirectory = useSessionUIStore(
|
||||
React.useCallback((state) => (currentSessionId ? state.getDirectoryForSession(currentSessionId) : null), [currentSessionId]),
|
||||
);
|
||||
const effectiveDirectory = currentSessionDirectory || currentDirectory;
|
||||
const currentSession = useSession(currentSessionId, effectiveDirectory || undefined);
|
||||
const isNewSessionDraftOpen = useSessionUIStore((state) => Boolean(state.newSessionDraft?.open));
|
||||
|
||||
const sessionTitle = currentSession?.title?.trim();
|
||||
// Single-line title, desktop-style: session title, or the "New session"
|
||||
// placeholder on the draft screen. No project/branch metadata line.
|
||||
const primaryLabel = sessionTitle
|
||||
|| (currentSessionId ? t('mobile.sessions.untitled') : t('sessions.switcher.draftTitle'));
|
||||
|
||||
React.useEffect(() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
}, [currentSessionId, effectiveDirectory]);
|
||||
|
||||
const handleOpenSessions = React.useCallback(() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenSessions();
|
||||
}, [onOpenSessions]);
|
||||
|
||||
// The two header popovers are mutually exclusive.
|
||||
const handleMetadataOpenChange = React.useCallback((value: boolean | ((open: boolean) => boolean)) => {
|
||||
setMetadataOpen((current) => {
|
||||
const next = typeof value === 'function' ? value(current) : value;
|
||||
if (next) setSwitcherOpen(false);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const toggleSwitcher = React.useCallback(() => {
|
||||
setSwitcherOpen((current) => {
|
||||
const next = !current;
|
||||
if (next) setMetadataOpen(false);
|
||||
return next;
|
||||
});
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<header
|
||||
className="oc-mobile-header relative z-30 flex shrink-0 items-center gap-1 bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/80"
|
||||
style={{ paddingTop: 'var(--oc-safe-area-top, 0px)' }}
|
||||
>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] w-full items-center gap-1 px-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.sessions.openSheetAria')}
|
||||
onClick={handleOpenSessions}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="list-unordered" className="size-5" />
|
||||
</button>
|
||||
|
||||
{/* Session title doubles as the recent-sessions switcher trigger. */}
|
||||
<button
|
||||
ref={titleRef}
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex min-w-0 items-center rounded-lg px-2 py-1.5 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary',
|
||||
compactTitle ? 'shrink' : 'flex-1',
|
||||
)}
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={switcherOpen}
|
||||
onClick={toggleSwitcher}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 items-center gap-1">
|
||||
<span className="block min-w-0 truncate typography-ui-label text-foreground">{primaryLabel}</span>
|
||||
{/* Discoverability: the chevron marks the title as a disclosure
|
||||
trigger and flips while the switcher is open. */}
|
||||
<Icon
|
||||
name="arrow-down-s"
|
||||
className={cn(
|
||||
'size-4 shrink-0 text-muted-foreground transition-transform duration-150',
|
||||
switcherOpen && 'rotate-180',
|
||||
)}
|
||||
/>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Compact title: this takes the leftover width so the trailing
|
||||
controls stay pinned to the right edge. */}
|
||||
{compactTitle ? <div className="min-w-0 flex-1" /> : null}
|
||||
|
||||
<MobileSessionMetadataButton
|
||||
open={metadataOpen}
|
||||
onOpenChange={handleMetadataOpenChange}
|
||||
currentSessionId={currentSessionId}
|
||||
effectiveDirectory={effectiveDirectory}
|
||||
isNewSessionDraftOpen={isNewSessionDraftOpen}
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
onClick={() => {
|
||||
setMetadataOpen(false);
|
||||
setSwitcherOpen(false);
|
||||
onOpenWorkspace();
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="pencil-ruler-2" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
<MobileSessionSwitcher
|
||||
open={switcherOpen}
|
||||
onClose={() => setSwitcherOpen(false)}
|
||||
anchorRef={titleRef}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,382 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isRelayModeActive } from '@/lib/relay/runtime-tunnel';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import { connectionDisplayUrl, isActiveRuntimeConnection, useMobileConnection } from './mobileConnections';
|
||||
import { isQrScanSupported, scanConnectionQr } from './mobileQrScan';
|
||||
import { mobileConnectionInputClass, mobileInputKeyboardProps } from './mobileConnectionUi';
|
||||
import { MobileQrConnectionLoading, MobileQrScannerOverlay } from './MobileQrScannerOverlay';
|
||||
|
||||
export const MobileInstancesSurface: React.FC<{
|
||||
onConnect: () => void;
|
||||
onActiveConnectionDeleted: () => void;
|
||||
}> = ({ onActiveConnectionDeleted, onConnect }) => {
|
||||
const { t } = useI18n();
|
||||
const conn = useMobileConnection(onConnect);
|
||||
const {
|
||||
connections, isBusy, isPasswordBusy, error, pendingConnection,
|
||||
connect, submitPassword, cancelPassword, saveConnection, removeConnection, setError,
|
||||
} = conn;
|
||||
const [editingId, setEditingId] = React.useState<string | null>(null);
|
||||
const editingConnection = editingId ? connections.find((connection) => connection.id === editingId) ?? null : null;
|
||||
const [confirmingDeleteId, setConfirmingDeleteId] = React.useState<string | null>(null);
|
||||
const [url, setUrl] = React.useState('');
|
||||
const [label, setLabel] = React.useState('');
|
||||
const [clientToken, setClientToken] = React.useState('');
|
||||
const [password, setPassword] = React.useState('');
|
||||
const [isScanning, setIsScanning] = React.useState(false);
|
||||
const [isCompletingScan, setIsCompletingScan] = React.useState(false);
|
||||
const scanAbortRef = React.useRef<AbortController | null>(null);
|
||||
const qrScanSupported = React.useMemo(() => isQrScanSupported(), []);
|
||||
// The manual add/edit form is hidden until asked for — the sheet leads with
|
||||
// the list of instances (with live status), not a wall of inputs.
|
||||
const [formOpen, setFormOpen] = React.useState(false);
|
||||
// Which row is being connected to, for the per-row spinner.
|
||||
const [connectingId, setConnectingId] = React.useState<string | null>(null);
|
||||
|
||||
// Populate/clear the form imperatively (on edit tap / cancel / save) rather than via
|
||||
// an effect keyed on the derived connection object. With an effect, any churn of the
|
||||
// connections list re-fires it and overwrites what the user is typing — the keyboard
|
||||
// "resets" mid-edit. Imperative population is immune to that.
|
||||
const resetForm = React.useCallback(() => {
|
||||
setEditingId(null);
|
||||
setUrl('');
|
||||
setLabel('');
|
||||
setClientToken('');
|
||||
setError(null);
|
||||
setFormOpen(false);
|
||||
}, [setError]);
|
||||
|
||||
const saveInstance = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
// The id is what makes this an EDIT: saveConnection uses it to preserve the
|
||||
// existing relay/https candidates (and the Keychain token they key) instead
|
||||
// of rebuilding the instance from the single URL field.
|
||||
void saveConnection({ id: editingId ?? undefined, url, label, clientToken }).then((saved) => {
|
||||
if (saved) resetForm();
|
||||
});
|
||||
}, [clientToken, editingId, label, resetForm, saveConnection, url]);
|
||||
|
||||
// Scan a pairing QR into the add/edit form fields (does not change edit mode, so
|
||||
// the form-reset effect doesn't wipe the scanned values). The user reviews + saves.
|
||||
const handleScanInstance = React.useCallback(async () => {
|
||||
if (scanAbortRef.current) return;
|
||||
setError(null);
|
||||
setIsScanning(true);
|
||||
const controller = new AbortController();
|
||||
scanAbortRef.current = controller;
|
||||
try {
|
||||
const result = await scanConnectionQr({ signal: controller.signal });
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
switch (result.status) {
|
||||
case 'ok':
|
||||
// Legacy token QR: prefill the manual form for review before saving.
|
||||
setUrl(result.url);
|
||||
if (result.label) setLabel(result.label);
|
||||
if (result.clientToken) setClientToken(result.clientToken);
|
||||
setFormOpen(true);
|
||||
break;
|
||||
case 'pairing':
|
||||
setIsCompletingScan(true);
|
||||
await conn.redeemPairingConnection(result.pairing);
|
||||
break;
|
||||
case 'permission-denied':
|
||||
setError(t('mobile.connect.scan.permissionDenied'));
|
||||
break;
|
||||
case 'invalid':
|
||||
setError(t('mobile.connect.scan.invalid'));
|
||||
break;
|
||||
case 'unsupported':
|
||||
setError(t('mobile.connect.scan.unsupported'));
|
||||
break;
|
||||
case 'failed':
|
||||
setError(t('mobile.connect.scan.failed'));
|
||||
break;
|
||||
case 'cancelled':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
} finally {
|
||||
setIsCompletingScan(false);
|
||||
if (scanAbortRef.current === controller) {
|
||||
scanAbortRef.current = null;
|
||||
setIsScanning(false);
|
||||
}
|
||||
}
|
||||
}, [conn, setError, t]);
|
||||
|
||||
React.useEffect(() => () => scanAbortRef.current?.abort(), []);
|
||||
|
||||
const handlePasswordSubmit = React.useCallback((event: React.FormEvent) => {
|
||||
event.preventDefault();
|
||||
void submitPassword(password);
|
||||
}, [password, submitPassword]);
|
||||
|
||||
const cancelPasswordPrompt = React.useCallback(() => {
|
||||
setPassword('');
|
||||
cancelPassword();
|
||||
}, [cancelPassword]);
|
||||
|
||||
// Two-step delete (mirrors the session sheet): the trash icon arms the row, a
|
||||
// second tap on the destructive button confirms, the X disarms. No hover relied on.
|
||||
const toggleConfirmDelete = React.useCallback((id: string) => {
|
||||
setConfirmingDeleteId((current) => (current === id ? null : id));
|
||||
}, []);
|
||||
|
||||
const confirmDelete = React.useCallback((id: string) => {
|
||||
setConfirmingDeleteId(null);
|
||||
if (editingId === id) resetForm();
|
||||
// Removing the ACTIVE instance — or the LAST one — must drop the user back
|
||||
// to the connect screen instead of leaving them in a stale, unbacked UI.
|
||||
const wasLast = connections.length === 1;
|
||||
void removeConnection(id).then((removed) => {
|
||||
if (!removed) return;
|
||||
if (wasLast || isActiveRuntimeConnection(removed)) {
|
||||
onActiveConnectionDeleted();
|
||||
}
|
||||
});
|
||||
}, [connections.length, editingId, onActiveConnectionDeleted, removeConnection, resetForm]);
|
||||
|
||||
const inputClass = mobileConnectionInputClass;
|
||||
|
||||
if (pendingConnection) {
|
||||
return (
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<form className="flex-1 overflow-y-auto px-5 py-4" onSubmit={handlePasswordSubmit}>
|
||||
<div className="flex flex-col gap-3">
|
||||
<div className="flex items-center gap-3 rounded-[18px] border border-border/70 bg-surface-elevated px-3.5 py-3">
|
||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="lock" className="size-[18px]" />
|
||||
</span>
|
||||
<div className="min-w-0">
|
||||
<p className="truncate typography-ui-label text-foreground">{pendingConnection.label}</p>
|
||||
<p className="truncate typography-small text-muted-foreground">
|
||||
{pendingConnection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(pendingConnection) : t('mobile.connect.relay.badge')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={password}
|
||||
onChange={(event) => setPassword(event.target.value)}
|
||||
placeholder={t('mobile.connect.password.placeholder')}
|
||||
aria-label={t('mobile.connect.password.label')}
|
||||
type="password"
|
||||
autoFocus
|
||||
className={inputClass}
|
||||
/>
|
||||
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full" disabled={isPasswordBusy || !password.trim()}>
|
||||
{isPasswordBusy ? t('mobile.connect.connecting') : t('mobile.connect.unlockButton')}
|
||||
</Button>
|
||||
<Button type="button" variant="ghost" size="sm" className="w-full" onClick={cancelPasswordPrompt}>
|
||||
{t('mobile.connect.cancelPassword')}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{isScanning ? <MobileQrScannerOverlay onCancel={() => scanAbortRef.current?.abort()} /> : null}
|
||||
{isCompletingScan ? <MobileQrConnectionLoading /> : null}
|
||||
<div className="flex h-full flex-col overflow-hidden">
|
||||
<div className="flex-1 overflow-y-auto px-5 py-4">
|
||||
<div className="space-y-6">
|
||||
{connections.length > 0 ? (
|
||||
<div className="overflow-hidden rounded-[18px] border border-border/70 bg-surface-elevated">
|
||||
{connections.map((connection) => {
|
||||
const confirming = confirmingDeleteId === connection.id;
|
||||
const isActive = isActiveRuntimeConnection(connection);
|
||||
const isConnectingRow = connectingId === connection.id;
|
||||
// Status line: the active instance says HOW it is connected right
|
||||
// now (direct vs relay); others show their address.
|
||||
const statusText = isConnectingRow
|
||||
? t('mobile.connect.connecting')
|
||||
: isActive
|
||||
? (isRelayModeActive() ? t('mobile.instances.status.connectedRelay') : t('mobile.instances.status.connectedDirect'))
|
||||
: connection.candidates.some((c) => c.kind === 'direct') ? connectionDisplayUrl(connection) : t('mobile.connect.relay.badge');
|
||||
return (
|
||||
<div
|
||||
key={connection.id}
|
||||
className={cn(
|
||||
'flex items-center border-b border-border/70 transition-colors last:border-b-0',
|
||||
confirming && 'bg-[color-mix(in_srgb,var(--destructive)_8%,transparent)]',
|
||||
)}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="flex min-w-0 flex-1 items-center gap-3 px-3.5 py-3 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary disabled:opacity-60"
|
||||
onClick={() => {
|
||||
if (isActive) return;
|
||||
setConnectingId(connection.id);
|
||||
void connect({ id: connection.id, candidates: connection.candidates, clientToken: connection.clientToken, label: connection.label })
|
||||
.finally(() => setConnectingId(null));
|
||||
}}
|
||||
disabled={(isBusy && !isConnectingRow) || confirming}
|
||||
>
|
||||
<span className="relative flex size-9 shrink-0 items-center justify-center rounded-[12px] bg-interactive-hover text-foreground">
|
||||
<Icon name="server" className="size-[18px]" />
|
||||
{isActive ? (
|
||||
<span className="absolute -right-0.5 -top-0.5 size-2.5 rounded-full border-2 border-[var(--surface-elevated)] bg-[var(--status-success)]" aria-hidden />
|
||||
) : null}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1">
|
||||
<span className="block truncate typography-ui-label text-foreground">{connection.label}</span>
|
||||
<span className={cn(
|
||||
'block truncate typography-small',
|
||||
isActive && !isConnectingRow ? 'text-[var(--status-success)]' : 'text-muted-foreground',
|
||||
)}>
|
||||
{statusText}
|
||||
</span>
|
||||
</span>
|
||||
{isConnectingRow ? <Icon name="loader-4" className="size-5 shrink-0 animate-spin text-muted-foreground" /> : null}
|
||||
</button>
|
||||
<div className="flex items-center gap-0.5 pr-2">
|
||||
{confirming ? (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.confirmDeleteAria', { label: connection.label })}
|
||||
className="flex h-9 shrink-0 items-center gap-1.5 rounded-full bg-destructive px-3 text-destructive-foreground transition-opacity active:opacity-90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-destructive"
|
||||
onClick={() => confirmDelete(connection.id)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="delete-bin" className="size-[18px]" />
|
||||
<span className="typography-ui-label">{t('mobile.instances.delete')}</span>
|
||||
</button>
|
||||
) : !connection.candidates.some((c) => c.kind === 'direct') ? null : (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t('mobile.instances.edit')}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={() => {
|
||||
setEditingId(connection.id);
|
||||
setUrl(connectionDisplayUrl(connection));
|
||||
setLabel(connection.label);
|
||||
setClientToken(connection.clientToken || '');
|
||||
setError(null);
|
||||
}}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="edit" className="size-[18px]" />
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
aria-label={confirming
|
||||
? t('mobile.instances.cancelDeleteAria', { label: connection.label })
|
||||
: t('mobile.instances.deleteAria', { label: connection.label })}
|
||||
className="flex size-9 items-center justify-center rounded-full text-muted-foreground transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={() => toggleConfirmDelete(connection.id)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name={confirming ? 'close' : 'delete-bin'} className="size-[18px]" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<p className="rounded-[18px] border border-dashed border-border/70 px-4 py-6 text-center typography-small text-muted-foreground">
|
||||
{t('mobile.connect.saved.empty')}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Add actions: QR pairing is the primary path; the manual form stays
|
||||
hidden until asked for (or until a row's edit button opens it). */}
|
||||
{!formOpen && !editingConnection ? (
|
||||
<div className="space-y-2">
|
||||
{qrScanSupported ? (
|
||||
<Button
|
||||
type="button"
|
||||
size="lg"
|
||||
className="h-12 w-full"
|
||||
onClick={() => void handleScanInstance()}
|
||||
disabled={isScanning}
|
||||
>
|
||||
<Icon name="scan-2" className={cn('size-[18px]', isScanning && 'animate-pulse')} />
|
||||
{t('mobile.connect.scanQr')}
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant={qrScanSupported ? 'ghost' : 'outline'}
|
||||
size="lg"
|
||||
className="h-12 w-full"
|
||||
onClick={() => { setError(null); setFormOpen(true); }}
|
||||
>
|
||||
<Icon name="add" className="size-[18px]" />
|
||||
{t('mobile.instances.addManual')}
|
||||
</Button>
|
||||
{error ? <p className="px-1 text-center typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
</div>
|
||||
) : (
|
||||
<form className="space-y-3" onSubmit={saveInstance}>
|
||||
<div className="flex h-8 items-center justify-between gap-3 px-1">
|
||||
<h3 className="typography-ui-label text-foreground">
|
||||
{editingConnection ? t('mobile.instances.editTitle') : t('mobile.instances.addTitle')}
|
||||
</h3>
|
||||
<Button type="button" variant="ghost" size="xs" onClick={resetForm}>
|
||||
{t('mobile.instances.cancelEdit')}
|
||||
</Button>
|
||||
</div>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.url.label')}</span>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={url}
|
||||
onChange={(event) => setUrl(event.target.value)}
|
||||
placeholder={t('mobile.connect.url.placeholder')}
|
||||
type="url"
|
||||
inputMode="url"
|
||||
autoCapitalize="none"
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.instances.label.label')}</span>
|
||||
<input
|
||||
value={label}
|
||||
onChange={(event) => setLabel(event.target.value)}
|
||||
placeholder={t('mobile.instances.label.placeholder')}
|
||||
autoComplete="off"
|
||||
autoCapitalize="words"
|
||||
autoCorrect="off"
|
||||
spellCheck={false}
|
||||
className={inputClass}
|
||||
/>
|
||||
</label>
|
||||
<label className="block space-y-1.5">
|
||||
<span className="block px-1 typography-ui-label text-foreground">{t('mobile.connect.token.label')}</span>
|
||||
<input
|
||||
{...mobileInputKeyboardProps}
|
||||
value={clientToken}
|
||||
onChange={(event) => setClientToken(event.target.value)}
|
||||
placeholder={t('mobile.connect.token.placeholder')}
|
||||
autoCapitalize="none"
|
||||
className={inputClass}
|
||||
/>
|
||||
<p className="px-1 typography-micro text-muted-foreground">{t('mobile.connect.token.hint')}</p>
|
||||
</label>
|
||||
{error ? <p className="px-1 typography-small text-[var(--status-error)]">{error}</p> : null}
|
||||
<Button type="submit" size="lg" className="mt-1 h-12 w-full">
|
||||
{editingConnection ? t('mobile.instances.saveEdit') : t('mobile.instances.saveNew')}
|
||||
</Button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
@@ -30,7 +30,7 @@ import { useWorktreeOrderStore } from '@/stores/useWorktreeOrderStore';
|
||||
import type { WorktreeMetadata } from '@/types/worktree';
|
||||
|
||||
import { MobileDeleteWorktreeDialog } from './MobileDeleteWorktreeDialog';
|
||||
import { MobileSurfaceShell } from './MobileSurfaceShell';
|
||||
import { MobileFullscreenSurface } from './MobileFullscreenSurface';
|
||||
|
||||
type MobileEditableProject = {
|
||||
id: string;
|
||||
@@ -72,7 +72,7 @@ const SortableWorktreeRow: React.FC<{
|
||||
ref={setNodeRef}
|
||||
style={style}
|
||||
className={cn(
|
||||
'flex items-center gap-1 rounded-2xl border border-border/40 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
|
||||
'flex items-center gap-1 rounded-2xl border border-border/70 bg-[var(--surface-elevated)] px-1.5 py-1.5 transition-colors',
|
||||
isDragging && 'shadow-lg shadow-black/20',
|
||||
)}
|
||||
>
|
||||
@@ -218,12 +218,12 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
|
||||
return (
|
||||
<>
|
||||
<MobileSurfaceShell
|
||||
<MobileFullscreenSurface
|
||||
open={open}
|
||||
onClose={onClose}
|
||||
onBack={onClose}
|
||||
title={t('projectEditDialog.title')}
|
||||
ariaLabel={t('projectEditDialog.title')}
|
||||
noHeaderBorder
|
||||
trailing={
|
||||
<Button
|
||||
type="button"
|
||||
@@ -293,7 +293,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
aria-label={t('projectEditDialog.option.none')}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
|
||||
color === null ? 'border-foreground' : 'border-border hover:border-border/80',
|
||||
color === null ? 'border-foreground' : 'border-border/70 hover:border-border/70',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -308,7 +308,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
title={c.label}
|
||||
className={cn(
|
||||
'size-9 rounded-xl border-2 transition-all',
|
||||
color === c.key ? 'border-foreground' : 'border-transparent hover:border-border',
|
||||
color === c.key ? 'border-foreground' : 'border-transparent hover:border-border/70',
|
||||
)}
|
||||
style={{ backgroundColor: c.cssVar, touchAction: 'manipulation' }}
|
||||
/>
|
||||
@@ -328,7 +328,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
aria-label={t('projectEditDialog.option.none')}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
|
||||
icon === null ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border hover:border-border/80',
|
||||
icon === null ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border/70 hover:border-border/70',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -343,7 +343,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
title={i.label}
|
||||
className={cn(
|
||||
'flex size-9 items-center justify-center rounded-xl border-2 transition-all',
|
||||
icon === i.key ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border hover:border-border/80',
|
||||
icon === i.key ? 'border-foreground bg-[var(--surface-elevated)]' : 'border-border/70 hover:border-border/70',
|
||||
)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
@@ -402,7 +402,7 @@ export const MobileProjectEditSurface: React.FC<MobileProjectEditSurfaceProps> =
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</MobileSurfaceShell>
|
||||
</MobileFullscreenSurface>
|
||||
|
||||
{project ? (
|
||||
<MobileDeleteWorktreeDialog
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { OpenChamberLogo } from '@/components/ui/OpenChamberLogo';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
|
||||
export const MobileQrScannerOverlay: React.FC<{ onCancel: () => void }> = ({ onCancel }) => {
|
||||
const { t } = useI18n();
|
||||
const overlayRef = React.useRef<HTMLDivElement>(null);
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const htmlBackground = {
|
||||
value: document.documentElement.style.getPropertyValue('background-color'),
|
||||
priority: document.documentElement.style.getPropertyPriority('background-color'),
|
||||
};
|
||||
const bodyBackground = {
|
||||
value: document.body.style.getPropertyValue('background-color'),
|
||||
priority: document.body.style.getPropertyPriority('background-color'),
|
||||
};
|
||||
// The app's reduced-transparency theme deliberately uses an !important
|
||||
// background. Use an inline important color while CameraX is behind the
|
||||
// WebView; the CSS minifier collapses `background: transparent` in a way
|
||||
// that does not reset that important background color on Android WebView.
|
||||
document.documentElement.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||
document.body.style.setProperty('background-color', 'rgba(0, 0, 0, 0)', 'important');
|
||||
|
||||
// startScan() places CameraX behind the WebView. OpenChamber has several
|
||||
// independent portal roots, so hiding only #root (or relying on inherited
|
||||
// visibility) can leave a sheet/sidebar painted over the preview. Opacity on
|
||||
// each top-level sibling is composited for its whole subtree and cannot be
|
||||
// overridden by descendants.
|
||||
const hidden = new Map<HTMLElement, { opacity: string; pointerEvents: string }>();
|
||||
const hideBodySibling = (node: Node) => {
|
||||
if (!(node instanceof HTMLElement) || node === overlayRef.current || hidden.has(node)) return;
|
||||
hidden.set(node, { opacity: node.style.opacity, pointerEvents: node.style.pointerEvents });
|
||||
node.style.setProperty('opacity', '0');
|
||||
node.style.setProperty('pointer-events', 'none');
|
||||
};
|
||||
Array.from(document.body.children).forEach(hideBodySibling);
|
||||
const observer = new MutationObserver((records) => {
|
||||
records.forEach((record) => record.addedNodes.forEach(hideBodySibling));
|
||||
});
|
||||
observer.observe(document.body, { childList: true });
|
||||
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
hidden.forEach((previous, element) => {
|
||||
element.style.opacity = previous.opacity;
|
||||
element.style.pointerEvents = previous.pointerEvents;
|
||||
});
|
||||
if (htmlBackground.value) {
|
||||
document.documentElement.style.setProperty('background-color', htmlBackground.value, htmlBackground.priority);
|
||||
} else {
|
||||
document.documentElement.style.removeProperty('background-color');
|
||||
}
|
||||
if (bodyBackground.value) {
|
||||
document.body.style.setProperty('background-color', bodyBackground.value, bodyBackground.priority);
|
||||
} else {
|
||||
document.body.style.removeProperty('background-color');
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const handleVisibilityChange = () => {
|
||||
if (document.visibilityState === 'hidden') onCancel();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibilityChange);
|
||||
return () => document.removeEventListener('visibilitychange', handleVisibilityChange);
|
||||
}, [onCancel]);
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
ref={overlayRef}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.connect.scanQr')}
|
||||
className="fixed inset-0 z-[1000] flex flex-col bg-transparent px-6 pb-[calc(var(--safe-area-inset-bottom,env(safe-area-inset-bottom,0px))+24px)] pt-[calc(var(--safe-area-inset-top,env(safe-area-inset-top,0px))+24px)] text-foreground"
|
||||
>
|
||||
<div className="flex flex-1 flex-col items-center justify-center gap-6">
|
||||
<div className="aspect-square w-full max-w-72 rounded-[28px] border-2 border-foreground/90 shadow-[0_0_0_9999px_color-mix(in_srgb,var(--surface-background)_18%,transparent)]" aria-hidden />
|
||||
<p className="max-w-sm rounded-[16px] border border-border/60 bg-background px-4 py-3 text-center typography-body text-foreground shadow-sm">
|
||||
{t('mobile.connect.welcome.scanHint')}
|
||||
</p>
|
||||
</div>
|
||||
<Button type="button" variant="outline" size="lg" className="mx-auto min-h-12 w-full max-w-sm bg-background" onClick={onCancel}>
|
||||
<Icon name="close" className="size-[18px]" />
|
||||
{t('mobile.instances.cancelEdit')}
|
||||
</Button>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileQrConnectionLoading: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
return createPortal(
|
||||
<div role="status" className="fixed inset-0 z-[1000] flex flex-col items-center justify-center gap-5 bg-background px-6 text-foreground">
|
||||
<OpenChamberLogo width={96} height={96} isAnimated />
|
||||
<div className="flex items-center gap-2 typography-ui-label text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-[18px] animate-spin" />
|
||||
<span>{t('mobile.connect.connecting')}</span>
|
||||
</div>
|
||||
</div>,
|
||||
document.body,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,575 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import type { IconName } from '@/components/icon/icons';
|
||||
import { ProviderLogo } from '@/components/ui/ProviderLogo';
|
||||
import { preloadProviderLogos } from '@/hooks/useProviderLogo';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { clampPercent, formatQuotaResetLabel, formatQuotaValueLabel, formatWindowLabel, QUOTA_PROVIDERS, resolveUsageTone } from '@/lib/quota';
|
||||
import { getDisplayModelName } from '@/lib/quota/model-families';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useConfigStore } from '@/stores/useConfigStore';
|
||||
import { useQuotaAutoRefresh, useQuotaStore } from '@/stores/useQuotaStore';
|
||||
import type { QuotaProviderId, UsageWindow } from '@/types';
|
||||
import { useUIStore, type TimeFormatPreference } from '@/stores/useUIStore';
|
||||
import { useSelectionStore } from '@/sync/selection-store';
|
||||
import { useSessionMessages } from '@/sync/sync-context';
|
||||
|
||||
const TABLET_METADATA_POPOVER_WIDTH = 380;
|
||||
|
||||
const getNumericLimit = (limit: unknown, key: 'context' | 'output'): number | undefined => {
|
||||
if (!limit || typeof limit !== 'object') return undefined;
|
||||
const value = (limit as Partial<Record<'context' | 'output', unknown>>)[key];
|
||||
return typeof value === 'number' && Number.isFinite(value) ? value : undefined;
|
||||
};
|
||||
|
||||
const getTokenCount = (value: unknown): number => (
|
||||
typeof value === 'number' && Number.isFinite(value) ? value : 0
|
||||
);
|
||||
|
||||
const formatTokens = (value: number): string => {
|
||||
if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
|
||||
if (value >= 1_000) return `${(value / 1_000).toFixed(1)}K`;
|
||||
return String(value);
|
||||
};
|
||||
|
||||
type MobileUsageLimitRow = {
|
||||
key: string;
|
||||
label: string;
|
||||
subtitle?: string;
|
||||
window: UsageWindow;
|
||||
};
|
||||
|
||||
type MobileUsageProviderGroup = {
|
||||
providerId: QuotaProviderId;
|
||||
providerName: string;
|
||||
rows: MobileUsageLimitRow[];
|
||||
status: string | null;
|
||||
};
|
||||
|
||||
type ContextDisplay = {
|
||||
percentage: number;
|
||||
tokens: string;
|
||||
colorClass: string;
|
||||
} | null;
|
||||
|
||||
const getWindowValueClass = (window: UsageWindow): string => {
|
||||
const usedPercent = window.usedPercent;
|
||||
if (typeof usedPercent !== 'number' || !Number.isFinite(usedPercent)) return 'text-foreground';
|
||||
if (usedPercent >= 80) return 'text-[var(--status-error)]';
|
||||
if (usedPercent >= 50) return 'text-[var(--status-warning)]';
|
||||
return 'text-foreground';
|
||||
};
|
||||
|
||||
const ContextProgressIcon: React.FC<{ percentage: number }> = ({ percentage }) => {
|
||||
const progressPct = clampPercent(percentage) ?? 0;
|
||||
const tone = resolveUsageTone(percentage);
|
||||
const progressColor = tone === 'critical'
|
||||
? 'var(--status-error)'
|
||||
: tone === 'warn'
|
||||
? 'var(--status-warning)'
|
||||
: 'var(--status-success)';
|
||||
const size = 18;
|
||||
const stroke = 3;
|
||||
const radius = (size - stroke) / 2;
|
||||
const circumference = 2 * Math.PI * radius;
|
||||
|
||||
return (
|
||||
<svg
|
||||
viewBox={`0 0 ${size} ${size}`}
|
||||
className="size-[18px] -rotate-90"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(progressPct)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke="var(--interactive-border)"
|
||||
strokeWidth={stroke}
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={radius}
|
||||
fill="none"
|
||||
stroke={progressColor}
|
||||
strokeWidth={stroke}
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={circumference}
|
||||
strokeDashoffset={circumference * (1 - progressPct / 100)}
|
||||
className="transition-[stroke-dashoffset,stroke] duration-300"
|
||||
/>
|
||||
</svg>
|
||||
);
|
||||
};
|
||||
|
||||
const MetadataRow: React.FC<{
|
||||
icon?: IconName;
|
||||
iconNode?: React.ReactNode;
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
}> = ({ icon, iconNode, label, children }) => (
|
||||
<div className="flex min-w-0 items-center gap-3 rounded-xl px-2.5 py-2.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
{iconNode ?? (icon ? <Icon name={icon} className="size-[18px]" /> : null)}
|
||||
</span>
|
||||
<span className="shrink-0 typography-ui-label text-muted-foreground">{label}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-right typography-ui-label font-medium text-foreground">
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const SessionMetadataOverlay: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
contextDisplay: ContextDisplay;
|
||||
usageGroups: MobileUsageProviderGroup[];
|
||||
usageDisplayMode: 'usage' | 'remaining';
|
||||
isUsageLoading: boolean;
|
||||
timeFormatPreference: TimeFormatPreference;
|
||||
}> = ({ open, onClose, anchorRef, contextDisplay, usageGroups, usageDisplayMode, isUsageLoading, timeFormatPreference }) => {
|
||||
const { t } = useI18n();
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = React.useState(open);
|
||||
const [isExiting, setIsExiting] = React.useState(false);
|
||||
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
||||
// broken — render a popover anchored to the metadata button instead.
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [anchorLeft, setIpadAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport. Anchor the popover in the
|
||||
// wrapper's own coordinate space — viewport-based lefts would double-count
|
||||
// the sidebar offset.
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isTabletLayout || !shouldRender) return;
|
||||
const compute = () => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!anchorRect || !wrapperRect) {
|
||||
setIpadAnchorLeft(null);
|
||||
return;
|
||||
}
|
||||
const relativeLeft = anchorRect.left - wrapperRect.left;
|
||||
const left = Math.min(
|
||||
Math.max(relativeLeft, 8),
|
||||
Math.max(8, wrapperRect.width - TABLET_METADATA_POPOVER_WIDTH - 8),
|
||||
);
|
||||
setIpadAnchorLeft(left);
|
||||
};
|
||||
compute();
|
||||
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
||||
// toggle/resize, orientation change) — the header buttons move with it.
|
||||
const wrapper = wrapperRef.current;
|
||||
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
||||
|
||||
const isPopover = isTabletLayout && anchorLeft !== null;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setShouldRender(true);
|
||||
setIsExiting(false);
|
||||
return;
|
||||
}
|
||||
|
||||
if (!shouldRender) return;
|
||||
setIsExiting(true);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsExiting(false);
|
||||
}, 140);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [open, shouldRender]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
const closeIfOutside = (event: PointerEvent | WheelEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
|
||||
document.addEventListener('pointerdown', closeIfOutside, true);
|
||||
document.addEventListener('wheel', closeIfOutside, true);
|
||||
return () => {
|
||||
document.removeEventListener('pointerdown', closeIfOutside, true);
|
||||
document.removeEventListener('wheel', closeIfOutside, true);
|
||||
};
|
||||
}, [anchorRef, onClose, open]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
className={cn(
|
||||
'overflow-y-auto overscroll-contain rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-metadata-out' : 'session-metadata-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_METADATA_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{contextDisplay ? (
|
||||
<MetadataRow
|
||||
iconNode={<ContextProgressIcon percentage={contextDisplay.percentage} />}
|
||||
label={t('mobile.header.metadata.context')}
|
||||
>
|
||||
<span className="inline-flex items-baseline gap-1.5 tabular-nums">
|
||||
<span className={cn('font-semibold', contextDisplay.colorClass)}>{contextDisplay.percentage.toFixed(1)}%</span>
|
||||
<span className="text-muted-foreground">{contextDisplay.tokens}</span>
|
||||
</span>
|
||||
</MetadataRow>
|
||||
) : null}
|
||||
<MobileUsageLimits
|
||||
groups={usageGroups}
|
||||
displayMode={usageDisplayMode}
|
||||
isLoading={isUsageLoading}
|
||||
timeFormatPreference={timeFormatPreference}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes session-metadata-in {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes session-metadata-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const MobileUsageLimits: React.FC<{
|
||||
groups: MobileUsageProviderGroup[];
|
||||
displayMode: 'usage' | 'remaining';
|
||||
isLoading: boolean;
|
||||
timeFormatPreference: TimeFormatPreference;
|
||||
}> = ({ groups, displayMode, isLoading, timeFormatPreference }) => {
|
||||
const { t } = useI18n();
|
||||
const modeLabel = displayMode === 'remaining' ? t('header.services.remaining') : t('header.services.used');
|
||||
|
||||
// First open often races the quota fetch (~2s) — show an explicit loading
|
||||
// row instead of collapsing to an empty overlay.
|
||||
if (groups.length === 0) {
|
||||
if (!isLoading) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-2.5 py-6 text-muted-foreground">
|
||||
<Icon name="loader-4" className="size-4 animate-spin" aria-hidden />
|
||||
<span className="typography-ui-label">{t('common.loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="pt-2.5">
|
||||
<div className="flex min-w-0 items-center gap-3 px-2.5 pb-1.5">
|
||||
<span className="flex size-5 shrink-0 items-center justify-center text-muted-foreground">
|
||||
<Icon name="timer" className="size-[18px]" />
|
||||
</span>
|
||||
<span className="shrink-0 typography-ui-label text-muted-foreground">
|
||||
{t('mobile.header.metadata.usage')}
|
||||
</span>
|
||||
<span className="inline-flex min-w-0 flex-1 items-center justify-end gap-1.5 typography-ui-label text-muted-foreground">
|
||||
{isLoading ? <Icon name="refresh" className="size-3.5 animate-spin" /> : null}
|
||||
<span className="truncate">{modeLabel}</span>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
{groups.map((group) => (
|
||||
<div key={group.providerId} className="min-w-0 rounded-xl bg-[var(--surface-muted)] p-2.5">
|
||||
<div className="flex min-w-0 items-center gap-2">
|
||||
<ProviderLogo providerId={group.providerId} className="size-4 shrink-0" />
|
||||
<span className="min-w-0 flex-1 truncate typography-ui-label font-medium text-foreground">
|
||||
{group.providerName}
|
||||
</span>
|
||||
{group.status && group.rows.length === 0 ? (
|
||||
<span className="shrink-0 truncate typography-micro text-muted-foreground">
|
||||
{group.status}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{group.rows.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{group.rows.map((row) => {
|
||||
const displayPercent = displayMode === 'remaining' ? row.window.remainingPercent : row.window.usedPercent;
|
||||
const metricLabel = formatQuotaValueLabel(row.window.valueLabel, displayPercent);
|
||||
const resetLabel = formatQuotaResetLabel(
|
||||
row.window.resetAt,
|
||||
row.window.resetAfterFormatted ?? row.window.resetAtFormatted,
|
||||
timeFormatPreference,
|
||||
);
|
||||
return (
|
||||
<div key={row.key} className="flex min-w-0 items-baseline justify-between gap-3">
|
||||
<span className="inline-flex min-w-0 flex-1 items-baseline gap-1.5">
|
||||
<span className="truncate typography-ui-label text-muted-foreground">
|
||||
{row.subtitle ? `${row.subtitle} · ${row.label}` : row.label}
|
||||
</span>
|
||||
{resetLabel ? (
|
||||
<span className="shrink-0 truncate typography-micro text-muted-foreground/70">{resetLabel}</span>
|
||||
) : null}
|
||||
</span>
|
||||
<span className={cn('shrink-0 typography-ui-label font-semibold tabular-nums', getWindowValueClass(row.window))}>
|
||||
{metricLabel === '-' ? '' : metricLabel}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
{group.status && group.rows.length > 0 ? (
|
||||
<div className="mt-1.5 typography-micro text-muted-foreground">{group.status}</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export const MobileSessionMetadataButton = React.memo(function MobileSessionMetadataButton({
|
||||
open,
|
||||
onOpenChange,
|
||||
currentSessionId,
|
||||
effectiveDirectory,
|
||||
isNewSessionDraftOpen,
|
||||
}: {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean | ((open: boolean) => boolean)) => void;
|
||||
currentSessionId: string | null;
|
||||
effectiveDirectory: string | null;
|
||||
isNewSessionDraftOpen: boolean;
|
||||
}) {
|
||||
const { t } = useI18n();
|
||||
const metadataTriggerRef = React.useRef<HTMLButtonElement>(null);
|
||||
const activeSessionMessages = useSessionMessages(currentSessionId ?? '', effectiveDirectory || undefined);
|
||||
const providers = useConfigStore((state) => state.providers);
|
||||
const currentProviderId = useConfigStore((state) => state.currentProviderId);
|
||||
const currentModelId = useConfigStore((state) => state.currentModelId);
|
||||
const getModelMetadata = useConfigStore((state) => state.getModelMetadata);
|
||||
useConfigStore((state) => state.modelsMetadata.size);
|
||||
const savedSessionModel = useSelectionStore(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? state.sessionModelSelections.get(currentSessionId) ?? null : null),
|
||||
[currentSessionId],
|
||||
),
|
||||
);
|
||||
const quotaResults = useQuotaStore((state) => state.results);
|
||||
const loadQuotaSettings = useQuotaStore((state) => state.loadSettings);
|
||||
const fetchAllQuotas = useQuotaStore((state) => state.fetchAllQuotas);
|
||||
const isQuotaLoading = useQuotaStore((state) => state.isLoading);
|
||||
const quotaDisplayMode = useQuotaStore((state) => state.displayMode);
|
||||
const dropdownProviderIds = useQuotaStore((state) => state.dropdownProviderIds);
|
||||
const selectedQuotaModels = useQuotaStore((state) => state.selectedModels);
|
||||
const timeFormatPreference = useUIStore((state) => state.timeFormatPreference);
|
||||
|
||||
useQuotaAutoRefresh();
|
||||
|
||||
React.useEffect(() => {
|
||||
void loadQuotaSettings();
|
||||
}, [loadQuotaSettings]);
|
||||
|
||||
React.useEffect(() => {
|
||||
preloadProviderLogos(dropdownProviderIds);
|
||||
}, [dropdownProviderIds]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || isQuotaLoading) return;
|
||||
const missingEnabledProvider = dropdownProviderIds.some((providerId) => (
|
||||
!quotaResults.some((result) => result.providerId === providerId)
|
||||
));
|
||||
if (!missingEnabledProvider) return;
|
||||
void fetchAllQuotas();
|
||||
}, [dropdownProviderIds, fetchAllQuotas, isQuotaLoading, open, quotaResults]);
|
||||
|
||||
const latestMessageModel = React.useMemo(() => {
|
||||
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
|
||||
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
|
||||
model?: { providerID?: string; modelID?: string };
|
||||
};
|
||||
if (message.role !== 'user') continue;
|
||||
const providerID = typeof message.model?.providerID === 'string' && message.model.providerID.trim().length > 0
|
||||
? message.model.providerID
|
||||
: undefined;
|
||||
const modelID = typeof message.model?.modelID === 'string' && message.model.modelID.trim().length > 0
|
||||
? message.model.modelID
|
||||
: undefined;
|
||||
if (providerID && modelID) return { providerID, modelID };
|
||||
}
|
||||
return null;
|
||||
}, [activeSessionMessages]);
|
||||
|
||||
const modelRef = latestMessageModel
|
||||
?? (savedSessionModel ? { providerID: savedSessionModel.providerId, modelID: savedSessionModel.modelId } : null)
|
||||
?? (currentProviderId && currentModelId ? { providerID: currentProviderId, modelID: currentModelId } : null);
|
||||
const provider = modelRef ? providers.find((entry) => entry.id === modelRef.providerID) : undefined;
|
||||
const liveModel = provider?.models.find((model) => model.id === modelRef?.modelID);
|
||||
const metadata = modelRef ? getModelMetadata(modelRef.providerID, modelRef.modelID) : undefined;
|
||||
const contextLimit = getNumericLimit((liveModel as { limit?: unknown } | undefined)?.limit, 'context')
|
||||
?? metadata?.limit?.context
|
||||
?? 0;
|
||||
const totalTokens = React.useMemo(() => {
|
||||
for (let i = activeSessionMessages.length - 1; i >= 0; i -= 1) {
|
||||
const message = activeSessionMessages[i] as typeof activeSessionMessages[number] & {
|
||||
tokens?: {
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
reasoning?: unknown;
|
||||
cache?: { read?: unknown; write?: unknown };
|
||||
};
|
||||
};
|
||||
if (message.role !== 'assistant' || !message.tokens) continue;
|
||||
const total = getTokenCount(message.tokens.input)
|
||||
+ getTokenCount(message.tokens.output)
|
||||
+ getTokenCount(message.tokens.reasoning)
|
||||
+ getTokenCount(message.tokens.cache?.read)
|
||||
+ getTokenCount(message.tokens.cache?.write);
|
||||
if (total > 0) return total;
|
||||
}
|
||||
return 0;
|
||||
}, [activeSessionMessages]);
|
||||
|
||||
const contextPercentage =
|
||||
!isNewSessionDraftOpen && totalTokens > 0 && contextLimit > 0
|
||||
? Math.min((totalTokens / contextLimit) * 100, 999)
|
||||
: null;
|
||||
const contextTokens = contextPercentage !== null
|
||||
? `${formatTokens(totalTokens)}/${formatTokens(contextLimit)}`
|
||||
: null;
|
||||
const contextColorClass =
|
||||
contextPercentage === null
|
||||
? ''
|
||||
: contextPercentage >= 90
|
||||
? 'text-[var(--status-error)]'
|
||||
: contextPercentage >= 75
|
||||
? 'text-[var(--status-warning)]'
|
||||
: 'text-[var(--status-success)]';
|
||||
const contextDisplay: ContextDisplay = contextPercentage !== null && contextTokens
|
||||
? { percentage: contextPercentage, tokens: contextTokens, colorClass: contextColorClass }
|
||||
: null;
|
||||
|
||||
const usageGroups = React.useMemo<MobileUsageProviderGroup[]>(() => {
|
||||
const resultsByProvider = new Map(quotaResults.map((result) => [result.providerId, result]));
|
||||
return QUOTA_PROVIDERS
|
||||
.filter((providerMeta) => dropdownProviderIds.includes(providerMeta.id))
|
||||
.filter((providerMeta) => resultsByProvider.get(providerMeta.id)?.configured === true)
|
||||
.map((providerMeta) => {
|
||||
const result = resultsByProvider.get(providerMeta.id)!;
|
||||
const rows: MobileUsageLimitRow[] = [];
|
||||
|
||||
for (const [label, window] of Object.entries(result?.usage?.windows ?? {})) {
|
||||
rows.push({
|
||||
key: `window-${label}`,
|
||||
label: formatWindowLabel(label),
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
const modelEntries = Object.entries(result?.usage?.models ?? {});
|
||||
const providerSelectedModels = selectedQuotaModels[providerMeta.id] ?? [];
|
||||
const visibleModelEntries = providerSelectedModels.length > 0
|
||||
? modelEntries.filter(([modelName]) => providerSelectedModels.includes(modelName))
|
||||
: modelEntries;
|
||||
for (const [modelName, modelUsage] of visibleModelEntries) {
|
||||
const entries = Object.entries(modelUsage.windows ?? {});
|
||||
if (entries.length === 0) continue;
|
||||
const [label, window] = entries[0];
|
||||
rows.push({
|
||||
key: `model-${modelName}-${label}`,
|
||||
label: formatWindowLabel(label),
|
||||
subtitle: getDisplayModelName(modelName),
|
||||
window,
|
||||
});
|
||||
}
|
||||
|
||||
const status = !result.ok && result.error
|
||||
? result.error
|
||||
: rows.length === 0
|
||||
? t('header.services.noRateLimitsReported')
|
||||
: null;
|
||||
|
||||
return {
|
||||
providerId: providerMeta.id,
|
||||
providerName: providerMeta.name,
|
||||
rows,
|
||||
status,
|
||||
};
|
||||
});
|
||||
}, [dropdownProviderIds, quotaResults, selectedQuotaModels, t]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || usageGroups.length === 0) return;
|
||||
preloadProviderLogos(usageGroups.map((group) => group.providerId));
|
||||
}, [open, usageGroups]);
|
||||
|
||||
return (
|
||||
<>
|
||||
<button
|
||||
ref={metadataTriggerRef}
|
||||
type="button"
|
||||
className="flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.header.openMetadataAria')}
|
||||
aria-expanded={open}
|
||||
onClick={() => onOpenChange((currentOpen) => !currentOpen)}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
{/* Live context gauge doubles as the metadata trigger: filled by the
|
||||
session's context usage, an empty ring on a fresh draft. */}
|
||||
<ContextProgressIcon percentage={contextDisplay?.percentage ?? 0} />
|
||||
</button>
|
||||
<SessionMetadataOverlay
|
||||
open={open}
|
||||
onClose={() => onOpenChange(false)}
|
||||
anchorRef={metadataTriggerRef}
|
||||
contextDisplay={contextDisplay}
|
||||
usageGroups={usageGroups}
|
||||
usageDisplayMode={quotaDisplayMode}
|
||||
isUsageLoading={isQuotaLoading}
|
||||
timeFormatPreference={timeFormatPreference}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,235 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { formatSessionCompactDateLabel } from '@/components/session/sidebar/utils';
|
||||
import { useSwitcherItems } from '@/components/session/sidebar/hooks/useSwitcherItems';
|
||||
import { useTabletLayout } from '@/lib/device';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { refreshGlobalSessions, resolveGlobalSessionDirectory } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionUnseenCount } from '@/sync/notification-store';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useGlobalSessionStatus } from '@/sync/sync-context';
|
||||
|
||||
const RECENT_SESSIONS_LIMIT = 10;
|
||||
/** Matches the metadata popover's width so both header dropdowns read as a pair. */
|
||||
const TABLET_POPOVER_WIDTH = 380;
|
||||
|
||||
const getSessionTitle = (session: Session, fallback: string): string =>
|
||||
session.title?.trim() || fallback;
|
||||
|
||||
/** One switcher row: live status (busy spinner / attention dot), title,
|
||||
"project · branch", compact time. Mirrors the desktop SessionSwitcherDropdown
|
||||
indicator conventions; no subsession chevrons on mobile by design. */
|
||||
const SwitcherRow: React.FC<{
|
||||
session: Session;
|
||||
meta: string;
|
||||
active: boolean;
|
||||
onSelect: () => void;
|
||||
}> = ({ session, meta, active, onSelect }) => {
|
||||
const { t } = useI18n();
|
||||
const status = useGlobalSessionStatus(session.id);
|
||||
const unseenCount = useSessionUnseenCount(session.id);
|
||||
const statusType = status?.type ?? 'idle';
|
||||
const isStreaming = statusType === 'busy' || statusType === 'retry';
|
||||
const showUnreadDot = !isStreaming && unseenCount > 0 && !active;
|
||||
const timeLabel = formatSessionCompactDateLabel(session.time?.updated ?? session.time?.created ?? 0);
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
className={cn(
|
||||
'flex w-full items-center gap-3 rounded-xl px-2.5 py-2 text-left transition-colors active:bg-interactive-hover focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-primary',
|
||||
active && 'bg-[color-mix(in_srgb,var(--primary)_10%,transparent)]',
|
||||
)}
|
||||
onClick={onSelect}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<span className="flex min-w-0 flex-1 flex-col">
|
||||
<span className={cn('block truncate typography-ui-label', active ? 'text-primary' : 'text-foreground')}>
|
||||
{getSessionTitle(session, t('sessions.sidebar.session.untitled'))}
|
||||
</span>
|
||||
{meta ? (
|
||||
<span className="block truncate typography-micro text-muted-foreground">{meta}</span>
|
||||
) : null}
|
||||
</span>
|
||||
{/* Activity sits on the right, before the time — no reserved left gutter. */}
|
||||
{isStreaming ? (
|
||||
<Icon name="loader-4" className="size-3.5 shrink-0 animate-spin text-primary" aria-hidden />
|
||||
) : showUnreadDot ? (
|
||||
<span className="size-1.5 shrink-0 rounded-full bg-[var(--status-info)]" aria-hidden />
|
||||
) : null}
|
||||
{timeLabel ? (
|
||||
<span className="shrink-0 typography-micro text-muted-foreground tabular-nums">{timeLabel}</span>
|
||||
) : null}
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
/** Recent-sessions popover under the mobile header, opened by tapping the
|
||||
session title. Same visual family as the metadata/usage overlay. */
|
||||
export const MobileSessionSwitcher: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
anchorRef: React.RefObject<HTMLElement | null>;
|
||||
}> = ({ open, onClose, anchorRef }) => {
|
||||
const { t } = useI18n();
|
||||
const panelRef = React.useRef<HTMLDivElement>(null);
|
||||
const [shouldRender, setShouldRender] = React.useState(open);
|
||||
const [isExiting, setIsExiting] = React.useState(false);
|
||||
// Tablet: a phone-width sheet stretched across the whole chat column looks
|
||||
// broken — anchor a popover under the title instead. Mirror image of the
|
||||
// metadata/usage popover, which anchors to the ring on the right.
|
||||
const { enabled: isTabletLayout } = useTabletLayout();
|
||||
const wrapperRef = React.useRef<HTMLDivElement>(null);
|
||||
const [anchorLeft, setAnchorLeft] = React.useState<number | null>(null);
|
||||
|
||||
// The shell has transformed ancestors, so the fixed wrapper's containing
|
||||
// block is the chat column, NOT the viewport — anchor in the wrapper's own
|
||||
// coordinate space (see SessionMetadataOverlay for the same reasoning).
|
||||
React.useLayoutEffect(() => {
|
||||
if (!open || !isTabletLayout || !shouldRender) return;
|
||||
const compute = () => {
|
||||
const anchorRect = anchorRef.current?.getBoundingClientRect();
|
||||
const wrapperRect = wrapperRef.current?.getBoundingClientRect();
|
||||
if (!anchorRect || !wrapperRect) {
|
||||
setAnchorLeft(null);
|
||||
return;
|
||||
}
|
||||
const relativeLeft = anchorRect.left - wrapperRect.left;
|
||||
setAnchorLeft(Math.min(
|
||||
Math.max(relativeLeft, 8),
|
||||
Math.max(8, wrapperRect.width - TABLET_POPOVER_WIDTH - 8),
|
||||
));
|
||||
};
|
||||
compute();
|
||||
// Re-anchor if the chat column shifts while the popover is open (sidebar
|
||||
// toggle/resize, orientation change) — the header buttons move with it.
|
||||
const wrapper = wrapperRef.current;
|
||||
if (typeof ResizeObserver === 'undefined' || !wrapper) return;
|
||||
const observer = new ResizeObserver(compute);
|
||||
observer.observe(wrapper);
|
||||
return () => observer.disconnect();
|
||||
}, [anchorRef, isTabletLayout, open, shouldRender]);
|
||||
|
||||
const isPopover = isTabletLayout && anchorLeft !== null;
|
||||
const currentSessionId = useSessionUIStore((state) => state.currentSessionId);
|
||||
const setCurrentSession = useSessionUIStore((state) => state.setCurrentSession);
|
||||
const setActiveProjectIdOnly = useProjectsStore((state) => state.setActiveProjectIdOnly);
|
||||
|
||||
const items = useSwitcherItems(open || shouldRender, { maxParents: RECENT_SESSIONS_LIMIT });
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
// Fresh authoritative snapshot on open — updated stamps re-sort recents
|
||||
// (see raiseSessionOrderingBaselines) while the cached list shows first.
|
||||
void refreshGlobalSessions();
|
||||
setShouldRender(true);
|
||||
setIsExiting(false);
|
||||
return;
|
||||
}
|
||||
if (!shouldRender) return;
|
||||
setIsExiting(true);
|
||||
const timeoutId = window.setTimeout(() => {
|
||||
setShouldRender(false);
|
||||
setIsExiting(false);
|
||||
}, 140);
|
||||
return () => window.clearTimeout(timeoutId);
|
||||
}, [open, shouldRender]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const handleKey = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') onClose();
|
||||
};
|
||||
document.addEventListener('keydown', handleKey);
|
||||
return () => document.removeEventListener('keydown', handleKey);
|
||||
}, [onClose, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const closeIfOutside = (event: PointerEvent) => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node)) {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (panelRef.current?.contains(target) || anchorRef.current?.contains(target)) return;
|
||||
onClose();
|
||||
};
|
||||
document.addEventListener('pointerdown', closeIfOutside, true);
|
||||
return () => document.removeEventListener('pointerdown', closeIfOutside, true);
|
||||
}, [anchorRef, onClose, open]);
|
||||
|
||||
const handleSelect = React.useCallback((session: Session) => {
|
||||
void setCurrentSession(session.id, resolveGlobalSessionDirectory(session));
|
||||
onClose();
|
||||
}, [onClose, setCurrentSession]);
|
||||
|
||||
if (!shouldRender) return null;
|
||||
|
||||
return (
|
||||
<div ref={wrapperRef} className="fixed inset-x-0 bottom-0 top-[calc(var(--oc-safe-area-top,0px)+var(--oc-header-height,56px))] z-20 pointer-events-none">
|
||||
<div
|
||||
ref={panelRef}
|
||||
role="dialog"
|
||||
aria-label={t('sessions.switcher.openAria')}
|
||||
className={cn(
|
||||
'flex flex-col overflow-hidden rounded-[20px] border border-border/70 bg-[var(--surface-elevated)] p-2 shadow-[0_12px_32px_rgb(0_0_0_/_0.2)] will-change-transform',
|
||||
isPopover ? 'absolute origin-top-left' : 'mx-3 mt-2',
|
||||
isExiting ? 'pointer-events-none' : 'pointer-events-auto',
|
||||
)}
|
||||
style={{
|
||||
animation: `${isExiting ? 'session-switcher-out' : 'session-switcher-in'} ${isExiting ? 140 : 170}ms cubic-bezier(0.32, 0.72, 0, 1) forwards`,
|
||||
maxHeight: 'min(72dvh, calc(100dvh - var(--oc-safe-area-top, 0px) - var(--oc-header-height, 56px) - 1rem))',
|
||||
...(isPopover
|
||||
? {
|
||||
top: 8,
|
||||
left: anchorLeft ?? 8,
|
||||
width: `min(${TABLET_POPOVER_WIDTH}px, calc(100% - 16px))`,
|
||||
}
|
||||
: null),
|
||||
}}
|
||||
>
|
||||
<div className="oc-hide-scrollbar min-h-0 flex-1 space-y-0.5 overflow-y-auto overscroll-contain">
|
||||
{items.length === 0 ? (
|
||||
<p className="px-3 py-6 text-center typography-small text-muted-foreground">
|
||||
{t('sessions.switcher.empty')}
|
||||
</p>
|
||||
) : (
|
||||
items.map((item) => {
|
||||
const session = item.node.session;
|
||||
const meta = [item.secondaryMeta?.projectLabel, item.secondaryMeta?.branchLabel]
|
||||
.filter(Boolean)
|
||||
.join(' · ');
|
||||
return (
|
||||
<SwitcherRow
|
||||
key={session.id}
|
||||
session={session}
|
||||
meta={meta}
|
||||
active={session.id === currentSessionId}
|
||||
onSelect={() => {
|
||||
if (item.projectId) setActiveProjectIdOnly(item.projectId);
|
||||
handleSelect(session);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<style>{`
|
||||
@keyframes session-switcher-in {
|
||||
from { opacity: 0; transform: translateY(-8px) scale(0.985); }
|
||||
to { opacity: 1; transform: translateY(0) scale(1); }
|
||||
}
|
||||
@keyframes session-switcher-out {
|
||||
from { opacity: 1; transform: translateY(0) scale(1); }
|
||||
to { opacity: 0; transform: translateY(-6px) scale(0.985); }
|
||||
}
|
||||
`}</style>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,300 +0,0 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { RiArrowLeftLine, RiCloseLine } from '@remixicon/react';
|
||||
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const SURFACE_ROOT_ID = 'mobile-surface-root';
|
||||
const DISMISS_THRESHOLD_PX = 90;
|
||||
const ENTER_DELAY_MS = 16;
|
||||
// Enter-slide duration. Heavy content is revealed when this transition actually
|
||||
// ends (transitionend); this also feeds the fallback timer.
|
||||
const ENTER_DURATION_MS = 100;
|
||||
// How far below its resting position the sheet starts the enter slide. Small
|
||||
// offset → a short "rise + fade" rather than a full slide up from the bottom.
|
||||
const ENTER_OFFSET_PX = 48;
|
||||
// Extra gap above the sheet (below the top safe area) so it doesn't sit flush
|
||||
// against the very top of the app.
|
||||
const TOP_GAP_PX = 8;
|
||||
|
||||
const ensureSurfaceRoot = (): HTMLElement | null => {
|
||||
if (typeof document === 'undefined') return null;
|
||||
let root = document.getElementById(SURFACE_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = SURFACE_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
return root;
|
||||
};
|
||||
|
||||
export type MobileSurfaceShellProps = {
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
title?: React.ReactNode;
|
||||
subtitle?: React.ReactNode;
|
||||
trailing?: React.ReactNode;
|
||||
/** When set, the leading icon becomes a back arrow that calls this. Otherwise it's a close X bound to onClose. */
|
||||
onBack?: () => void;
|
||||
/** If true, disable swipe-down-to-dismiss (e.g. when a nested view should keep gesture for itself). */
|
||||
disableSwipeDismiss?: boolean;
|
||||
/** If true, render only the drag handle and let the child render its own header. */
|
||||
headerless?: boolean;
|
||||
ariaLabel?: string;
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
export const MobileSurfaceShell: React.FC<MobileSurfaceShellProps> = ({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
subtitle,
|
||||
trailing,
|
||||
onBack,
|
||||
disableSwipeDismiss = false,
|
||||
headerless = false,
|
||||
ariaLabel,
|
||||
children,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [mounted, setMounted] = React.useState(false);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
const [contentReady, setContentReady] = React.useState(false);
|
||||
const [dragOffset, setDragOffset] = React.useState(0);
|
||||
const dragStartYRef = React.useRef<number | null>(null);
|
||||
const isDraggingRef = React.useRef(false);
|
||||
const surfaceRef = React.useRef<HTMLElement | null>(null);
|
||||
const previousFocusRef = React.useRef<HTMLElement | null>(null);
|
||||
// Keep onClose in a ref so the focus/keydown effect below depends only on `open`.
|
||||
// The parent passes a fresh inline onClose on every render; if the effect depended
|
||||
// on it, each parent re-render (e.g. an SSE store update) would re-run it and
|
||||
// refocus the first element — stealing focus from whatever input the user is in
|
||||
// and collapsing the keyboard mid-edit.
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
rootRef.current = ensureSurfaceRoot();
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setMounted(true);
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
setEntered(false);
|
||||
const id = window.setTimeout(() => setMounted(false), 300);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
// Defer mounting heavy children until the enter slide finishes, so the
|
||||
// animation stays smooth instead of competing with a large content render.
|
||||
// Primary trigger is the slide's transitionend (below); this is just a
|
||||
// fallback in case it never fires (reduced motion / interrupted transition).
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setContentReady(false);
|
||||
return;
|
||||
}
|
||||
const id = window.setTimeout(() => setContentReady(true), ENTER_DELAY_MS + ENTER_DURATION_MS + 80);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
document.body.style.overflow = 'hidden';
|
||||
const focusFirstElement = () => {
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = surface.querySelector<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
);
|
||||
(focusable ?? surface).focus({ preventScroll: true });
|
||||
};
|
||||
const focusTimer = window.setTimeout(focusFirstElement, ENTER_DELAY_MS);
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape') {
|
||||
onCloseRef.current();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab') return;
|
||||
const surface = surfaceRef.current;
|
||||
if (!surface) return;
|
||||
const focusable = Array.from(surface.querySelectorAll<HTMLElement>(
|
||||
'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])',
|
||||
)).filter((element) => !element.hasAttribute('disabled') && element.getAttribute('aria-hidden') !== 'true');
|
||||
if (focusable.length === 0) {
|
||||
event.preventDefault();
|
||||
surface.focus({ preventScroll: true });
|
||||
return;
|
||||
}
|
||||
const first = focusable[0];
|
||||
const last = focusable[focusable.length - 1];
|
||||
const active = document.activeElement;
|
||||
if (event.shiftKey && active === first) {
|
||||
event.preventDefault();
|
||||
last.focus({ preventScroll: true });
|
||||
} else if (!event.shiftKey && active === last) {
|
||||
event.preventDefault();
|
||||
first.focus({ preventScroll: true });
|
||||
}
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
window.clearTimeout(focusTimer);
|
||||
document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
previousFocusRef.current?.focus?.({ preventScroll: true });
|
||||
previousFocusRef.current = null;
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const handleDragStart = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (disableSwipeDismiss) return;
|
||||
dragStartYRef.current = event.touches[0]?.clientY ?? null;
|
||||
isDraggingRef.current = true;
|
||||
};
|
||||
|
||||
const handleDragMove = (event: React.TouchEvent<HTMLDivElement>) => {
|
||||
if (!isDraggingRef.current || dragStartYRef.current == null) return;
|
||||
const currentY = event.touches[0]?.clientY ?? dragStartYRef.current;
|
||||
const delta = currentY - dragStartYRef.current;
|
||||
setDragOffset(delta > 0 ? delta : 0);
|
||||
};
|
||||
|
||||
const handleDragEnd = () => {
|
||||
if (!isDraggingRef.current) return;
|
||||
isDraggingRef.current = false;
|
||||
dragStartYRef.current = null;
|
||||
if (dragOffset >= DISMISS_THRESHOLD_PX) {
|
||||
setDragOffset(0);
|
||||
onClose();
|
||||
} else {
|
||||
setDragOffset(0);
|
||||
}
|
||||
};
|
||||
|
||||
if (!mounted || !rootRef.current) return null;
|
||||
|
||||
const leading = onBack ? (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('header.actions.backAria')}
|
||||
onClick={onBack}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiArrowLeftLine className="size-5" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
className="-ml-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<RiCloseLine className="size-5" />
|
||||
</button>
|
||||
);
|
||||
|
||||
// When settled, use `none` (not translateY(0)) so the sheet isn't kept on a
|
||||
// compositing layer — that layer is clipped to the safe-area viewport on iOS,
|
||||
// leaving a scrim gap below it over the home-indicator inset.
|
||||
const visualTransform = !entered
|
||||
? `translateY(${ENTER_OFFSET_PX}px)`
|
||||
: dragOffset > 0
|
||||
? `translateY(${dragOffset}px)`
|
||||
: 'none';
|
||||
|
||||
return createPortal(
|
||||
<div
|
||||
className={cn(
|
||||
'oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-[rgb(0_0_0_/_0.45)]',
|
||||
// The opacity transition keeps the scrim on its own compositing layer,
|
||||
// which iOS Safari clips to the viewport — without it, a static scrim
|
||||
// bleeds the dim into the bottom toolbar overscroll zone. Quick fade so
|
||||
// it still feels near-instant.
|
||||
'transition-opacity duration-200 ease-out',
|
||||
entered ? 'opacity-100' : 'opacity-0',
|
||||
)}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={ariaLabel}
|
||||
onClick={onClose}
|
||||
>
|
||||
{/* Sheet is a normal flex child — mirroring MobileOverlayPanel. */}
|
||||
<section
|
||||
ref={surfaceRef}
|
||||
className="mt-auto flex min-h-0 w-full flex-col overflow-hidden rounded-t-[20px] border-t border-border/40 bg-background text-foreground"
|
||||
tabIndex={-1}
|
||||
onClick={(event) => event.stopPropagation()}
|
||||
onTransitionEnd={(event) => {
|
||||
// Reveal content exactly when the enter slide ends — not on a fixed timer.
|
||||
if (entered && event.target === event.currentTarget && event.propertyName === 'transform') {
|
||||
setContentReady(true);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
// Sized to leave the top safe area (plus a small gap) uncovered so the
|
||||
// scrim dims it and the sheet sits a few px below the very top.
|
||||
height: `calc(100% - var(--oc-safe-area-top, 0px) - ${TOP_GAP_PX}px)`,
|
||||
transform: visualTransform,
|
||||
transition: isDraggingRef.current
|
||||
? 'none'
|
||||
: `transform ${ENTER_DURATION_MS}ms cubic-bezier(0.32, 0.72, 0, 1)`,
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="shrink-0 select-none"
|
||||
onTouchStart={handleDragStart}
|
||||
onTouchMove={handleDragMove}
|
||||
onTouchEnd={handleDragEnd}
|
||||
onTouchCancel={handleDragEnd}
|
||||
>
|
||||
<div className="flex items-center justify-center pt-2 pb-1">
|
||||
<span className="h-1 w-10 rounded-full bg-[var(--surface-muted)]" aria-hidden />
|
||||
</div>
|
||||
{!headerless ? (
|
||||
<header className="flex h-[var(--oc-header-height,56px)] items-center gap-2 px-3">
|
||||
{leading}
|
||||
<div className="min-w-0 flex-1 px-1">
|
||||
{title ? (
|
||||
typeof title === 'string' ? (
|
||||
<h2 className="truncate typography-ui-label text-foreground">{title}</h2>
|
||||
) : (
|
||||
title
|
||||
)
|
||||
) : null}
|
||||
{subtitle ? (
|
||||
typeof subtitle === 'string' ? (
|
||||
<p className="truncate typography-micro text-muted-foreground">{subtitle}</p>
|
||||
) : (
|
||||
subtitle
|
||||
)
|
||||
) : null}
|
||||
</div>
|
||||
{trailing ? <div className="flex shrink-0 items-center gap-1.5">{trailing}</div> : null}
|
||||
</header>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{contentReady ? (
|
||||
<div className="h-full" style={{ animation: 'oc-surface-content-in 200ms ease-out' }}>
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
<style>{'@keyframes oc-surface-content-in { from { opacity: 0 } to { opacity: 1 } }'}</style>
|
||||
</div>,
|
||||
rootRef.current,
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,299 @@
|
||||
import React from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
import { Icon } from '@/components/icon/Icon';
|
||||
import { McpIcon } from '@/components/icons/McpIcon';
|
||||
import { McpDropdownContent } from '@/components/mcp/McpDropdown';
|
||||
import { ProjectContextPanel } from '@/components/layout/RightSidebarTabs';
|
||||
import { ErrorBoundary } from '@/components/ui/ErrorBoundary';
|
||||
import { SortableTabsStrip, type SortableTabsStripItem } from '@/components/ui/sortable-tabs-strip';
|
||||
import { TerminalView } from '@/components/views/TerminalView';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useDirectoryStore } from '@/stores/useDirectoryStore';
|
||||
import { useMcpConfigStore } from '@/stores/useMcpConfigStore';
|
||||
import { useMcpStore } from '@/stores/useMcpStore';
|
||||
|
||||
import { MobileChangesSurface } from './MobileChangesSurface';
|
||||
import { MobileFilesSurface } from './MobileFilesSurface';
|
||||
|
||||
const DRAWER_ROOT_ID = 'mobile-surface-root';
|
||||
const ENTER_DELAY_MS = 16;
|
||||
// Slightly long, decelerating slide — matches the sessions drawer so both
|
||||
// sides feel like the same piece of chrome.
|
||||
const ENTER_DURATION_MS = 320;
|
||||
const DRAWER_EASING = 'cubic-bezier(0.22, 1, 0.36, 1)';
|
||||
|
||||
export type MobileWorkspaceTab = 'changes' | 'files' | 'terminal' | 'notes' | 'mcp';
|
||||
|
||||
/** Quick MCP enable/disable toggles as a workspace pane, with its own slim
|
||||
action row (add server → settings, refresh) replacing the old fullscreen
|
||||
surface's header actions. */
|
||||
const McpWorkspacePane: React.FC<{ onOpenMcpSettings: () => void }> = ({ onOpenMcpSettings }) => {
|
||||
const { t } = useI18n();
|
||||
const [isRefreshing, setIsRefreshing] = React.useState(false);
|
||||
const currentDirectory = useDirectoryStore((state) => state.currentDirectory);
|
||||
const refreshMcpStatus = useMcpStore((state) => state.refresh);
|
||||
const loadMcpConfigs = useMcpConfigStore((state) => state.loadMcpConfigs);
|
||||
|
||||
const refresh = () => {
|
||||
if (isRefreshing) return;
|
||||
setIsRefreshing(true);
|
||||
const minSpinPromise = new Promise((resolve) => window.setTimeout(resolve, 500));
|
||||
void Promise.all([
|
||||
refreshMcpStatus({ directory: currentDirectory || null, silent: true }),
|
||||
loadMcpConfigs({ force: true }),
|
||||
minSpinPromise,
|
||||
]).finally(() => setIsRefreshing(false));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex h-full min-h-0 flex-col">
|
||||
<div className="flex shrink-0 items-center justify-end gap-1 px-2 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={onOpenMcpSettings}
|
||||
aria-label={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
title={t('settings.mcp.sidebar.actions.addServerTitle')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="add" className="size-5" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="flex size-10 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground disabled:opacity-60 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
onClick={refresh}
|
||||
disabled={isRefreshing}
|
||||
aria-label={t('mcpDropdown.actions.refreshAria')}
|
||||
title={t('mcpDropdown.actions.refreshAria')}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="refresh" className={cn('size-5', isRefreshing && 'animate-spin')} />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1">
|
||||
<McpDropdownContent
|
||||
active
|
||||
className="h-full"
|
||||
listClassName="max-h-none"
|
||||
hideHeader
|
||||
mobileListDensity
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
/** The workspace surfaces as tabs (Changes / Files / Terminal / Notes / MCP).
|
||||
|
||||
Two hosts, same content and same state:
|
||||
- `drawer` (default) covers the app and slides in from the right edge —
|
||||
the phone, and a tablet in portrait where a side panel would leave no
|
||||
usable chat column;
|
||||
- `panel` renders inline so the caller can size it as a real sidebar
|
||||
beside the chat (tablet, landscape). The caller owns the width and the
|
||||
open/close animation there; this component only fills it.
|
||||
|
||||
Closes via the header X, Escape (unless the terminal tab owns the keys), or
|
||||
the Android back button (handled by MobileShell). */
|
||||
export const MobileWorkspaceDrawer: React.FC<{
|
||||
open: boolean;
|
||||
onClose: () => void;
|
||||
tab: MobileWorkspaceTab;
|
||||
onTabChange: (tab: MobileWorkspaceTab) => void;
|
||||
/** When set, the Changes tab opens directly into the per-file diff. */
|
||||
pendingChangesDiff: { path: string; staged: boolean } | null;
|
||||
/** Notes tab: opens a plan fullscreen (layered above the drawer). */
|
||||
onOpenPlan: (plan: { path: string; title: string }) => void;
|
||||
/** MCP tab: jump to the MCP settings page pre-seeded with a new server draft. */
|
||||
onOpenMcpSettings: () => void;
|
||||
variant?: 'drawer' | 'panel';
|
||||
}> = ({ open, onClose, tab, onTabChange, pendingChangesDiff, onOpenPlan, onOpenMcpSettings, variant = 'drawer' }) => {
|
||||
const { t } = useI18n();
|
||||
const rootRef = React.useRef<HTMLElement | null>(null);
|
||||
const [entered, setEntered] = React.useState(false);
|
||||
// Kept visible through the exit slide; flipped to hidden once it finishes.
|
||||
const [visible, setVisible] = React.useState(open);
|
||||
const onCloseRef = React.useRef(onClose);
|
||||
React.useEffect(() => {
|
||||
onCloseRef.current = onClose;
|
||||
}, [onClose]);
|
||||
const tabRef = React.useRef(tab);
|
||||
React.useEffect(() => {
|
||||
tabRef.current = tab;
|
||||
}, [tab]);
|
||||
|
||||
// Tabs the user has actually opened — their panes stay mounted afterwards.
|
||||
const [visitedTabs, setVisitedTabs] = React.useState<ReadonlySet<MobileWorkspaceTab>>(() => new Set());
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setVisitedTabs((current) => {
|
||||
if (current.has(tab)) return current;
|
||||
const next = new Set(current);
|
||||
next.add(tab);
|
||||
return next;
|
||||
});
|
||||
}, [open, tab]);
|
||||
|
||||
if (typeof document !== 'undefined' && !rootRef.current) {
|
||||
let root = document.getElementById(DRAWER_ROOT_ID);
|
||||
if (!root) {
|
||||
root = document.createElement('div');
|
||||
root.id = DRAWER_ROOT_ID;
|
||||
document.body.appendChild(root);
|
||||
}
|
||||
rootRef.current = root;
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (open) {
|
||||
setVisible(true);
|
||||
const id = window.setTimeout(() => setEntered(true), ENTER_DELAY_MS);
|
||||
return () => window.clearTimeout(id);
|
||||
}
|
||||
setEntered(false);
|
||||
const id = window.setTimeout(() => setVisible(false), ENTER_DURATION_MS + 40);
|
||||
return () => window.clearTimeout(id);
|
||||
}, [open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
// Only the full-cover drawer owns the page scroll; the inline panel sits
|
||||
// inside the shell and must leave the chat beside it scrollable.
|
||||
const previousOverflow = document.body.style.overflow;
|
||||
if (variant === 'drawer') document.body.style.overflow = 'hidden';
|
||||
const handleKeyDown = (event: KeyboardEvent) => {
|
||||
// The terminal owns Escape (it goes to the PTY) — don't hijack it.
|
||||
if (event.key === 'Escape' && tabRef.current !== 'terminal') onCloseRef.current();
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => {
|
||||
if (variant === 'drawer') document.body.style.overflow = previousOverflow;
|
||||
document.removeEventListener('keydown', handleKeyDown);
|
||||
};
|
||||
}, [open, variant]);
|
||||
|
||||
if (variant === 'drawer' && !rootRef.current) return null;
|
||||
|
||||
const tabItems: SortableTabsStripItem[] = [
|
||||
{ id: 'changes', label: t('mobile.menu.changes'), icon: <Icon name="git-branch" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'files', label: t('mobile.menu.files'), icon: <Icon name="file-text" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'terminal', label: t('mobile.menu.terminal'), icon: <Icon name="terminal" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'notes', label: t('contextRail.surface.notes'), icon: <Icon name="sticky-note" className="h-3.5 w-3.5" /> },
|
||||
{ id: 'mcp', label: t('mobile.menu.mcp'), icon: <McpIcon className="h-3.5 w-3.5" /> },
|
||||
];
|
||||
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex h-[var(--oc-header-height,56px)] shrink-0 items-center gap-2 px-3">
|
||||
<div className="flex h-9 min-w-0 flex-1 items-center">
|
||||
{/* Mounted only while shown; nonCompositedIndicator keeps the active
|
||||
pill off its own compositing layer — creating one inside the
|
||||
drawer's slide flickers in WKWebView. */}
|
||||
{visible ? (
|
||||
<SortableTabsStrip
|
||||
items={tabItems}
|
||||
activeId={tab}
|
||||
onSelect={(id) => onTabChange(id as MobileWorkspaceTab)}
|
||||
layoutMode="fit"
|
||||
variant="active-pill"
|
||||
nonCompositedIndicator
|
||||
// Five tabs don't fit with labels — the active tab keeps
|
||||
// icon + label, the rest collapse to icons.
|
||||
inactiveTabsIconOnly
|
||||
className="h-full"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="-mr-1 flex size-10 shrink-0 items-center justify-center rounded-full text-muted-foreground transition-colors hover:bg-interactive-hover hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary"
|
||||
aria-label={t('mobile.surface.closeAria')}
|
||||
onClick={onClose}
|
||||
style={{ touchAction: 'manipulation' }}
|
||||
>
|
||||
<Icon name="close" className="size-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="min-h-0 flex-1 overflow-hidden">
|
||||
{/* Panes stay MOUNTED once visited (hidden when inactive/closed), so
|
||||
reopening the drawer lands exactly where the user left off — an
|
||||
open diff, an edited file, an attached terminal. */}
|
||||
{visitedTabs.has('changes') ? (
|
||||
<div
|
||||
// A newly requested per-file diff remounts the pane so
|
||||
// initialDiffPath applies; plain reopens keep the state.
|
||||
key={pendingChangesDiff ? `changes:${pendingChangesDiff.path}:${pendingChangesDiff.staged}` : 'changes'}
|
||||
className={cn('h-full', tab !== 'changes' && 'hidden')}
|
||||
>
|
||||
<ErrorBoundary>
|
||||
<MobileChangesSurface
|
||||
initialDiffPath={pendingChangesDiff?.path ?? null}
|
||||
initialDiffStaged={pendingChangesDiff?.staged === true}
|
||||
/>
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('files') ? (
|
||||
<div className={cn('h-full', tab !== 'files' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<MobileFilesSurface />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('terminal') ? (
|
||||
<div className={cn('h-full', tab !== 'terminal' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<TerminalView visible={open && tab === 'terminal'} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('notes') ? (
|
||||
<div className={cn('h-full', tab !== 'notes' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<ProjectContextPanel onActionComplete={onClose} onOpenPlan={onOpenPlan} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
{visitedTabs.has('mcp') ? (
|
||||
<div className={cn('h-full', tab !== 'mcp' && 'hidden')}>
|
||||
<ErrorBoundary>
|
||||
<McpWorkspacePane onOpenMcpSettings={onOpenMcpSettings} />
|
||||
</ErrorBoundary>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
if (variant === 'panel') {
|
||||
// The caller animates the width; the content itself is plain flow so it
|
||||
// never gets its own compositing layer (iOS clips those to the safe-area
|
||||
// viewport, which is exactly what the drawer's settled `transform: none`
|
||||
// avoids on the other host).
|
||||
return <div className="flex h-full min-h-0 flex-col bg-background text-foreground">{body}</div>;
|
||||
}
|
||||
|
||||
return createPortal(
|
||||
<section
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label={t('mobile.header.openWorkspaceAria')}
|
||||
aria-hidden={!open}
|
||||
className="oc-keyboard-inset-surface fixed inset-0 z-50 flex flex-col bg-background text-foreground"
|
||||
style={{
|
||||
paddingTop: 'var(--oc-safe-area-top, 0px)',
|
||||
// Settled state drops the transform entirely so the drawer isn't kept
|
||||
// on a compositing layer (iOS clips those to the safe-area viewport).
|
||||
transform: entered ? 'none' : 'translateX(100%)',
|
||||
transition: `transform ${ENTER_DURATION_MS}ms ${DRAWER_EASING}`,
|
||||
visibility: visible ? 'visible' : 'hidden',
|
||||
pointerEvents: open ? 'auto' : 'none',
|
||||
}}
|
||||
>
|
||||
{body}
|
||||
</section>,
|
||||
rootRef.current as HTMLElement,
|
||||
);
|
||||
};
|
||||
@@ -7,6 +7,7 @@ import { TooltipProvider } from '@/components/ui/tooltip';
|
||||
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 { VSCodeLayout } from '@/components/layout/VSCodeLayout';
|
||||
import { usePushVisibilityBeacon } from '@/hooks/usePushVisibilityBeacon';
|
||||
import { useRouter } from '@/hooks/useRouter';
|
||||
@@ -107,6 +108,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<AgentManagerView />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
</div>
|
||||
</TooltipProvider>
|
||||
@@ -125,6 +127,7 @@ export function VSCodeApp({ apis }: VSCodeAppProps) {
|
||||
<div className="h-full text-foreground bg-background">
|
||||
<SyncAppEffects embeddedBackgroundWorkEnabled={true} />
|
||||
<VSCodeLayout />
|
||||
<OpenCodeUpdateToast />
|
||||
<Toaster position="top-center" />
|
||||
<ConfigUpdateOverlay />
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,6 @@ import React from 'react';
|
||||
|
||||
import { isCapacitorApp } from '@/lib/platform';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
|
||||
import { buildDeepLink, parseDeepLink, type DeepLinkIntent, type SessionsFilter, type ViewTarget } from './deepLinks';
|
||||
|
||||
@@ -59,9 +58,10 @@ const execute = (intent: DeepLinkIntent): boolean => {
|
||||
return true;
|
||||
|
||||
case 'status':
|
||||
// The session status panel is store-backed (useUIStore.mobileSessionPanelOpen),
|
||||
// so it opens without a shell handler — like session/new-session.
|
||||
useUIStore.getState().setMobileSessionPanelOpen(true);
|
||||
// The old input-bar status panel is gone — recent sessions with statuses
|
||||
// now live in the sessions drawer, so route status links there.
|
||||
if (!handlers.openSessions) return false;
|
||||
handlers.openSessions();
|
||||
return true;
|
||||
|
||||
case 'view':
|
||||
|
||||
@@ -0,0 +1,97 @@
|
||||
import React from 'react';
|
||||
|
||||
export const IPAD_LEFT_SIDEBAR_WIDTH = 320;
|
||||
export const IPAD_RIGHT_SIDEBAR_WIDTH = 380;
|
||||
const IPAD_SIDEBAR_MIN_WIDTH = 280;
|
||||
const IPAD_SIDEBAR_MAX_WIDTH = 560;
|
||||
/** The workspace panel holds diffs, a file editor and a terminal, so it earns
|
||||
far more room than the sessions list ever needs. */
|
||||
export const IPAD_WORKSPACE_SIDEBAR_MAX_WIDTH = 900;
|
||||
|
||||
/** Drag-resize for the iPad sidebars: same live-width mechanics as the desktop
|
||||
Sidebar (imperative styles during the drag, committed to state at the end),
|
||||
but with a finger-sized grab strip instead of a 3px hover handle. */
|
||||
export function useIpadSidebarResize(
|
||||
side: 'left' | 'right',
|
||||
storageKey: string,
|
||||
defaultWidth: number,
|
||||
maxWidth: number = IPAD_SIDEBAR_MAX_WIDTH,
|
||||
) {
|
||||
const asideRef = React.useRef<HTMLElement | null>(null);
|
||||
const [width, setWidth] = React.useState(() => {
|
||||
if (typeof window === 'undefined') return defaultWidth;
|
||||
const stored = Number.parseInt(window.localStorage.getItem(storageKey) ?? '', 10);
|
||||
if (!Number.isFinite(stored)) return defaultWidth;
|
||||
return Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, stored));
|
||||
});
|
||||
const [isResizing, setIsResizing] = React.useState(false);
|
||||
const startXRef = React.useRef(0);
|
||||
const startWidthRef = React.useRef(width);
|
||||
const liveWidthRef = React.useRef<number | null>(null);
|
||||
const pointerIdRef = React.useRef<number | null>(null);
|
||||
|
||||
const clampWidth = React.useCallback((value: number) => (
|
||||
Math.min(maxWidth, Math.max(IPAD_SIDEBAR_MIN_WIDTH, Math.round(value)))
|
||||
), [maxWidth]);
|
||||
|
||||
const applyLiveWidth = React.useCallback((nextWidth: number) => {
|
||||
const aside = asideRef.current;
|
||||
if (!aside) return;
|
||||
aside.style.width = `${nextWidth}px`;
|
||||
aside.style.minWidth = `${nextWidth}px`;
|
||||
aside.style.maxWidth = `${nextWidth}px`;
|
||||
aside.style.setProperty('--oc-ipad-sidebar-width', `${nextWidth}px`);
|
||||
}, []);
|
||||
|
||||
const handlePointerDown = React.useCallback((event: React.PointerEvent) => {
|
||||
try {
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
pointerIdRef.current = event.pointerId;
|
||||
startXRef.current = event.clientX;
|
||||
startWidthRef.current = width;
|
||||
liveWidthRef.current = width;
|
||||
setIsResizing(true);
|
||||
event.preventDefault();
|
||||
}, [width]);
|
||||
|
||||
const handlePointerMove = React.useCallback((event: React.PointerEvent) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
const delta = event.clientX - startXRef.current;
|
||||
const next = clampWidth(startWidthRef.current + (side === 'left' ? delta : -delta));
|
||||
if (liveWidthRef.current === next) return;
|
||||
liveWidthRef.current = next;
|
||||
applyLiveWidth(next);
|
||||
}, [applyLiveWidth, clampWidth, side]);
|
||||
|
||||
const handlePointerEnd = React.useCallback((event: React.PointerEvent) => {
|
||||
if (pointerIdRef.current !== event.pointerId) return;
|
||||
try {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
const finalWidth = clampWidth(liveWidthRef.current ?? startWidthRef.current);
|
||||
pointerIdRef.current = null;
|
||||
liveWidthRef.current = null;
|
||||
setIsResizing(false);
|
||||
setWidth(finalWidth);
|
||||
try {
|
||||
window.localStorage.setItem(storageKey, String(finalWidth));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [clampWidth, storageKey]);
|
||||
|
||||
const handleProps = React.useMemo(() => ({
|
||||
onPointerDown: handlePointerDown,
|
||||
onPointerMove: handlePointerMove,
|
||||
onPointerUp: handlePointerEnd,
|
||||
onPointerCancel: handlePointerEnd,
|
||||
}), [handlePointerDown, handlePointerEnd, handlePointerMove]);
|
||||
|
||||
return { asideRef, width, isResizing, handleProps };
|
||||
}
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
/** Kills autocorrect/autocomplete on URL/token/password fields — mobile keyboards
|
||||
mangle those values otherwise. */
|
||||
export const mobileInputKeyboardProps = {
|
||||
autoComplete: 'off',
|
||||
autoCorrect: 'off',
|
||||
spellCheck: false,
|
||||
} as const;
|
||||
|
||||
export const mobileConnectionInputClass = 'h-12 w-full rounded-[16px] border border-border/70 bg-surface-elevated px-4 text-[16px] text-foreground outline-none transition-colors placeholder:text-muted-foreground focus:border-primary focus:ring-2 focus:ring-primary/20';
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { loadMobileConnections, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
import { createMobilePasswordOperationTracker, loadMobileConnections, migrateLegacyInlineTokenRecords, upsertMobileConnection, validateMobileConnectionSession, type MobileRelayConfig } from './mobileConnections';
|
||||
|
||||
const originalFetch = globalThis.fetch;
|
||||
const originalWindow = globalThis.window;
|
||||
@@ -40,6 +40,36 @@ const testRelay: MobileRelayConfig = {
|
||||
};
|
||||
|
||||
describe('mobile connection storage', () => {
|
||||
test('cancellation invalidates an in-flight password completion', async () => {
|
||||
const tracker = createMobilePasswordOperationTracker();
|
||||
const operation = tracker.begin();
|
||||
let resolveLogin: () => void = () => {
|
||||
throw new Error('Login was not started');
|
||||
};
|
||||
let switchedRuntime = false;
|
||||
const completion = new Promise<void>((resolve) => { resolveLogin = resolve; }).then(() => {
|
||||
if (tracker.isCurrent(operation)) switchedRuntime = true;
|
||||
});
|
||||
|
||||
tracker.cancel();
|
||||
resolveLogin();
|
||||
await completion;
|
||||
|
||||
expect(switchedRuntime).toBe(false);
|
||||
});
|
||||
|
||||
test('removes inline tokens only after each secure migration succeeds', async () => {
|
||||
const result = await migrateLegacyInlineTokenRecords([
|
||||
{ id: 'ok', url: 'http://ok.example', clientToken: 'token-ok' },
|
||||
{ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' },
|
||||
], async (url) => url.includes('ok.example'));
|
||||
|
||||
expect(result.migrated).toBe(1);
|
||||
expect(result.failed).toBe(1);
|
||||
expect(result.records[0]).toEqual({ id: 'ok', url: 'http://ok.example', hasToken: true });
|
||||
expect(result.records[1]).toEqual({ id: 'failed', url: 'http://failed.example', clientToken: 'token-failed' });
|
||||
});
|
||||
|
||||
test('entries persisted before candidates migrate to a single direct candidate', async () => {
|
||||
try {
|
||||
installTestWindow();
|
||||
|
||||
@@ -72,6 +72,20 @@ const MOBILE_SECURE_TIMEOUT_MS = 3000;
|
||||
// feels instant instead of hanging for seconds.
|
||||
const MOBILE_FAST_PROBE_TIMEOUT_MS = 2500;
|
||||
|
||||
export const createMobilePasswordOperationTracker = () => {
|
||||
let current = 0;
|
||||
return {
|
||||
begin: (): number => {
|
||||
current += 1;
|
||||
return current;
|
||||
},
|
||||
cancel: (): void => {
|
||||
current += 1;
|
||||
},
|
||||
isCurrent: (operation: number): boolean => operation === current,
|
||||
};
|
||||
};
|
||||
|
||||
export type MobileConnectionMode = 'direct' | 'relay';
|
||||
|
||||
// Persisted relay transport config. This is connection metadata, not a secret
|
||||
@@ -691,6 +705,30 @@ const deleteSecureToken = async (key: string): Promise<void> => {
|
||||
|
||||
// One-time migration: a legacy localStorage record on native might still carry an
|
||||
// inline `clientToken`. Move it into the secure store and strip the metadata.
|
||||
export const migrateLegacyInlineTokenRecords = async (
|
||||
records: unknown[],
|
||||
migrateToken: (url: string, token: string) => Promise<boolean>,
|
||||
): Promise<{ records: unknown[]; migrated: number; failed: number }> => {
|
||||
let migrated = 0;
|
||||
let failed = 0;
|
||||
const next = await Promise.all(records.map(async (item) => {
|
||||
if (!item || typeof item !== 'object') return item;
|
||||
const record = item as Record<string, unknown>;
|
||||
const url = typeof record.url === 'string' ? record.url : null;
|
||||
const token = typeof record.clientToken === 'string' ? record.clientToken.trim() : '';
|
||||
if (!url || !token) return item;
|
||||
if (!await migrateToken(url, token)) {
|
||||
failed += 1;
|
||||
return item;
|
||||
}
|
||||
migrated += 1;
|
||||
const { clientToken: _removed, ...metadata } = record;
|
||||
void _removed;
|
||||
return { ...metadata, hasToken: true };
|
||||
}));
|
||||
return { records: next, migrated, failed };
|
||||
};
|
||||
|
||||
const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
if (typeof window === 'undefined' || !isCapacitorApp()) return;
|
||||
let parsed: unknown;
|
||||
@@ -707,11 +745,20 @@ const migrateLegacyInlineTokens = async (): Promise<void> => {
|
||||
&& Boolean((item as { clientToken: string }).clientToken.trim()));
|
||||
if (legacy.length === 0) return;
|
||||
logStorage('secure:migrate-start', { count: legacy.length });
|
||||
for (const { url, clientToken } of legacy) {
|
||||
await writeSecureToken(getConnectionStorageKey(url), clientToken);
|
||||
const result = await migrateLegacyInlineTokenRecords(parsed, async (url, token) => {
|
||||
const key = getConnectionStorageKey(url);
|
||||
if (!await writeSecureToken(key, token)) return false;
|
||||
return await readSecureToken(key) === token;
|
||||
});
|
||||
if (result.migrated > 0) {
|
||||
try {
|
||||
window.localStorage.setItem(MOBILE_CONNECTIONS_STORAGE_KEY, JSON.stringify(result.records));
|
||||
} catch (error) {
|
||||
console.warn('[mobile-storage] failed to finalize secure token migration', error);
|
||||
return;
|
||||
}
|
||||
}
|
||||
writeConnections(readConnections());
|
||||
logStorage('secure:migrate-done', { count: legacy.length });
|
||||
logStorage('secure:migrate-done', { migrated: result.migrated, failed: result.failed });
|
||||
};
|
||||
|
||||
export const loadMobileConnections = async (): Promise<MobileSavedConnection[]> => {
|
||||
@@ -797,7 +844,12 @@ const probeConnectionCandidates = async (
|
||||
continue;
|
||||
}
|
||||
}
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions);
|
||||
// With a bearer token, probe EXACTLY the way the runtime authenticates:
|
||||
// bearer-only, no cookies. A leftover valid oc_ui_session cookie in the
|
||||
// WebView otherwise answers "authenticated" for a revoked/expired token,
|
||||
// the probe passes, and the app dies later on bootstrap's bearer-only
|
||||
// requests. Cookie auth stays for the token-less (browser) flow.
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
||||
if (session?.status === 401) return { status: 'needs-login' };
|
||||
if (!session || (!session.ok && session.status !== 404)) continue;
|
||||
const status = await readSessionStatus(session);
|
||||
@@ -929,28 +981,45 @@ export const getAutoConnectTargetLabel = (): string | null => {
|
||||
// the runtime endpoint when reachable AND we already have a usable bearer token;
|
||||
// returns false — caller shows the connect screen — when there is no saved
|
||||
// instance, it's unreachable, or it needs a (re)login. No prompts or UI state.
|
||||
export const autoConnectLastInstance = async (): Promise<boolean> => {
|
||||
export type AutoConnectOutcome =
|
||||
| { status: 'connected' }
|
||||
/** No saved instance / no saved token — nothing to report to the user. */
|
||||
| { status: 'no-candidate' }
|
||||
| { status: 'unreachable'; label: string }
|
||||
/** The saved token was rejected (expired/revoked) — the user must sign in again. */
|
||||
| { status: 'needs-login'; label: string };
|
||||
|
||||
export const autoConnectLastInstance = async (): Promise<AutoConnectOutcome> => {
|
||||
await migrateLegacyInlineTokens();
|
||||
const candidate = readConnections()[0]; // sorted most-recent-first
|
||||
if (!candidate) return false;
|
||||
if (!candidate) return { status: 'no-candidate' };
|
||||
|
||||
// The runtime transport needs a bearer token; only auto-connect when one is
|
||||
// already saved. A missing/expired token must go through the login UI.
|
||||
let token: string | undefined;
|
||||
if (isCapacitorApp()) {
|
||||
if (!candidate.hasToken) return false;
|
||||
if (!candidate.hasToken) {
|
||||
return { status: 'no-candidate' };
|
||||
}
|
||||
token = await readSecureToken(secureTokenKeyOf(candidate));
|
||||
if (!token) return false;
|
||||
if (!token) {
|
||||
return { status: 'no-candidate' };
|
||||
}
|
||||
} else {
|
||||
token = candidate.clientToken;
|
||||
if (!token) return false;
|
||||
if (!token) return { status: 'no-candidate' };
|
||||
}
|
||||
|
||||
const result = await probeConnectionCandidates(candidate.candidates, token);
|
||||
if (result.status !== 'ok') return false;
|
||||
// Fast probe: the cold-launch splash should decide in a couple of seconds,
|
||||
// not sit through the full connect timeouts on a dead LAN candidate. A slow
|
||||
// network that fails the fast probe still lands on the connect screen where
|
||||
// a manual tap retries with the full budget.
|
||||
const result = await probeConnectionCandidates(candidate.candidates, token, { fast: true });
|
||||
if (result.status === 'needs-login') return { status: 'needs-login', label: candidate.label };
|
||||
if (result.status !== 'ok') return { status: 'unreachable', label: candidate.label };
|
||||
await upsertMobileConnection({ id: candidate.id, label: candidate.label, candidates: candidate.candidates }); // bump lastUsedAt (keeps token)
|
||||
switchToTransport(result.transport, token, { runtimeKey: secureTokenKeyOf(candidate) });
|
||||
return true;
|
||||
return { status: 'connected' };
|
||||
};
|
||||
|
||||
export const validateMobileConnectionSession = async (input: {
|
||||
@@ -972,7 +1041,9 @@ export const validateMobileConnectionSession = async (input: {
|
||||
const health = await requestWithTimeout(`${url}/health`, { method: 'GET', headers }, requestOptions);
|
||||
if (!health?.ok) return false;
|
||||
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: 'include', headers }, requestOptions);
|
||||
// Bearer-only when a token is present — see the probe note about stale
|
||||
// session cookies masking a revoked token.
|
||||
const session = await requestWithTimeout(`${url}/auth/session`, { method: 'GET', credentials: token ? 'omit' : 'include', headers }, requestOptions);
|
||||
if (!session || (!session.ok && session.status !== 404)) return false;
|
||||
|
||||
const status = await readSessionStatus(session);
|
||||
@@ -1082,7 +1153,7 @@ export const isActiveRuntimeConnection = (connection: MobileSavedConnection): bo
|
||||
return Boolean(runtimeKey) && secureTokenKeyOf(connection) === runtimeKey;
|
||||
};
|
||||
|
||||
export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'no-connection';
|
||||
export type ReprobeOutcome = 'switched' | 'unchanged' | 'unreachable' | 'needs-login' | 'no-connection';
|
||||
|
||||
// App-resume re-probe: when the app wakes (Capacitor `isActive`), the network may
|
||||
// have changed while it slept, so re-select the active device's transport and
|
||||
@@ -1116,7 +1187,8 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
||||
switchToTransport(better.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||
return 'switched';
|
||||
}
|
||||
if (better.status === 'needs-login') return 'unreachable';
|
||||
// The shared token was explicitly rejected — no transport will accept it.
|
||||
if (better.status === 'needs-login') return 'needs-login';
|
||||
|
||||
// 2. No better transport — is the current one still alive on its live channel?
|
||||
if (currentIndex >= 0) {
|
||||
@@ -1139,6 +1211,7 @@ export const reprobeActiveConnection = async (): Promise<ReprobeOutcome> => {
|
||||
switchToTransport(fallback.transport, token, { runtimeKey: secureTokenKeyOf(active) });
|
||||
return 'switched';
|
||||
}
|
||||
if (fallback.status === 'needs-login') return 'needs-login';
|
||||
return 'unreachable';
|
||||
};
|
||||
|
||||
@@ -1280,6 +1353,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const [pendingConnection, setPendingConnection] = React.useState<MobilePendingConnection | null>(null);
|
||||
const connectionsRef = React.useRef(connections);
|
||||
const busyRef = React.useRef<'connect' | 'password' | 'pairing' | null>(null);
|
||||
const passwordOperationRef = React.useRef(createMobilePasswordOperationTracker());
|
||||
|
||||
const applyConnections = React.useCallback((next: MobileSavedConnection[]) => {
|
||||
connectionsRef.current = next;
|
||||
@@ -1463,6 +1537,8 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
if (!pendingConnection || !password.trim() || busyRef.current === 'password') return;
|
||||
setError(null);
|
||||
beginBusy('password');
|
||||
const operation = passwordOperationRef.current.begin();
|
||||
const isCurrentOperation = () => passwordOperationRef.current.isCurrent(operation);
|
||||
const { id, label, candidates } = pendingConnection;
|
||||
// A chosen relay transport owns an open tunnel; close it unless the switch
|
||||
// adopted it as the runtime tunnel.
|
||||
@@ -1473,6 +1549,7 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
// tunnel; cookies never cross it, so an issued bearer token is mandatory
|
||||
// there. `issueClientToken` mints the device's token in one round-trip.
|
||||
chosen = await establishLiveTransport(candidates);
|
||||
if (!isCurrentOperation()) return;
|
||||
if (!chosen) {
|
||||
setError(t('mobile.connect.error.unreachable'));
|
||||
return;
|
||||
@@ -1489,12 +1566,14 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
const response = chosen.kind === 'relay'
|
||||
? await raceWithTimeout(RELAY_CONNECT_TIMEOUT_MS, chosen.tunnel.fetch('/auth/session', loginInit).catch(() => null))
|
||||
: await requestWithTimeout(`${chosen.url}/auth/session`, loginInit);
|
||||
if (!isCurrentOperation()) return;
|
||||
logConnect('password:done', { ok: response?.ok === true, status: response?.status ?? null });
|
||||
if (!response?.ok) {
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
return;
|
||||
}
|
||||
const body = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
if (!isCurrentOperation()) return;
|
||||
const issuedToken = typeof body?.clientToken === 'string' ? body.clientToken.trim() : '';
|
||||
logConnect('password:token', { issued: Boolean(issuedToken) });
|
||||
|
||||
@@ -1515,8 +1594,11 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
|
||||
// Persist the token BEFORE switching (no fire-and-forget).
|
||||
if (isCapacitorApp()) {
|
||||
if (!isCurrentOperation()) return;
|
||||
await writeSecureToken(secureTokenKeyOf({ candidates }), issuedToken);
|
||||
if (!isCurrentOperation()) return;
|
||||
}
|
||||
if (!isCurrentOperation()) return;
|
||||
persistMetadata({ id, label, candidates, clientToken: issuedToken });
|
||||
setPendingConnection(null);
|
||||
// A relay transport hands its live login tunnel to the runtime (adopted
|
||||
@@ -1527,20 +1609,24 @@ export const useMobileConnection = (onConnected: () => void): UseMobileConnectio
|
||||
{ runtimeKey: secureTokenKeyOf({ candidates }) },
|
||||
);
|
||||
adopted = chosen.kind === 'relay';
|
||||
if (!isCurrentOperation()) return;
|
||||
onConnected();
|
||||
} catch (error) {
|
||||
if (!isCurrentOperation()) return;
|
||||
console.warn('[mobile-connect] password threw', error);
|
||||
setError(t('mobile.connect.error.passwordFailed'));
|
||||
} finally {
|
||||
if (!adopted && chosen?.kind === 'relay') chosen.tunnel.close();
|
||||
endBusy('password');
|
||||
if (isCurrentOperation()) endBusy('password');
|
||||
}
|
||||
}, [beginBusy, endBusy, onConnected, pendingConnection, persistMetadata, t]);
|
||||
|
||||
const cancelPassword = React.useCallback(() => {
|
||||
passwordOperationRef.current.cancel();
|
||||
endBusy('password');
|
||||
setPendingConnection(null);
|
||||
setError(null);
|
||||
}, []);
|
||||
}, [endBusy]);
|
||||
|
||||
const saveConnection = React.useCallback(async (input: MobileConnectInput): Promise<MobileSavedConnection | null> => {
|
||||
setError(null);
|
||||
|
||||
@@ -0,0 +1,430 @@
|
||||
import React from 'react';
|
||||
|
||||
import { observeNativeKeyboardHeight, resetHardwareKeyboardDetection, startHardwareKeyboardBridge } from '@/lib/hardwareKeyboard';
|
||||
|
||||
/** True when running inside the native Capacitor shell (iOS/Android app). */
|
||||
export const isCapacitorMobileApp = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const maybeCapacitor = (window as typeof window & {
|
||||
Capacitor?: { isNativePlatform?: () => boolean; getPlatform?: () => string };
|
||||
}).Capacitor;
|
||||
if (maybeCapacitor?.isNativePlatform?.() === true) return true;
|
||||
return window.location.protocol === 'capacitor:';
|
||||
};
|
||||
|
||||
export const useNativeMobileChrome = (): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorMobileApp()) return;
|
||||
|
||||
let disposed = false;
|
||||
const cleanup: Array<() => void> = [];
|
||||
const root = document.documentElement;
|
||||
// Marks the Capacitor shell so keyboard-inset CSS only applies here, not in
|
||||
// the browser-hosted PWA (which handles the keyboard via dvh / interactive-widget).
|
||||
root.classList.add('oc-capacitor-app');
|
||||
// Platform marker: Android resizes the window for the keyboard natively (no manual
|
||||
// inset/choreography — the keyboard listeners below skip Android entirely).
|
||||
const capacitorPlatform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (capacitorPlatform === 'android') {
|
||||
root.classList.add('oc-platform-android');
|
||||
}
|
||||
|
||||
// iOS reports hardware keyboards natively (GCKeyboard); adopting that
|
||||
// answer switches the layout off its keyboard-event inference entirely.
|
||||
cleanup.push(startHardwareKeyboardBridge());
|
||||
|
||||
const setInset = (px: number) => {
|
||||
root.style.setProperty('--oc-keyboard-inset', `${Math.max(0, Math.round(px))}px`);
|
||||
};
|
||||
|
||||
void import('@capacitor/status-bar').then(async ({ StatusBar, Style }) => {
|
||||
if (disposed) return;
|
||||
// Keep the status bar transparent over the WebView. A custom UIScene lifecycle
|
||||
// (iOS 26) plus returning from background can silently drop the overlay state,
|
||||
// letting an opaque status-bar background flash in at the top — so re-assert it
|
||||
// on mount, once shortly after (startup race), and whenever the app re-activates.
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
const applyStatusBar = async () => {
|
||||
if (platform === 'android') {
|
||||
// Inset the WebView below the bar and paint it with the resolved theme background
|
||||
// (the splash colours the theme system persists). On Android 15+ edge-to-edge is
|
||||
// enforced and both calls are no-ops — there the app pads itself via the
|
||||
// Capacitor-injected --safe-area-inset-* CSS vars (see mobile.css, oc-platform-android).
|
||||
const isDark = document.documentElement.classList.contains('dark');
|
||||
const themeBg =
|
||||
(isDark ? localStorage.getItem('splashBgDark') : localStorage.getItem('splashBgLight')) ||
|
||||
(isDark ? '#171515' : '#fffdf4');
|
||||
await StatusBar.setOverlaysWebView({ overlay: false }).catch(() => undefined);
|
||||
await StatusBar.setBackgroundColor({ color: themeBg }).catch(() => undefined);
|
||||
// Capacitor Style is named for the CONTENT: Style.Light = dark text (light bg),
|
||||
// Style.Dark = light text (dark bg). So dark theme → Style.Dark, light theme → Style.Light.
|
||||
await StatusBar.setStyle({ style: isDark ? Style.Dark : Style.Light }).catch(() => undefined);
|
||||
await StatusBar.show().catch(() => undefined);
|
||||
return;
|
||||
}
|
||||
await StatusBar.setStyle({ style: Style.Default }).catch(() => undefined);
|
||||
await StatusBar.setOverlaysWebView({ overlay: true }).catch(() => undefined);
|
||||
await StatusBar.show().catch(() => undefined);
|
||||
};
|
||||
await applyStatusBar();
|
||||
const retry = window.setTimeout(() => void applyStatusBar(), 400);
|
||||
cleanup.push(() => window.clearTimeout(retry));
|
||||
|
||||
const { App } = await import('@capacitor/app');
|
||||
const stateHandle = await App.addListener('appStateChange', ({ isActive }) => {
|
||||
if (isActive) void applyStatusBar();
|
||||
});
|
||||
if (disposed) {
|
||||
void stateHandle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void stateHandle.remove());
|
||||
}).catch(() => undefined);
|
||||
|
||||
void import('@capacitor/keyboard').then(async ({ Keyboard }) => {
|
||||
if (disposed) return;
|
||||
// iOS (WKWebView, resize: 'none') keeps 100dvh at full height with the keyboard
|
||||
// overlaying, so we lift the UI manually via --oc-keyboard-inset. Android resizes the
|
||||
// window for the keyboard (dvh already shrinks), so applying the inset on top would
|
||||
// double-count — Android gets only the class/event signals below.
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
if (platform === 'android') {
|
||||
// Android resizes the WebView natively, so no inset/transform
|
||||
// choreography — but the UI still needs the open/closed signal:
|
||||
// oc-keyboard-open drives CSS (draft starters, composer padding), and
|
||||
// the settled event gives the chat its one deterministic re-pin after
|
||||
// the native resize (the auto-follow idle gate ignores it otherwise).
|
||||
const willShowHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
observeNativeKeyboardHeight(info.keyboardHeight);
|
||||
root.classList.add('oc-keyboard-open');
|
||||
// The composer already expanded on tap — re-pin the chat to it now,
|
||||
// so the native resize that follows is the only remaining movement.
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } }));
|
||||
});
|
||||
const didShowHandle = await Keyboard.addListener('keyboardDidShow', () => {
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: true } }));
|
||||
});
|
||||
const willHideHandle = await Keyboard.addListener('keyboardWillHide', () => {
|
||||
// Same single-motion trick as iOS: collapse the composer into the
|
||||
// pill synchronously (flushSync in ChatInput) so the native window
|
||||
// growth and the composer shrink land together, not as two steps.
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-intent', { detail: { open: false } }));
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
});
|
||||
const didHideHandle = await Keyboard.addListener('keyboardDidHide', () => {
|
||||
window.dispatchEvent(new CustomEvent('oc:keyboard-settled', { detail: { open: false } }));
|
||||
});
|
||||
const removeAll = () => {
|
||||
void willShowHandle.remove();
|
||||
void didShowHandle.remove();
|
||||
void willHideHandle.remove();
|
||||
void didHideHandle.remove();
|
||||
};
|
||||
if (disposed) {
|
||||
removeAll();
|
||||
return;
|
||||
}
|
||||
cleanup.push(removeAll);
|
||||
return;
|
||||
}
|
||||
// No WebKit form accessory bar (prev/next arrows + Done) above the keyboard —
|
||||
// there's a single input, so it only eats vertical space.
|
||||
await Keyboard.setAccessoryBarVisible({ isVisible: false }).catch(() => undefined);
|
||||
|
||||
// Keyboard slide choreography (see the "Native (Capacitor) keyboard handling"
|
||||
// block in mobile.css for the full picture). `keyboardWillShow` fires at the
|
||||
// START of the iOS keyboard animation and carries the final height; the
|
||||
// visible motion is transform-only (inline styles on the kb-movers), and the shell's layout
|
||||
// height (--oc-kb-layout) snaps exactly once per open/close at the moment the
|
||||
// resize is invisible. visualViewport tracking was tried but doesn't shrink
|
||||
// under WKWebView's `resize: 'none'`, so these events are the reliable signal.
|
||||
const KB_ANIM_MS = 250;
|
||||
// Dismissal reads faster than the rise — run the hide leg shorter (kept in
|
||||
// sync with the .oc-kb-hide transition-duration override in mobile.css).
|
||||
const KB_HIDE_MS = 200;
|
||||
const KB_ANIM_EASING = 'cubic-bezier(0.38, 0.7, 0.125, 1)';
|
||||
let settleTimer: number | null = null;
|
||||
let caretTimer: number | null = null;
|
||||
let keyboardHeight = 0;
|
||||
let layoutApplied = false;
|
||||
let safeBottomPx = 0;
|
||||
let keyboardOpen = false;
|
||||
|
||||
const setVar = (name: string, px: number) => {
|
||||
root.style.setProperty(name, `${Math.max(0, Math.round(px))}px`);
|
||||
};
|
||||
const clearSettle = () => {
|
||||
if (settleTimer !== null) {
|
||||
window.clearTimeout(settleTimer);
|
||||
settleTimer = null;
|
||||
}
|
||||
};
|
||||
const dispatchKb = (type: 'oc:keyboard-intent' | 'oc:keyboard-anim' | 'oc:keyboard-settled', detail: Record<string, unknown>) => {
|
||||
window.dispatchEvent(new CustomEvent(type, { detail }));
|
||||
};
|
||||
// Elements that ride the keyboard slide, with their travel factor. Driven
|
||||
// by INLINE styles from here: WebKit does not reliably start a transition
|
||||
// when the transform's value changes via a CSS custom property, which
|
||||
// left the composer parked until the keyboard finished.
|
||||
const getKbMovers = (): Array<{ el: HTMLElement; factor: number }> => {
|
||||
const movers: Array<{ el: HTMLElement; factor: number }> = [];
|
||||
const composer = document.querySelector<HTMLElement>('.oc-mobile-composer');
|
||||
if (composer) movers.push({ el: composer, factor: 1 });
|
||||
// The centered draft title moves half the shift — exactly where the
|
||||
// center lands after the shell snap (see mobile.css notes).
|
||||
const draftCenter = document.querySelector<HTMLElement>('.oc-draft-center');
|
||||
if (draftCenter) movers.push({ el: draftCenter, factor: 0.5 });
|
||||
return movers;
|
||||
};
|
||||
const clearKbMovers = () => {
|
||||
for (const { el } of getKbMovers()) {
|
||||
el.style.transition = '';
|
||||
el.style.transform = '';
|
||||
}
|
||||
};
|
||||
|
||||
const showHandle = await Keyboard.addListener('keyboardWillShow', (info) => {
|
||||
clearSettle();
|
||||
observeNativeKeyboardHeight(info.keyboardHeight);
|
||||
keyboardOpen = true;
|
||||
keyboardHeight = info.keyboardHeight;
|
||||
if (!layoutApplied) {
|
||||
// The shell's resolved padding-bottom while the keyboard is down IS the
|
||||
// bottom safe padding it gives up when open — measure it so the slide
|
||||
// distance lands the composer exactly where the final layout puts it.
|
||||
const shell = document.querySelector('.oc-mobile-app-shell');
|
||||
safeBottomPx = shell ? parseFloat(getComputedStyle(shell).paddingBottom) || 0 : 0;
|
||||
}
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-kb-hide');
|
||||
// WKWebView renders the caret as a native layer that doesn't ride CSS
|
||||
// transforms — after the rise it visibly "flies" from the pre-keyboard
|
||||
// position to the final one. Hide it for the transition (plus the lag
|
||||
// window where UIKit animates it into place) and pop it back in.
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
root.classList.add('oc-keyboard-open', 'oc-kb-animating', 'oc-kb-caret-hold');
|
||||
setInset(keyboardHeight);
|
||||
for (const { el, factor } of getKbMovers()) {
|
||||
el.style.transition = `transform ${KB_ANIM_MS}ms ${KB_ANIM_EASING}`;
|
||||
el.style.transform = `translateY(${-slide * factor}px)`;
|
||||
}
|
||||
// Reserve the keyboard strip inside the chat scroller NOW and re-pin
|
||||
// immediately (settled = one cheap scrollTop write over already-mounted
|
||||
// rows), so the chat bottom moves as the keyboard STARTS rising instead
|
||||
// of waiting for it to finish. `slide` (keyboard minus the safe inset
|
||||
// the shell gives up) is exactly the strip the scroller loses at
|
||||
// settle, so pin position and settle stay geometry-neutral.
|
||||
setVar('--oc-kb-scroll-inset', slide);
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'show', slide, durationMs: KB_ANIM_MS, easing: KB_ANIM_EASING });
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
// Invisible swap: transition off, layout takes the keyboard height (one
|
||||
// reflow), shift returns to 0 in the same frame.
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', keyboardHeight);
|
||||
layoutApplied = true;
|
||||
clearKbMovers();
|
||||
dispatchKb('oc:keyboard-settled', { open: true });
|
||||
// Reveal the caret only after UIKit's own caret reposition window.
|
||||
caretTimer = window.setTimeout(() => {
|
||||
caretTimer = null;
|
||||
root.classList.remove('oc-kb-caret-hold');
|
||||
}, 250);
|
||||
}, KB_ANIM_MS + 20);
|
||||
});
|
||||
|
||||
// Shared hide choreography. The bridge's `keyboardWillHide` can arrive a
|
||||
// beat AFTER the native dismiss animation has already started (WKWebView +
|
||||
// resize: 'none'), which made the composer begin its down-slide only once
|
||||
// the keyboard was gone. The earliest reliable signal for the common
|
||||
// dismissal path (tap outside the input) is the textarea's focusout — so
|
||||
// both trigger this, and `keyboardOpen` makes the second call a no-op.
|
||||
const runHide = () => {
|
||||
if (!keyboardOpen) return;
|
||||
keyboardOpen = false;
|
||||
clearSettle();
|
||||
// Fired BEFORE any layout change: lets the composer collapse into its
|
||||
// pill synchronously (flushSync in ChatInput), so the keyboard hide
|
||||
// compensation below measures keyboard + composer shrink as ONE delta
|
||||
// instead of two staggered steps.
|
||||
dispatchKb('oc:keyboard-intent', { open: false });
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
root.classList.remove('oc-kb-caret-hold');
|
||||
const slide = Math.max(0, keyboardHeight - safeBottomPx);
|
||||
root.classList.remove('oc-keyboard-open');
|
||||
setInset(0);
|
||||
setVar('--oc-kb-scroll-inset', 0);
|
||||
if (layoutApplied) {
|
||||
// Settled-open → restore the full-height layout NOW (still hidden behind
|
||||
// the keyboard) and FLIP the movers to their raised position without
|
||||
// transitioning, so the next frame looks unchanged.
|
||||
root.classList.remove('oc-kb-animating');
|
||||
setVar('--oc-kb-layout', 0);
|
||||
layoutApplied = false;
|
||||
for (const { el, factor } of getKbMovers()) {
|
||||
el.style.transition = 'none';
|
||||
el.style.transform = `translateY(${-slide * factor}px)`;
|
||||
}
|
||||
// Force the style/layout flush so the transition below starts from the
|
||||
// FLIP position instead of coalescing both writes into one frame.
|
||||
void (document.querySelector('.oc-mobile-app-shell') as HTMLElement | null)?.offsetHeight;
|
||||
}
|
||||
// If the hide interrupted a show mid-animation (layout not applied yet),
|
||||
// the movers transition back down from wherever they currently are.
|
||||
dispatchKb('oc:keyboard-anim', { phase: 'hide', slide, durationMs: KB_HIDE_MS, easing: KB_ANIM_EASING });
|
||||
root.classList.add('oc-kb-animating', 'oc-kb-hide');
|
||||
for (const { el } of getKbMovers()) {
|
||||
el.style.transition = `transform ${KB_HIDE_MS}ms ${KB_ANIM_EASING}`;
|
||||
el.style.transform = 'translateY(0px)';
|
||||
}
|
||||
settleTimer = window.setTimeout(() => {
|
||||
settleTimer = null;
|
||||
root.classList.remove('oc-kb-animating', 'oc-kb-hide');
|
||||
clearKbMovers();
|
||||
dispatchKb('oc:keyboard-settled', { open: false });
|
||||
}, KB_HIDE_MS + 20);
|
||||
};
|
||||
|
||||
const hideHandle = await Keyboard.addListener('keyboardWillHide', runHide);
|
||||
|
||||
// Early hide trigger: blurring the focused text field is what starts the
|
||||
// native dismiss animation, and it happens in-page — no bridge latency.
|
||||
// Deferred a task so a synchronous refocus (focus moving to another text
|
||||
// input, or a control that restores focus) doesn't false-trigger; in that
|
||||
// case the keyboard never hides and `keyboardWillHide` never fires either.
|
||||
const isTextInput = (node: unknown): boolean =>
|
||||
node instanceof HTMLElement
|
||||
&& (node.tagName === 'TEXTAREA' || node.tagName === 'INPUT' || node.isContentEditable);
|
||||
const handleFocusOut = (event: FocusEvent) => {
|
||||
if (!keyboardOpen) return;
|
||||
if (!isTextInput(event.target)) return;
|
||||
if (isTextInput(event.relatedTarget)) return;
|
||||
window.setTimeout(() => {
|
||||
if (!keyboardOpen) return;
|
||||
if (isTextInput(document.activeElement)) return;
|
||||
runHide();
|
||||
}, 0);
|
||||
};
|
||||
document.addEventListener('focusout', handleFocusOut, true);
|
||||
|
||||
if (disposed) {
|
||||
clearSettle();
|
||||
document.removeEventListener('focusout', handleFocusOut, true);
|
||||
void showHandle.remove();
|
||||
void hideHandle.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(
|
||||
clearSettle,
|
||||
() => {
|
||||
if (caretTimer !== null) {
|
||||
window.clearTimeout(caretTimer);
|
||||
caretTimer = null;
|
||||
}
|
||||
},
|
||||
() => document.removeEventListener('focusout', handleFocusOut, true),
|
||||
() => void showHandle.remove(),
|
||||
() => void hideHandle.remove(),
|
||||
);
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
resetHardwareKeyboardDetection();
|
||||
root.classList.remove('oc-capacitor-app', 'oc-keyboard-open', 'oc-kb-animating', 'oc-kb-hide', 'oc-kb-caret-hold', 'oc-platform-android');
|
||||
root.style.removeProperty('--oc-keyboard-inset');
|
||||
root.style.removeProperty('--oc-kb-shift');
|
||||
root.style.removeProperty('--oc-kb-layout');
|
||||
root.style.removeProperty('--oc-kb-scroll-inset');
|
||||
};
|
||||
}, []);
|
||||
};
|
||||
|
||||
export const useNativeMobileLifecycle = (onResume: () => void): void => {
|
||||
const wasInactiveRef = React.useRef(false);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorMobileApp()) return;
|
||||
|
||||
let disposed = false;
|
||||
const cleanup: Array<() => void> = [];
|
||||
const resumeAfterInactive = () => {
|
||||
if (!wasInactiveRef.current) return;
|
||||
wasInactiveRef.current = false;
|
||||
onResume();
|
||||
};
|
||||
|
||||
// Belt-and-suspenders resume detection. Capacitor's `appStateChange` is the
|
||||
// primary signal, but on iOS it can be missed after a long suspend, so the
|
||||
// webview's own `visibilitychange` is a second trigger — either one flips
|
||||
// wasInactiveRef and fires onResume exactly once per background→foreground.
|
||||
const handleVisibility = () => {
|
||||
if (document.visibilityState === 'hidden') {
|
||||
wasInactiveRef.current = true;
|
||||
return;
|
||||
}
|
||||
resumeAfterInactive();
|
||||
};
|
||||
document.addEventListener('visibilitychange', handleVisibility);
|
||||
cleanup.push(() => document.removeEventListener('visibilitychange', handleVisibility));
|
||||
|
||||
void import('@capacitor/app').then(async ({ App }) => {
|
||||
if (disposed) return;
|
||||
const state = await App.addListener('appStateChange', ({ isActive }) => {
|
||||
document.documentElement.classList.toggle('oc-native-app-active', isActive);
|
||||
if (!isActive) {
|
||||
wasInactiveRef.current = true;
|
||||
return;
|
||||
}
|
||||
resumeAfterInactive();
|
||||
});
|
||||
const resume = await App.addListener('resume', resumeAfterInactive);
|
||||
if (disposed) {
|
||||
void state.remove();
|
||||
void resume.remove();
|
||||
return;
|
||||
}
|
||||
cleanup.push(() => void state.remove(), () => void resume.remove());
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
cleanup.forEach((remove) => remove());
|
||||
};
|
||||
}, [onResume]);
|
||||
};
|
||||
|
||||
export const useNativeAndroidBackButton = (onBack: () => boolean): void => {
|
||||
React.useEffect(() => {
|
||||
if (!isCapacitorMobileApp()) return;
|
||||
|
||||
let disposed = false;
|
||||
let remove: (() => void) | null = null;
|
||||
|
||||
void import('@capacitor/app').then(async ({ App }) => {
|
||||
if (disposed) return;
|
||||
const listener = await App.addListener('backButton', () => {
|
||||
if (onBack()) return;
|
||||
void App.minimizeApp().catch(() => undefined);
|
||||
});
|
||||
if (disposed) {
|
||||
void listener.remove();
|
||||
return;
|
||||
}
|
||||
remove = () => void listener.remove();
|
||||
}).catch(() => undefined);
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
remove?.();
|
||||
};
|
||||
}, [onBack]);
|
||||
};
|
||||
@@ -0,0 +1,16 @@
|
||||
import type { ProjectEntry } from '@/lib/api/types';
|
||||
|
||||
export const normalizePath = (value?: string | null): string =>
|
||||
(value || '').replace(/\\/g, '/').replace(/\/+$/g, '');
|
||||
|
||||
export const getProjectLabel = (path: string): string => {
|
||||
const normalized = normalizePath(path);
|
||||
if (!normalized) return '';
|
||||
const segments = normalized.split('/').filter(Boolean);
|
||||
return segments[segments.length - 1]?.replace(/[-_]/g, ' ') || normalized;
|
||||
};
|
||||
|
||||
export const getProjectDisplayLabel = (project: ProjectEntry | null, fallbackDirectory: string): string => {
|
||||
if (project) return project.label?.trim() || getProjectLabel(project.path);
|
||||
return getProjectLabel(fallbackDirectory);
|
||||
};
|
||||
@@ -1,8 +1,8 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import { encodePairingConnectionPayload, buildPairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
import { parseConnectionPayload } from './mobileQrScan';
|
||||
import { parseConnectionPayload, scanConnectionQr } from './mobileQrScan';
|
||||
|
||||
const hostEncPubJwk = { kty: 'EC', crv: 'P-256', x: 'eHhY', y: 'eVlZ' } as const;
|
||||
|
||||
@@ -40,3 +40,126 @@ describe('parseConnectionPayload', () => {
|
||||
expect(parseConnectionPayload('openchamber://connect?v=1&mode=relay#offer=eyJ2IjoxfQ')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('scanConnectionQr on Android', () => {
|
||||
const originalWindow = globalThis.window;
|
||||
|
||||
afterEach(() => {
|
||||
Object.defineProperty(globalThis, 'window', { configurable: true, value: originalWindow });
|
||||
});
|
||||
|
||||
test('uses the bundled startScan flow and cleans up after a result', async () => {
|
||||
const listeners = new Map<string, (event: { barcodes?: Array<{ rawValue?: string }> }) => void>();
|
||||
let removeCalls = 0;
|
||||
let stopCalls = 0;
|
||||
let scanCalls = 0;
|
||||
let startOptions: unknown;
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const stopScan = async () => { stopCalls += 1; };
|
||||
const scan = async () => { scanCalls += 1; return { barcodes: [] }; };
|
||||
const startScan = async (options?: unknown) => {
|
||||
startOptions = options;
|
||||
listeners.get('barcodesScanned')?.({ barcodes: [{ rawValue: 'https://oc.example' }] });
|
||||
};
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
scan,
|
||||
startScan,
|
||||
stopScan,
|
||||
addListener: mock((event: string, callback: (info: { barcodes?: Array<{ rawValue?: string }> }) => void) => {
|
||||
listeners.set(event, callback);
|
||||
return Promise.resolve({ remove });
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
|
||||
expect(await scanConnectionQr()).toEqual({ status: 'ok', url: 'https://oc.example' });
|
||||
expect(startOptions).toEqual({ formats: ['QR_CODE'] });
|
||||
expect(scanCalls).toBe(0);
|
||||
expect(stopCalls).toBe(1);
|
||||
expect(removeCalls).toBe(2);
|
||||
});
|
||||
|
||||
test('stops scanning when the caller aborts', async () => {
|
||||
let stopCalls = 0;
|
||||
const stopScan = async () => { stopCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan: mock(async () => undefined),
|
||||
stopScan,
|
||||
addListener: mock(async () => ({ remove: mock(() => undefined) })),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = scanConnectionQr({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
|
||||
expect(await result).toEqual({ status: 'cancelled' });
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('waits for listener setup to finish before cleaning up an aborted scan', async () => {
|
||||
let finishListenerSetup: (() => void) | undefined;
|
||||
let removeCalls = 0;
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
const listenerSetup = new Promise<void>((resolve) => { finishListenerSetup = resolve; });
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const startScan = async () => { startCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan,
|
||||
stopScan: async () => { stopCalls += 1; },
|
||||
addListener: mock(async () => {
|
||||
await listenerSetup;
|
||||
return { remove };
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
const controller = new AbortController();
|
||||
const result = scanConnectionQr({ signal: controller.signal });
|
||||
await Promise.resolve();
|
||||
controller.abort();
|
||||
finishListenerSetup?.();
|
||||
|
||||
expect(await result).toEqual({ status: 'cancelled' });
|
||||
expect(startCalls).toBe(0);
|
||||
expect(removeCalls).toBe(2);
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
|
||||
test('cleans up successful listener registration when the other listener fails', async () => {
|
||||
let removeCalls = 0;
|
||||
let startCalls = 0;
|
||||
let stopCalls = 0;
|
||||
const remove = () => { removeCalls += 1; };
|
||||
const plugin = {
|
||||
requestPermissions: mock(async () => ({ camera: 'granted' })),
|
||||
startScan: async () => { startCalls += 1; },
|
||||
stopScan: async () => { stopCalls += 1; },
|
||||
addListener: mock(async (event: string) => {
|
||||
if (event === 'scanError') throw new Error('listener setup failed');
|
||||
return { remove };
|
||||
}),
|
||||
};
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: { Capacitor: { getPlatform: () => 'android', Plugins: { BarcodeScanner: plugin } } },
|
||||
});
|
||||
|
||||
expect(await scanConnectionQr()).toEqual({ status: 'failed' });
|
||||
expect(startCalls).toBe(0);
|
||||
expect(removeCalls).toBe(1);
|
||||
expect(stopCalls).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,8 @@
|
||||
// Connection payload parsing + native QR scanning for the dedicated mobile app.
|
||||
//
|
||||
// Pairing v2 links (openchamber://connect?v=2&p=<base64url>) carry a one-time
|
||||
// secret and a list of transport candidates (lan / tunnel / relay); they are
|
||||
// redeemed server-side over whichever candidate connects first. We also accept a
|
||||
// bare http(s) URL so a QR encoding only the server address works.
|
||||
//
|
||||
// QR scanning is delegated to a Capacitor barcode-scanner plugin if the native
|
||||
// shell registered one (`window.Capacitor.Plugins.BarcodeScanner`). We resolve it
|
||||
// at runtime instead of importing the package so the web build stays dependency-free
|
||||
// and the browser-hosted mobile UI degrades to `unsupported` cleanly.
|
||||
// Android uses the plugin's CameraX-backed startScan() flow. Unlike its ready-made
|
||||
// scan() activity, this path bundles the barcode model in the app and does not need
|
||||
// Google Play Services. iOS keeps the native ready-made scanner.
|
||||
|
||||
import { parsePairingConnectionPayload, type PairingConnectionPayload } from '@/lib/connectionPayload';
|
||||
|
||||
@@ -32,74 +26,16 @@ export type QrScanResult =
|
||||
| { status: 'failed' };
|
||||
|
||||
type ScannedBarcode = { rawValue?: string; displayValue?: string };
|
||||
|
||||
type ModuleInstallProgress = { state?: number };
|
||||
type ListenerHandle = { remove: () => void };
|
||||
|
||||
type ListenerHandle = { remove: () => void | Promise<void> };
|
||||
type BarcodeScannerPlugin = {
|
||||
requestPermissions?: () => Promise<{ camera?: string } | undefined>;
|
||||
scan?: (options?: { formats?: string[] }) => Promise<{ barcodes?: ScannedBarcode[] } | undefined>;
|
||||
// Android-only: the Google code scanner used by scan() needs the ML Kit barcode module,
|
||||
// which Play Services must download once before the first scan. Absent on iOS.
|
||||
isGoogleBarcodeScannerModuleAvailable?: () => Promise<{ available?: boolean } | undefined>;
|
||||
installGoogleBarcodeScannerModule?: () => Promise<void>;
|
||||
startScan?: (options?: { formats?: string[] }) => Promise<void>;
|
||||
stopScan?: () => Promise<void>;
|
||||
addListener?: (
|
||||
event: 'googleBarcodeScannerModuleInstallProgress',
|
||||
cb: (info: ModuleInstallProgress) => void,
|
||||
) => Promise<ListenerHandle>;
|
||||
};
|
||||
|
||||
// Google's ModuleInstallProgress states: 4 = COMPLETED, 3 = CANCELED, 5 = FAILED.
|
||||
const MODULE_STATE_COMPLETED = 4;
|
||||
const MODULE_STATE_CANCELED = 3;
|
||||
const MODULE_STATE_FAILED = 5;
|
||||
const MODULE_INSTALL_TIMEOUT_MS = 90_000;
|
||||
|
||||
// Ensure the Android Google barcode module is downloaded before scanning. No-op on platforms
|
||||
// where these methods don't exist (iOS) or when it's already available. Resolves once the module
|
||||
// is usable; rejects if the install is canceled, fails, or times out.
|
||||
const ensureScannerModule = async (plugin: BarcodeScannerPlugin): Promise<void> => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
if (
|
||||
capacitor?.getPlatform?.() !== 'android' ||
|
||||
!plugin.isGoogleBarcodeScannerModuleAvailable ||
|
||||
!plugin.installGoogleBarcodeScannerModule
|
||||
) {
|
||||
return;
|
||||
}
|
||||
const status = await plugin.isGoogleBarcodeScannerModuleAvailable().catch(() => undefined);
|
||||
if (status?.available) return;
|
||||
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
let handle: ListenerHandle | undefined;
|
||||
const finish = (fn: () => void) => {
|
||||
window.clearTimeout(timer);
|
||||
handle?.remove();
|
||||
fn();
|
||||
};
|
||||
const timer = window.setTimeout(
|
||||
() => finish(() => reject(new Error('module install timed out'))),
|
||||
MODULE_INSTALL_TIMEOUT_MS,
|
||||
);
|
||||
// addListener may return a handle synchronously OR a Promise<handle> depending on the
|
||||
// Capacitor proxy — normalize with Promise.resolve so a non-thenable handle doesn't throw
|
||||
// and abort the install call below.
|
||||
Promise.resolve(
|
||||
plugin.addListener?.('googleBarcodeScannerModuleInstallProgress', (info) => {
|
||||
if (info?.state === MODULE_STATE_COMPLETED) finish(resolve);
|
||||
else if (info?.state === MODULE_STATE_CANCELED || info?.state === MODULE_STATE_FAILED) {
|
||||
finish(() => reject(new Error('module install failed')));
|
||||
}
|
||||
}),
|
||||
)
|
||||
.then((h) => {
|
||||
handle = h as ListenerHandle | undefined;
|
||||
})
|
||||
.catch(() => undefined);
|
||||
Promise.resolve(plugin.installGoogleBarcodeScannerModule?.()).catch((error) =>
|
||||
finish(() => reject(error instanceof Error ? error : new Error('module install failed'))),
|
||||
);
|
||||
});
|
||||
event: 'barcodesScanned' | 'scanError',
|
||||
cb: (info: { barcodes?: ScannedBarcode[]; message?: string }) => void,
|
||||
) => Promise<ListenerHandle> | ListenerHandle;
|
||||
};
|
||||
|
||||
const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
@@ -108,7 +44,12 @@ const getScannerPlugin = (): BarcodeScannerPlugin | null => {
|
||||
Capacitor?: { Plugins?: Record<string, unknown> };
|
||||
}).Capacitor;
|
||||
const plugin = capacitor?.Plugins?.BarcodeScanner as BarcodeScannerPlugin | undefined;
|
||||
return plugin && typeof plugin.scan === 'function' ? plugin : null;
|
||||
return plugin && (typeof plugin.scan === 'function' || typeof plugin.startScan === 'function') ? plugin : null;
|
||||
};
|
||||
|
||||
const isAndroid = (): boolean => {
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
return capacitor?.getPlatform?.() === 'android';
|
||||
};
|
||||
|
||||
export const parseConnectionPayload = (raw: string): MobileConnectionPayload | MobilePairingPayload | null => {
|
||||
@@ -124,54 +65,85 @@ export const parseConnectionPayload = (raw: string): MobileConnectionPayload | M
|
||||
return null;
|
||||
};
|
||||
|
||||
// The Google code scanner can briefly still throw "module not available" in the moments right
|
||||
// after its install completes. Detect that specific error so we can re-ensure + retry rather
|
||||
// than surfacing a failure the user would have to manually tap through.
|
||||
const isModuleUnavailableError = (error: unknown): boolean => {
|
||||
const message =
|
||||
typeof error === 'object' && error && 'message' in error
|
||||
? String((error as { message?: unknown }).message ?? '')
|
||||
: String(error ?? '');
|
||||
return /module/i.test(message) && /not\s*available|unavailable/i.test(message);
|
||||
const resultFromRawValue = (raw: string): QrScanResult => {
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
};
|
||||
|
||||
const scanWithBundledAndroidScanner = async (
|
||||
plugin: BarcodeScannerPlugin,
|
||||
signal?: AbortSignal,
|
||||
): Promise<QrScanResult> => {
|
||||
if (!plugin.startScan || !plugin.stopScan || !plugin.addListener) return { status: 'unsupported' };
|
||||
if (signal?.aborted) return { status: 'cancelled' };
|
||||
|
||||
let barcodeListener: ListenerHandle | undefined;
|
||||
let errorListener: ListenerHandle | undefined;
|
||||
let settled = false;
|
||||
let resolveResult: (result: QrScanResult) => void = () => undefined;
|
||||
|
||||
const result = new Promise<QrScanResult>((resolve) => {
|
||||
resolveResult = resolve;
|
||||
});
|
||||
const finish = (scanResult: QrScanResult) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
resolveResult(scanResult);
|
||||
};
|
||||
const abort = () => finish({ status: 'cancelled' });
|
||||
signal?.addEventListener('abort', abort, { once: true });
|
||||
|
||||
try {
|
||||
const listenerResults = await Promise.allSettled([
|
||||
Promise.resolve(plugin.addListener('barcodesScanned', ({ barcodes }) => {
|
||||
const barcode = barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (raw) finish(resultFromRawValue(raw));
|
||||
})).then((handle) => { barcodeListener = handle; }),
|
||||
Promise.resolve(plugin.addListener('scanError', () => finish({ status: 'failed' })))
|
||||
.then((handle) => { errorListener = handle; }),
|
||||
]);
|
||||
|
||||
if (listenerResults.some(({ status }) => status === 'rejected')) {
|
||||
finish({ status: 'failed' });
|
||||
} else if (!settled) {
|
||||
void plugin.startScan({ formats: ['QR_CODE'] }).catch(() => finish({ status: 'failed' }));
|
||||
}
|
||||
|
||||
return await result;
|
||||
} finally {
|
||||
signal?.removeEventListener('abort', abort);
|
||||
await Promise.allSettled([
|
||||
Promise.resolve(barcodeListener?.remove()),
|
||||
Promise.resolve(errorListener?.remove()),
|
||||
plugin.stopScan(),
|
||||
]);
|
||||
}
|
||||
};
|
||||
|
||||
export const isQrScanSupported = (): boolean => getScannerPlugin() !== null;
|
||||
|
||||
export const scanConnectionQr = async (): Promise<QrScanResult> => {
|
||||
export const scanConnectionQr = async (options?: { signal?: AbortSignal }): Promise<QrScanResult> => {
|
||||
const plugin = getScannerPlugin();
|
||||
if (!plugin?.scan) return { status: 'unsupported' };
|
||||
if (!plugin) return { status: 'unsupported' };
|
||||
|
||||
try {
|
||||
if (plugin.requestPermissions) {
|
||||
const permission = await plugin.requestPermissions();
|
||||
const camera = permission?.camera;
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') {
|
||||
return { status: 'permission-denied' };
|
||||
}
|
||||
if (camera && camera !== 'granted' && camera !== 'limited') return { status: 'permission-denied' };
|
||||
}
|
||||
|
||||
// First scan on Android downloads the Google barcode module (the button stays in its
|
||||
// scanning state for the whole wait). The module can still report "not available" for a
|
||||
// moment right after install, so re-ensure + retry within this same call instead of erroring
|
||||
// out — the user shouldn't have to guess to tap again.
|
||||
for (let attempt = 0; attempt < 3; attempt++) {
|
||||
try {
|
||||
await ensureScannerModule(plugin);
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
if (!raw) return { status: 'cancelled' };
|
||||
if (options?.signal?.aborted) return { status: 'cancelled' };
|
||||
if (isAndroid()) return scanWithBundledAndroidScanner(plugin, options?.signal);
|
||||
if (!plugin.scan) return { status: 'unsupported' };
|
||||
|
||||
const payload = parseConnectionPayload(raw);
|
||||
if (!payload) return { status: 'invalid' };
|
||||
if ('pairing' in payload) return { status: 'pairing', ...payload };
|
||||
return { status: 'ok', ...payload };
|
||||
} catch (error) {
|
||||
if (!isModuleUnavailableError(error) || attempt === 2) return { status: 'failed' };
|
||||
await new Promise((resolve) => window.setTimeout(resolve, 600));
|
||||
}
|
||||
}
|
||||
return { status: 'failed' };
|
||||
const result = await plugin.scan({ formats: ['QR_CODE'] });
|
||||
const barcode = result?.barcodes?.[0];
|
||||
const raw = (barcode?.rawValue ?? barcode?.displayValue ?? '').trim();
|
||||
return raw ? resultFromRawValue(raw) : { status: 'cancelled' };
|
||||
} catch {
|
||||
return { status: 'failed' };
|
||||
}
|
||||
|
||||
@@ -4,7 +4,10 @@ import type { ProjectEntry } from '@/lib/api/types';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useProjectsStore } from '@/stores/useProjectsStore';
|
||||
import { useSessionPinnedStore } from '@/stores/useSessionPinnedStore';
|
||||
import { useNotificationStore } from '@/sync/notification-store';
|
||||
import { compareSessionsByLifecycleOrder, useSessionOrderingStore } from '@/sync/session-ordering';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
|
||||
/**
|
||||
* Builds the lightweight session overview the native iOS widgets render (home medium,
|
||||
@@ -26,9 +29,11 @@ export interface MobileWidgetSession {
|
||||
}
|
||||
|
||||
export interface MobileWidgetSnapshot {
|
||||
/** Runtime instance that owns all session IDs and paths in this snapshot. */
|
||||
runtimeKey: string;
|
||||
/** Count of sessions needing attention — same signal that drives the app-icon badge. */
|
||||
attentionCount: number;
|
||||
/** Most-recently-updated top-level sessions, newest first (capped for the medium widget). */
|
||||
/** Top-level sessions in the app's shared lifecycle order (capped for the medium widget). */
|
||||
recentSessions: MobileWidgetSession[];
|
||||
}
|
||||
|
||||
@@ -70,9 +75,11 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
const unseenBySession = useNotificationStore.getState().index.session.unseenCount;
|
||||
const notifyOnSubtasks = useUIStore.getState().notifyOnSubtasks;
|
||||
const projects = useProjectsStore.getState().projects;
|
||||
const pinnedSessionIds = useSessionPinnedStore.getState().ids;
|
||||
const sessionOrderRanks = useSessionOrderingStore.getState().rankById;
|
||||
|
||||
let attentionCount = 0;
|
||||
const topLevel: Array<{ id: string; title: string; updated: number; unread: boolean; project: string }> = [];
|
||||
const topLevel: Array<{ session: Session; unread: boolean; project: string }> = [];
|
||||
|
||||
for (const session of sessions) {
|
||||
const isSubtask = parentIdOf(session) !== null;
|
||||
@@ -83,21 +90,19 @@ export const buildMobileWidgetSnapshot = (): MobileWidgetSnapshot => {
|
||||
}
|
||||
if (!isSubtask) {
|
||||
topLevel.push({
|
||||
id: session.id,
|
||||
title: session.title ?? '',
|
||||
updated: session.time?.updated ?? session.time?.created ?? 0,
|
||||
session,
|
||||
unread: needsAttention,
|
||||
project: projectLabelForDirectory(resolveGlobalSessionDirectory(session), projects),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
topLevel.sort((a, b) => b.updated - a.updated);
|
||||
topLevel.sort((a, b) => compareSessionsByLifecycleOrder(a.session, b.session, pinnedSessionIds, sessionOrderRanks));
|
||||
const recentSessions = topLevel
|
||||
.slice(0, RECENT_LIMIT)
|
||||
.map(({ id, title, unread, project }) => ({ id, title, unread, project }));
|
||||
.map(({ session, unread, project }) => ({ id: session.id, title: session.title ?? '', unread, project }));
|
||||
|
||||
return { attentionCount, recentSessions };
|
||||
return { runtimeKey: getRuntimeKey(), attentionCount, recentSessions };
|
||||
};
|
||||
|
||||
const SNAPSHOT_GLOBAL_KEY = '__OPENCHAMBER_WIDGET_SNAPSHOT__';
|
||||
|
||||
@@ -44,6 +44,10 @@ const initializeSharedPreferences = () => {
|
||||
};
|
||||
|
||||
export function renderMobileApp(apis: RuntimeAPIs) {
|
||||
// Stamp the surface before anything else reads it: perf tuning, sync paging,
|
||||
// and device info all key off isMobileSurfaceRuntime(), and without the stamp
|
||||
// a wide native device (iPad landscape) would fall out of the mobile branch.
|
||||
window.__OPENCHAMBER_SURFACE__ = 'mobile';
|
||||
preloadMarkdownRenderer();
|
||||
initializeSharedPreferences();
|
||||
|
||||
|
||||
@@ -7,8 +7,17 @@ import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useAutoReviewStore } from '@/stores/useAutoReviewStore';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { usePermissionStore } from '@/stores/permissionStore';
|
||||
import { useFileSearchStore } from '@/stores/useFileSearchStore';
|
||||
import { useGitStore } from '@/stores/useGitStore';
|
||||
import { useGitHubPrStatusStore } from '@/stores/useGitHubPrStatusStore';
|
||||
import { useSessionFoldersStore } from '@/stores/useSessionFoldersStore';
|
||||
import { useFilesViewTabsStore } from '@/stores/useFilesViewTabsStore';
|
||||
import { useTerminalStore } from '@/stores/useTerminalStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
import { resetStreamingState } from '@/sync/streaming';
|
||||
import { useGlobalSessionStatusStore } from '@/sync/global-session-status';
|
||||
import { resetSessionOrdering } from '@/sync/session-ordering';
|
||||
import { syncDesktopSettings } from '@/lib/persistence';
|
||||
|
||||
// Same-device transport switch (LAN⇄relay for one paired device): rebind the SDK
|
||||
// to the new transport WITHOUT tearing down connection/session state or remounting
|
||||
@@ -31,6 +40,7 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
useAutoReviewStore.getState().stopRunningRunsForRuntime(detail.previousRuntimeKey);
|
||||
}
|
||||
disposeTerminalInputTransport();
|
||||
useTerminalStore.getState().clearAll();
|
||||
opencodeClient.reconnectToRuntimeBaseUrl();
|
||||
useConfigStore.setState({
|
||||
providers: [],
|
||||
@@ -44,8 +54,16 @@ export const resetAppForRuntimeEndpointChange = (detail: RuntimeEndpointChangedD
|
||||
// Cross-project session list (mobile sessions sheet & co) belongs to the
|
||||
// previous instance — drop it so stale sessions can't linger after a switch.
|
||||
useGlobalSessionsStore.getState().resetForRuntimeSwitch();
|
||||
useGlobalSessionStatusStore.setState({ statusById: new Map() });
|
||||
resetSessionOrdering();
|
||||
usePermissionStore.getState().reset();
|
||||
useFileSearchStore.getState().resetForRuntimeSwitch();
|
||||
useGitStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useGitHubPrStatusStore.getState().resetForRuntimeSwitch();
|
||||
useSessionFoldersStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useFilesViewTabsStore.getState().resetForRuntimeSwitch(detail.runtimeKey);
|
||||
useSessionUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
useUIStore.getState().restoreForRuntimeSwitch(detail.runtimeKey);
|
||||
resetStreamingState();
|
||||
queueMicrotask(() => void syncDesktopSettings());
|
||||
};
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
import React from 'react';
|
||||
|
||||
/**
|
||||
* Native-feeling edge swipes on the mobile chat: start a horizontal swipe from
|
||||
* the very left/right screen edge and drag toward the centre.
|
||||
*
|
||||
* - Left edge → centre = open the sessions drawer
|
||||
* - Right edge → centre = open the most recent overflow surface
|
||||
*
|
||||
* Only `touchstart`/`touchend` are observed (both passive), so this never
|
||||
* interferes with vertical chat scrolling or the horizontal scroll inside code
|
||||
* blocks — it just reads where the gesture began and ended. The edge zone
|
||||
* keeps it clear of in-content horizontal scroll, which lives away from the
|
||||
* screen edges.
|
||||
*/
|
||||
|
||||
const EDGE_ZONE = 32; // px from a side where the swipe must begin
|
||||
// Android reserves the physical screen edge for system navigation. Accept a
|
||||
// wider start area so both OpenChamber drawers can be invoked beyond the
|
||||
// system Back gesture region without changing the browser/iOS gesture.
|
||||
const ANDROID_EDGE_ZONE = 80;
|
||||
const MIN_DISTANCE = 64; // px of horizontal travel required to commit
|
||||
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
|
||||
|
||||
export interface EdgeSwipeOptions {
|
||||
/** Swipe that started at the left edge and travelled right. */
|
||||
onLeftEdgeSwipe?: () => void;
|
||||
/** Swipe that started at the right edge and travelled left. */
|
||||
onRightEdgeSwipe?: () => void;
|
||||
}
|
||||
|
||||
export const useEdgeSwipe = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
options: EdgeSwipeOptions,
|
||||
): void => {
|
||||
// Keep callbacks in a ref so changing identities don't re-attach the listeners.
|
||||
const optionsRef = React.useRef(options);
|
||||
optionsRef.current = options;
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
const platform = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor?.getPlatform?.();
|
||||
const edgeZone = platform === 'android' ? ANDROID_EDGE_ZONE : EDGE_ZONE;
|
||||
|
||||
let tracking = false;
|
||||
let fromLeftEdge = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
const width = element.clientWidth;
|
||||
const nearLeft = touch.clientX <= edgeZone;
|
||||
const nearRight = touch.clientX >= width - edgeZone;
|
||||
tracking = nearLeft || nearRight;
|
||||
fromLeftEdge = nearLeft;
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
if (Math.abs(dx) < MIN_DISTANCE) return;
|
||||
if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return;
|
||||
// Must travel toward the centre: left edge → rightward, right edge → leftward.
|
||||
if (fromLeftEdge && dx <= 0) return;
|
||||
if (!fromLeftEdge && dx >= 0) return;
|
||||
|
||||
if (fromLeftEdge) optionsRef.current.onLeftEdgeSwipe?.();
|
||||
else optionsRef.current.onRightEdgeSwipe?.();
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [ref]);
|
||||
};
|
||||
@@ -1,125 +0,0 @@
|
||||
import React from 'react';
|
||||
import type { Session } from '@opencode-ai/sdk/v2';
|
||||
|
||||
import { resolveGlobalSessionDirectory, useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionUIStore } from '@/sync/session-ui-store';
|
||||
|
||||
/**
|
||||
* Native-feeling edge swipe to switch sessions in the mobile chat: start a horizontal swipe
|
||||
* from the very left/right edge and drag toward the centre to step through sessions.
|
||||
*
|
||||
* - Left edge → centre = previous session (the more-recent one in the list)
|
||||
* - Right edge → centre = next session (the older one)
|
||||
*
|
||||
* Navigation walks the same ranked list the rest of the mobile UI uses: top-level sessions
|
||||
* (no subtasks) across all projects, newest-first by `time.updated`. The order is computed at
|
||||
* gesture time from the store (not subscribed) so it's always fresh and never re-attaches.
|
||||
*
|
||||
* Only `touchstart`/`touchend` are observed (both passive), so this never interferes with
|
||||
* vertical chat scrolling or the horizontal scroll inside code blocks — it just reads where the
|
||||
* gesture began and ended. The edge zone keeps it clear of in-content horizontal scroll, which
|
||||
* lives away from the screen edges.
|
||||
*/
|
||||
|
||||
const EDGE_ZONE = 32; // px from a side where the swipe must begin
|
||||
const MIN_DISTANCE = 64; // px of horizontal travel required to commit a switch
|
||||
const MAX_OFF_AXIS_RATIO = 0.7; // |dy| must stay below |dx| * this (keep it horizontal)
|
||||
|
||||
const parentIdOf = (session: Session): string | null =>
|
||||
(session as Session & { parentID?: string | null }).parentID ?? null;
|
||||
|
||||
const updatedAt = (session: Session): number => session.time?.updated ?? session.time?.created ?? 0;
|
||||
|
||||
/** Top-level sessions across all projects, newest-first — the list the swipe walks. */
|
||||
const orderedTopLevelSessions = (): Session[] =>
|
||||
useGlobalSessionsStore
|
||||
.getState()
|
||||
.activeSessions.filter((session) => parentIdOf(session) === null)
|
||||
.slice()
|
||||
.sort((a, b) => updatedAt(b) - updatedAt(a));
|
||||
|
||||
/**
|
||||
* Switch to the session `step` positions away from the current one (clamped — no wrap).
|
||||
* Returns true if a switch actually happened.
|
||||
*/
|
||||
const switchByStep = (step: number): boolean => {
|
||||
const ordered = orderedTopLevelSessions();
|
||||
if (ordered.length < 2) return false;
|
||||
|
||||
const currentId = useSessionUIStore.getState().currentSessionId;
|
||||
const index = ordered.findIndex((session) => session.id === currentId);
|
||||
if (index < 0) return false;
|
||||
|
||||
const targetIndex = index + step;
|
||||
if (targetIndex < 0 || targetIndex >= ordered.length) return false;
|
||||
|
||||
const target = ordered[targetIndex];
|
||||
useSessionUIStore.getState().setCurrentSession(target.id, resolveGlobalSessionDirectory(target));
|
||||
return true;
|
||||
};
|
||||
|
||||
export interface EdgeSwipeSessionSwitchOptions {
|
||||
/** Called after a successful switch, with the travel direction, so the caller can animate. */
|
||||
onSwitch?: (direction: 'prev' | 'next') => void;
|
||||
}
|
||||
|
||||
export const useEdgeSwipeSessionSwitch = (
|
||||
ref: React.RefObject<HTMLElement | null>,
|
||||
options?: EdgeSwipeSessionSwitchOptions,
|
||||
): void => {
|
||||
// Keep onSwitch in a ref so a changing callback identity doesn't re-attach the listeners.
|
||||
const onSwitchRef = React.useRef(options?.onSwitch);
|
||||
onSwitchRef.current = options?.onSwitch;
|
||||
|
||||
React.useEffect(() => {
|
||||
const element = ref.current;
|
||||
if (!element) return;
|
||||
|
||||
let tracking = false;
|
||||
let fromLeftEdge = false;
|
||||
let startX = 0;
|
||||
let startY = 0;
|
||||
|
||||
const onTouchStart = (event: TouchEvent) => {
|
||||
if (event.touches.length !== 1) {
|
||||
tracking = false;
|
||||
return;
|
||||
}
|
||||
const touch = event.touches[0];
|
||||
const width = element.clientWidth;
|
||||
const nearLeft = touch.clientX <= EDGE_ZONE;
|
||||
const nearRight = touch.clientX >= width - EDGE_ZONE;
|
||||
tracking = nearLeft || nearRight;
|
||||
fromLeftEdge = nearLeft;
|
||||
startX = touch.clientX;
|
||||
startY = touch.clientY;
|
||||
};
|
||||
|
||||
const onTouchEnd = (event: TouchEvent) => {
|
||||
if (!tracking) return;
|
||||
tracking = false;
|
||||
const touch = event.changedTouches[0];
|
||||
if (!touch) return;
|
||||
|
||||
const dx = touch.clientX - startX;
|
||||
const dy = touch.clientY - startY;
|
||||
if (Math.abs(dx) < MIN_DISTANCE) return;
|
||||
if (Math.abs(dy) > Math.abs(dx) * MAX_OFF_AXIS_RATIO) return;
|
||||
// Must travel toward the centre: left edge → rightward, right edge → leftward.
|
||||
if (fromLeftEdge && dx <= 0) return;
|
||||
if (!fromLeftEdge && dx >= 0) return;
|
||||
|
||||
const step = fromLeftEdge ? -1 : 1;
|
||||
if (switchByStep(step)) {
|
||||
onSwitchRef.current?.(step < 0 ? 'prev' : 'next');
|
||||
}
|
||||
};
|
||||
|
||||
element.addEventListener('touchstart', onTouchStart, { passive: true });
|
||||
element.addEventListener('touchend', onTouchEnd, { passive: true });
|
||||
return () => {
|
||||
element.removeEventListener('touchstart', onTouchStart);
|
||||
element.removeEventListener('touchend', onTouchEnd);
|
||||
};
|
||||
}, [ref]);
|
||||
};
|
||||
@@ -21,6 +21,19 @@ import { useUIStore } from '@/stores/useUIStore';
|
||||
// the Google Services Gradle plugin on Android), so @capacitor/push-notifications' register()
|
||||
// returns the right token per platform. The token is sent to the server tagged with its platform
|
||||
// so the relay routes it to APNs vs FCM.
|
||||
// APNs environment of this build. Xcode/dev-signed installs get sandbox device tokens,
|
||||
// TestFlight/App Store installs get production ones; the native iOS shell reports which via
|
||||
// a global injected in SceneDelegate (see packages/mobile/ios/App/App/AppDelegate.swift).
|
||||
// Undefined when the global is absent (Android, or a shell predating the injection) — the
|
||||
// server then defaults to production, matching released builds.
|
||||
const getApnsEnvironment = (): 'sandbox' | 'production' | undefined => {
|
||||
if (typeof window === 'undefined') return undefined;
|
||||
const env = (window as typeof window & { __OPENCHAMBER_APNS_ENV__?: string }).__OPENCHAMBER_APNS_ENV__;
|
||||
if (env === 'development') return 'sandbox';
|
||||
if (env === 'production') return 'production';
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const isNativePushPlatform = (): boolean => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
const capacitor = (window as typeof window & { Capacitor?: { getPlatform?: () => string } }).Capacitor;
|
||||
@@ -56,7 +69,11 @@ export const useNativePushRegistration = (options: { enabled: boolean }): void =
|
||||
const registrationHandle = await PushNotifications.addListener('registration', (token) => {
|
||||
lastTokenRef.current = token.value;
|
||||
const apis = getRegisteredRuntimeAPIs();
|
||||
void apis?.push?.registerApnsToken?.({ token: token.value, platform: getClientPlatform() });
|
||||
void apis?.push?.registerApnsToken?.({
|
||||
token: token.value,
|
||||
platform: getClientPlatform(),
|
||||
environment: getApnsEnvironment(),
|
||||
});
|
||||
});
|
||||
|
||||
const registrationErrorHandle = await PushNotifications.addListener('registrationError', (error) => {
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M24.136 33.9485L12.0218 27.1098L24.136 20.2711L36.2501 27.1098L24.136 33.9485Z" fill="white"/>
|
||||
<path d="M23.907 19.3922C24.1323 19.3336 24.3742 19.3628 24.5822 19.4801L36.6971 26.3176C36.9826 26.4788 37.1585 26.7827 37.1585 27.1106C37.1582 27.4382 36.9824 27.7406 36.6971 27.9017L24.5822 34.7391C24.3048 34.8956 23.9666 34.8957 23.6893 34.7391L11.5743 27.9017C11.2892 27.7405 11.1132 27.4381 11.1129 27.1106C11.1129 26.7828 11.2889 26.4789 11.5743 26.3176L23.6893 19.4801L23.907 19.3922ZM13.8715 27.1086L24.1347 32.9034L34.3999 27.1086L24.1347 21.3139L13.8715 27.1086Z" fill="black"/>
|
||||
<path d="M23.2278 34.4786L12.9307 28.6659V43.5893L23.2278 49.4021V34.4786ZM25.0456 50.9581C25.0456 51.2813 24.8733 51.5822 24.5941 51.7451C24.3152 51.9077 23.9705 51.9097 23.6893 51.7511L11.5743 44.9117C11.2892 44.7504 11.113 44.4483 11.1129 44.1206V27.1098C11.1129 26.7866 11.2852 26.4877 11.5644 26.3248C11.8434 26.1622 12.188 26.16 12.4692 26.3188L24.5822 33.1563C24.8677 33.3175 25.0456 33.6214 25.0456 33.9493V50.9581Z" fill="black"/>
|
||||
<path d="M36.2501 27.1098V44.1205L24.1359 50.9591V42.7527V33.9485L36.2501 27.1098Z" fill="white"/>
|
||||
<path d="M35.8034 26.3188C36.0848 26.1599 36.4291 26.1619 36.7082 26.3248C36.9874 26.4878 37.1597 26.7866 37.1597 27.1098V44.1206C37.1596 44.4485 36.9818 44.7505 36.6962 44.9117L24.5833 51.7512C24.3022 51.9098 23.9574 51.9075 23.6784 51.7452C23.3993 51.5822 23.227 51.2814 23.227 50.9581V33.9493C23.227 33.6214 23.403 33.3175 23.6884 33.1563L35.8034 26.3188ZM25.0447 49.4001L35.3419 43.5893V28.6639L25.0447 34.4786V49.4001Z" fill="black"/>
|
||||
<path d="M48.3642 33.9485L36.25 27.1098L48.3642 20.2711L60.4784 27.1098L48.3642 33.9485Z" fill="white"/>
|
||||
<path d="M48.1352 19.3922C48.3605 19.3336 48.6024 19.3628 48.8104 19.4801L60.9253 26.3176C61.2109 26.4788 61.3867 26.7827 61.3867 27.1106C61.3864 27.4382 61.2106 27.7406 60.9253 27.9017L48.8104 34.7391C48.5331 34.8956 48.1948 34.8957 47.9175 34.7391L35.8026 27.9017C35.5174 27.7405 35.3415 27.4381 35.3412 27.1106C35.3412 26.7828 35.5171 26.4789 35.8026 26.3176L47.9175 19.4801L48.1352 19.3922ZM38.0997 27.1086L48.363 32.9034L58.6282 27.1086L48.363 21.3139L38.0997 27.1086Z" fill="black"/>
|
||||
<path d="M60.4784 27.1098V44.1205L48.3642 50.9591V42.7527V33.9485L60.4784 27.1098Z" fill="white"/>
|
||||
<path d="M60.0317 26.3188C60.3131 26.1599 60.6575 26.1619 60.9366 26.3248C61.2157 26.4878 61.388 26.7866 61.388 27.1098V44.1206C61.388 44.4485 61.2101 44.7505 60.9246 44.9117L48.8117 51.7512C48.5306 51.9098 48.1857 51.9075 47.9068 51.7452C47.6277 51.5822 47.4554 51.2814 47.4554 50.9581V33.9493C47.4554 33.6214 47.6313 33.3175 47.9168 33.1563L60.0317 26.3188ZM49.2731 49.4001L59.5703 43.5893V28.6639L49.2731 34.4786V49.4001Z" fill="black"/>
|
||||
<path d="M24.136 47.6218L12.0218 40.7832L24.136 33.9445L36.2501 40.7832L24.136 47.6218Z" fill="white"/>
|
||||
<path d="M23.907 33.0656C24.1323 33.0069 24.3742 33.0362 24.5822 33.1535L36.6971 39.991C36.9826 40.1522 37.1585 40.4561 37.1585 40.784C37.1582 41.1116 36.9824 41.4139 36.6971 41.575L24.5822 48.4125C24.3048 48.569 23.9666 48.5691 23.6893 48.4125L11.5743 41.575C11.2892 41.4139 11.1132 41.1115 11.1129 40.784C11.1129 40.4561 11.2889 40.1522 11.5743 39.991L23.6893 33.1535L23.907 33.0656ZM13.8715 40.782L24.1347 46.5768L34.3999 40.782L24.1347 34.9872L13.8715 40.782Z" fill="black"/>
|
||||
<path d="M12.0218 40.7831V57.7977L24.136 64.6363V56.4299V47.6218L12.0218 40.7831Z" fill="white"/>
|
||||
<path d="M23.2278 48.1519L12.9307 42.3392V57.2666L23.2278 63.0794V48.1519ZM25.0456 64.6354C25.0456 64.9585 24.8731 65.2575 24.5941 65.4205C24.3151 65.5833 23.9707 65.5871 23.6893 65.4284L11.5743 58.589C11.2892 58.4277 11.113 58.1256 11.1129 57.7979V40.7831C11.1129 40.4599 11.2852 40.1611 11.5644 39.9981C11.8434 39.8355 12.188 39.8333 12.4692 39.9921L24.5822 46.8296C24.8677 46.9908 25.0456 47.2947 25.0456 47.6226V64.6354Z" fill="black"/>
|
||||
<path d="M36.2501 40.7831V57.7977L24.1359 64.6363V56.4299V47.6218L36.2501 40.7831Z" fill="white"/>
|
||||
<path d="M35.8034 39.9921C36.0848 39.8332 36.4291 39.8352 36.7082 39.9981C36.9874 40.1611 37.1597 40.4599 37.1597 40.7831V57.7979C37.1596 58.1258 36.9817 58.4278 36.6962 58.589L24.5833 65.4285C24.3019 65.5873 23.9575 65.5833 23.6784 65.4205C23.3993 65.2575 23.227 64.9587 23.227 64.6354V47.6226C23.227 47.2948 23.403 46.9908 23.6884 46.8296L35.8034 39.9921ZM25.0447 63.0774L35.3419 57.2666V42.3372L25.0447 48.152V63.0774Z" fill="black"/>
|
||||
<path d="M48.3642 47.6218L36.25 40.7832L48.3642 33.9445L60.4784 40.7832L48.3642 47.6218Z" fill="white"/>
|
||||
<path d="M48.1352 33.0656C48.3605 33.0069 48.6024 33.0362 48.8104 33.1535L60.9253 39.991C61.2109 40.1522 61.3867 40.4561 61.3867 40.784C61.3864 41.1116 61.2106 41.4139 60.9253 41.575L48.8104 48.4125C48.533 48.569 48.1948 48.5691 47.9175 48.4125L35.8026 41.575C35.5174 41.4139 35.3414 41.1115 35.3411 40.784C35.3411 40.4561 35.5171 40.1522 35.8026 39.991L47.9175 33.1535L48.1352 33.0656ZM38.0997 40.782L48.3629 46.5768L58.6282 40.782L48.3629 34.9872L38.0997 40.782Z" fill="black"/>
|
||||
<path d="M47.456 48.1519L37.1588 42.3392V57.2666L47.456 63.0794V48.1519ZM49.2738 64.6354C49.2738 64.9585 49.1013 65.2575 48.8223 65.4205C48.5433 65.5833 48.1989 65.5871 47.9175 65.4284L35.8025 58.589C35.5174 58.4277 35.3412 58.1256 35.3411 57.7979V40.7831C35.3411 40.4599 35.5134 40.1611 35.7925 39.9981C36.0716 39.8355 36.4162 39.8333 36.6974 39.9921L48.8103 46.8296C49.0959 46.9908 49.2738 47.2947 49.2738 47.6226V64.6354Z" fill="black"/>
|
||||
<path d="M60.4784 40.7831V57.7977L48.3642 64.6363V56.4299V47.6218L60.4784 40.7831Z" fill="white"/>
|
||||
<path d="M60.0317 39.9921C60.3131 39.8332 60.6575 39.8352 60.9366 39.9981C61.2157 40.1611 61.388 40.4599 61.388 40.7831V57.7979C61.3879 58.1258 61.2101 58.4278 60.9246 58.589L48.8117 65.4285C48.5303 65.5873 48.1859 65.5833 47.9068 65.4205C47.6277 65.2575 47.4554 64.9587 47.4554 64.6354V47.6226C47.4554 47.2948 47.6313 46.9908 47.9168 46.8296L60.0317 39.9921ZM49.2731 63.0774L59.5703 57.2666V42.3372L49.2731 48.152V63.0774Z" fill="black"/>
|
||||
<path d="M35.9374 40.0909C44.9748 40.0909 52.3011 32.7647 52.3011 23.7273C52.3011 14.6899 44.9748 7.36365 35.9374 7.36365C26.9001 7.36365 19.5738 14.6899 19.5738 23.7273C19.5738 32.7647 26.9001 40.0909 35.9374 40.0909Z" fill="white"/>
|
||||
<path d="M51.3922 23.7273C51.3922 15.192 44.4727 8.27251 35.9374 8.27251C27.4021 8.27251 20.4827 15.192 20.4827 23.7273C20.4827 32.2626 27.4021 39.182 35.9374 39.182C44.4727 39.182 51.3922 32.2626 51.3922 23.7273ZM53.2099 23.7273C53.2099 33.2667 45.4769 40.9998 35.9374 40.9998C26.398 40.9998 18.6649 33.2667 18.6649 23.7273C18.6649 14.1878 26.398 6.45477 35.9374 6.45477C45.4769 6.45477 53.2099 14.1878 53.2099 23.7273Z" fill="black"/>
|
||||
<path d="M25.3011 23.1818H31.8465V29.7273H25.3011V23.1818Z" fill="black"/>
|
||||
<path d="M27.3465 25.2273H29.8011V29.7273H27.3465V25.2273Z" fill="white"/>
|
||||
<path d="M40.0284 23.1818H46.5738V29.7273H40.0284V23.1818Z" fill="black"/>
|
||||
<path d="M42.0738 25.2273H44.5284V29.7273H42.0738V25.2273Z" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
@@ -0,0 +1,15 @@
|
||||
<svg width="72" height="72" viewBox="0 0 72 72" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<g fill="white" stroke="black" stroke-width="3" stroke-linejoin="round">
|
||||
<path d="M12 40L24 34L36 40V52L24 59L12 52V40Z"/>
|
||||
<path d="M36 40L48 34L60 40V52L48 59L36 52V40Z"/>
|
||||
</g>
|
||||
<g stroke="black" stroke-width="3" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 40L24 46L36 40M24 46V59"/>
|
||||
<path d="M36 40L48 46L60 40M48 46V59"/>
|
||||
</g>
|
||||
<circle cx="36" cy="25" r="16" fill="white" stroke="black" stroke-width="3"/>
|
||||
<rect x="27" y="22" width="7" height="9" rx="1.5" fill="black"/>
|
||||
<rect x="29.5" y="25" width="2" height="6" fill="white"/>
|
||||
<rect x="38" y="22" width="7" height="9" rx="1.5" fill="black"/>
|
||||
<rect x="40.5" y="25" width="2" height="6" fill="white"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 807 B |
@@ -1,4 +1,4 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
import { afterEach, describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
type ComponentFn<P extends Record<string, unknown> = Record<string, unknown>> = (props: P) => unknown;
|
||||
|
||||
@@ -16,12 +16,43 @@ const hookRecords = new Map<unknown, HookRecord>();
|
||||
let currentRecord: HookRecord | null = null;
|
||||
let hookIndex = 0;
|
||||
let pendingEffects: Array<() => void> = [];
|
||||
const originalWindow = Object.getOwnPropertyDescriptor(globalThis, 'window');
|
||||
|
||||
afterEach(() => {
|
||||
if (originalWindow) {
|
||||
Object.defineProperty(globalThis, 'window', originalWindow);
|
||||
} else {
|
||||
Reflect.deleteProperty(globalThis, 'window');
|
||||
}
|
||||
});
|
||||
|
||||
const resetHarness = () => {
|
||||
hookRecords.clear();
|
||||
currentRecord = null;
|
||||
hookIndex = 0;
|
||||
pendingEffects = [];
|
||||
runtimeApiBaseUrl = '';
|
||||
runtimeKey = 'local';
|
||||
runtimeEndpointChangedListener = null;
|
||||
desktopInvoke = async () => null;
|
||||
desktopHostsGetCalls = 0;
|
||||
desktopHostsSetCalls = 0;
|
||||
runtimeSwitchCalls = 0;
|
||||
Object.defineProperty(globalThis, 'window', {
|
||||
configurable: true,
|
||||
value: {
|
||||
isSecureContext: false,
|
||||
localStorage: {
|
||||
getItem: () => null,
|
||||
setItem: () => undefined,
|
||||
},
|
||||
setTimeout: (callback: () => void) => {
|
||||
queueMicrotask(callback);
|
||||
return 0;
|
||||
},
|
||||
clearTimeout: () => undefined,
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const shallowEqualDeps = (left?: unknown[], right?: unknown[]): boolean => {
|
||||
@@ -149,6 +180,13 @@ const reactJsxRuntime = {
|
||||
|
||||
let desktopShell = false;
|
||||
let runtimeFetchRejects = true;
|
||||
let runtimeApiBaseUrl = '';
|
||||
let runtimeKey = 'local';
|
||||
let runtimeEndpointChangedListener: (() => void) | null = null;
|
||||
let desktopInvoke: () => Promise<unknown> = async () => null;
|
||||
let desktopHostsGetCalls = 0;
|
||||
let desktopHostsSetCalls = 0;
|
||||
let runtimeSwitchCalls = 0;
|
||||
|
||||
mock.module('react/jsx-runtime', () => reactJsxRuntime);
|
||||
mock.module('react/jsx-dev-runtime', () => reactJsxRuntime);
|
||||
@@ -172,7 +210,7 @@ mock.module('@/components/ui/checkbox', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui/input', () => ({
|
||||
Input: () => null,
|
||||
Input: (props: JSXProps) => ({ type: 'input', props }),
|
||||
}));
|
||||
|
||||
mock.module('@/components/ui', () => ({
|
||||
@@ -200,7 +238,7 @@ mock.module('@/lib/i18n', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktop', () => ({
|
||||
invokeDesktop: mock(() => Promise.resolve(null)),
|
||||
invokeDesktop: () => desktopInvoke(),
|
||||
isDesktopShell: mock(() => desktopShell),
|
||||
isVSCodeRuntime: mock(() => false),
|
||||
}));
|
||||
@@ -232,14 +270,26 @@ mock.module('@/lib/runtime-auth', () => ({
|
||||
}));
|
||||
|
||||
mock.module('@/lib/runtime-switch', () => ({
|
||||
getRuntimeApiBaseUrl: mock(() => ''),
|
||||
subscribeRuntimeEndpointChanged: mock(() => () => {}),
|
||||
switchRuntimeEndpoint: mock(() => undefined),
|
||||
getRuntimeApiBaseUrl: () => runtimeApiBaseUrl,
|
||||
getRuntimeKey: () => runtimeKey,
|
||||
subscribeRuntimeEndpointChanged: (listener: () => void) => {
|
||||
runtimeEndpointChangedListener = listener;
|
||||
return () => {
|
||||
if (runtimeEndpointChangedListener === listener) runtimeEndpointChangedListener = null;
|
||||
};
|
||||
},
|
||||
switchRuntimeEndpoint: () => { runtimeSwitchCalls += 1; },
|
||||
}));
|
||||
|
||||
mock.module('@/lib/desktopHosts', () => ({
|
||||
desktopHostsGet: mock(() => Promise.resolve(null)),
|
||||
desktopHostsSet: mock(() => Promise.resolve()),
|
||||
desktopHostsGet: () => {
|
||||
desktopHostsGetCalls += 1;
|
||||
return Promise.resolve(null);
|
||||
},
|
||||
desktopHostsSet: () => {
|
||||
desktopHostsSetCalls += 1;
|
||||
return Promise.resolve();
|
||||
},
|
||||
getDesktopHostApiUrl: mock(() => ''),
|
||||
normalizeHostUrl: mock(() => ''),
|
||||
}));
|
||||
@@ -288,6 +338,21 @@ const collectText = (node: unknown): string => {
|
||||
return '';
|
||||
};
|
||||
|
||||
const findElement = (node: unknown, type: string): { type: string; props: JSXProps } | null => {
|
||||
if (!node || typeof node !== 'object') return null;
|
||||
const element = node as { type?: unknown; props?: JSXProps };
|
||||
if (element.type === type && element.props) return { type, props: element.props };
|
||||
const children = element.props?.children;
|
||||
if (Array.isArray(children)) {
|
||||
for (const child of children) {
|
||||
const match = findElement(child, type);
|
||||
if (match) return match;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return findElement(children, type);
|
||||
};
|
||||
|
||||
describe('SessionAuthGate status-check failure behavior', () => {
|
||||
test('keeps non-desktop status-check rejection on the error screen', async () => {
|
||||
resetHarness();
|
||||
@@ -312,4 +377,37 @@ describe('SessionAuthGate status-check failure behavior', () => {
|
||||
expect(text).toContain('sessionAuth.locked.unlockTitle');
|
||||
expect(text).not.toContain('sessionAuth.error.networkTitle');
|
||||
});
|
||||
|
||||
test('discards a password completion after switching to another host', async () => {
|
||||
resetHarness();
|
||||
desktopShell = true;
|
||||
runtimeFetchRejects = false;
|
||||
runtimeApiBaseUrl = 'https://host-a.example';
|
||||
runtimeKey = 'host:a';
|
||||
let resolveLogin: (value: unknown) => void = () => {
|
||||
throw new Error('Password login did not start');
|
||||
};
|
||||
desktopInvoke = () => new Promise((resolve) => { resolveLogin = resolve; });
|
||||
|
||||
const lockedTree = await renderGate();
|
||||
const input = findElement(lockedTree, 'input');
|
||||
expect(input).not.toBeNull();
|
||||
(input?.props.onChange as (event: { target: { value: string } }) => void)({ target: { value: 'password-a' } });
|
||||
|
||||
const passwordTree = await renderGate();
|
||||
const form = findElement(passwordTree, 'form');
|
||||
expect(form).not.toBeNull();
|
||||
const pending = (form?.props.onSubmit as (event: { preventDefault: () => void }) => Promise<void>)({ preventDefault: () => undefined });
|
||||
await Promise.resolve();
|
||||
|
||||
runtimeApiBaseUrl = 'https://host-b.example';
|
||||
runtimeKey = 'host:b';
|
||||
runtimeEndpointChangedListener?.();
|
||||
resolveLogin({ token: 'token-a' });
|
||||
await pending;
|
||||
|
||||
expect(desktopHostsGetCalls).toBe(0);
|
||||
expect(desktopHostsSetCalls).toBe(0);
|
||||
expect(runtimeSwitchCalls).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { resolveStatusCheckFailureState } from './sessionAuthGateState';
|
||||
import { resolveStatusCheckFailureState, runtimeIdentityMatches } from './sessionAuthGateState';
|
||||
|
||||
describe('resolveStatusCheckFailureState', () => {
|
||||
test('keeps the desktop-shell password login fallback intact', () => {
|
||||
@@ -10,4 +10,18 @@ describe('resolveStatusCheckFailureState', () => {
|
||||
test('uses the network error screen for non-desktop status-check failures', () => {
|
||||
expect(resolveStatusCheckFailureState({})).toBe('error');
|
||||
});
|
||||
|
||||
test('rejects async auth results after switching hosts', () => {
|
||||
expect(runtimeIdentityMatches(
|
||||
{ apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' },
|
||||
{ apiBaseUrl: 'https://host-b.example', runtimeKey: 'host:b' },
|
||||
)).toBe(false);
|
||||
});
|
||||
|
||||
test('accepts a credential refresh for the same host', () => {
|
||||
expect(runtimeIdentityMatches(
|
||||
{ apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' },
|
||||
{ apiBaseUrl: 'https://host-a.example', runtimeKey: 'host:a' },
|
||||
)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,9 +13,9 @@ import { Icon } from "@/components/icon/Icon";
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { runtimeFetch } from '@/lib/runtime-fetch';
|
||||
import { getRuntimeExtraHeadersSync } from '@/lib/runtime-auth';
|
||||
import { getRuntimeApiBaseUrl, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { getRuntimeApiBaseUrl, getRuntimeKey, subscribeRuntimeEndpointChanged, switchRuntimeEndpoint } from '@/lib/runtime-switch';
|
||||
import { desktopHostsGet, desktopHostsSet, getDesktopHostApiUrl, normalizeHostUrl } from '@/lib/desktopHosts';
|
||||
import { resolveStatusCheckFailureState, type GateState } from './sessionAuthGateState';
|
||||
import { resolveStatusCheckFailureState, runtimeIdentityMatches, type GateState, type RuntimeIdentity } from './sessionAuthGateState';
|
||||
import {
|
||||
authenticateWithPasskey,
|
||||
cancelPasskeyCeremony,
|
||||
@@ -160,20 +160,34 @@ const shouldUseDesktopShellPasswordLogin = (): boolean => {
|
||||
return isDesktopShell() && !isLocalDesktopRuntime();
|
||||
};
|
||||
|
||||
const captureRuntimeIdentity = (): RuntimeIdentity => ({
|
||||
apiBaseUrl: getRuntimeApiBaseUrl(),
|
||||
runtimeKey: getRuntimeKey(),
|
||||
});
|
||||
|
||||
const isRuntimeIdentityActive = (identity: RuntimeIdentity): boolean => {
|
||||
return runtimeIdentityMatches(identity, captureRuntimeIdentity());
|
||||
};
|
||||
|
||||
type DesktopPasswordLoginResult = {
|
||||
token: string;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
const issueDesktopClientTokenViaShell = async (password: string, trustDevice: boolean): Promise<DesktopPasswordLoginResult | null> => {
|
||||
const issueDesktopClientTokenViaShell = async (
|
||||
password: string,
|
||||
trustDevice: boolean,
|
||||
runtime: RuntimeIdentity,
|
||||
requestHeaders: Record<string, string>,
|
||||
): Promise<DesktopPasswordLoginResult | null> => {
|
||||
if (!isDesktopShell() || typeof window === 'undefined') {
|
||||
return null;
|
||||
}
|
||||
const response = await invokeDesktop('desktop_remote_password_login', {
|
||||
url: getRuntimeApiBaseUrl(),
|
||||
url: runtime.apiBaseUrl,
|
||||
password,
|
||||
trustDevice,
|
||||
requestHeaders: getRuntimeExtraHeadersSync(),
|
||||
requestHeaders,
|
||||
}).catch(() => null);
|
||||
if (!response || typeof response !== 'object') {
|
||||
return null;
|
||||
@@ -186,22 +200,22 @@ const issueDesktopClientTokenViaShell = async (password: string, trustDevice: bo
|
||||
};
|
||||
};
|
||||
|
||||
const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string): Promise<void> => {
|
||||
if (!isDesktopShell() || !clientToken) return;
|
||||
const persistDesktopClientToken = async (runtime: RuntimeIdentity, clientToken: string): Promise<boolean> => {
|
||||
if (!isDesktopShell() || !clientToken || !isRuntimeIdentityActive(runtime)) return false;
|
||||
const cfg = await desktopHostsGet().catch(() => null);
|
||||
if (!cfg) return;
|
||||
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, apiBaseUrl)) {
|
||||
if (!cfg || !isRuntimeIdentityActive(runtime)) return false;
|
||||
if (cfg.localOrigin && sameOrigin(cfg.localOrigin, runtime.apiBaseUrl)) {
|
||||
await desktopHostsSet({
|
||||
hosts: cfg.hosts,
|
||||
defaultHostId: cfg.defaultHostId,
|
||||
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
|
||||
localClientToken: clientToken,
|
||||
}).catch(() => undefined);
|
||||
return;
|
||||
return isRuntimeIdentityActive(runtime);
|
||||
}
|
||||
let changed = false;
|
||||
const hosts = cfg.hosts.map((host) => {
|
||||
if (!sameOrigin(getDesktopHostApiUrl(host), apiBaseUrl)) {
|
||||
if (!sameOrigin(getDesktopHostApiUrl(host), runtime.apiBaseUrl)) {
|
||||
return host;
|
||||
}
|
||||
if (host.clientToken === clientToken) {
|
||||
@@ -210,24 +224,31 @@ const persistDesktopClientToken = async (apiBaseUrl: string, clientToken: string
|
||||
changed = true;
|
||||
return { ...host, clientToken };
|
||||
});
|
||||
if (!changed) return;
|
||||
if (!changed) return true;
|
||||
if (!isRuntimeIdentityActive(runtime)) return false;
|
||||
await desktopHostsSet({
|
||||
hosts,
|
||||
defaultHostId: cfg.defaultHostId,
|
||||
initialHostChoiceCompleted: cfg.initialHostChoiceCompleted,
|
||||
}).catch(() => undefined);
|
||||
return isRuntimeIdentityActive(runtime);
|
||||
};
|
||||
|
||||
const applyDesktopClientToken = async (clientToken: string): Promise<void> => {
|
||||
if (!clientToken) return;
|
||||
const apiBaseUrl = getRuntimeApiBaseUrl();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
await persistDesktopClientToken(apiBaseUrl, clientToken);
|
||||
const applyDesktopClientToken = async (
|
||||
clientToken: string,
|
||||
runtime: RuntimeIdentity,
|
||||
requestHeaders: Record<string, string>,
|
||||
): Promise<boolean> => {
|
||||
if (!clientToken || !isRuntimeIdentityActive(runtime)) return false;
|
||||
if (!await persistDesktopClientToken(runtime, clientToken)) return false;
|
||||
if (!isRuntimeIdentityActive(runtime)) return false;
|
||||
switchRuntimeEndpoint({
|
||||
apiBaseUrl,
|
||||
apiBaseUrl: runtime.apiBaseUrl,
|
||||
clientToken,
|
||||
requestHeaders: Object.keys(requestHeaders).length > 0 ? requestHeaders : null,
|
||||
runtimeKey: runtime.runtimeKey,
|
||||
});
|
||||
return true;
|
||||
};
|
||||
|
||||
const AuthShell: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
@@ -338,17 +359,21 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
window.localStorage.setItem(TRUST_DEVICE_STORAGE_KEY, trustDevice ? 'true' : 'false');
|
||||
}, [trustDevice]);
|
||||
|
||||
const refreshPasskeyStatus = React.useCallback(async () => {
|
||||
const refreshPasskeyStatus = React.useCallback(async (runtime = captureRuntimeIdentity()) => {
|
||||
if (skipAuth) {
|
||||
return defaultPasskeyStatus;
|
||||
}
|
||||
|
||||
try {
|
||||
const nextStatus = await fetchPasskeyStatus();
|
||||
setPasskeyStatus(nextStatus);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setPasskeyStatus(nextStatus);
|
||||
}
|
||||
return nextStatus;
|
||||
} catch {
|
||||
setPasskeyStatus(defaultPasskeyStatus);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setPasskeyStatus(defaultPasskeyStatus);
|
||||
}
|
||||
return defaultPasskeyStatus;
|
||||
}
|
||||
}, [skipAuth]);
|
||||
@@ -423,14 +448,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
const runtime = captureRuntimeIdentity();
|
||||
setState((prev) => (prev === 'authenticated' ? prev : 'pending'));
|
||||
try {
|
||||
const [response, latestPasskeyStatus] = await Promise.all([
|
||||
fetchSessionStatus(),
|
||||
refreshPasskeyStatus(),
|
||||
refreshPasskeyStatus(runtime),
|
||||
]);
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!isRuntimeIdentityActive(runtime)) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (response.ok) {
|
||||
resetTransientRetry();
|
||||
setState('authenticated');
|
||||
@@ -472,6 +502,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setState('error');
|
||||
setIsTunnelLocked(false);
|
||||
} catch (error) {
|
||||
if (!isRuntimeIdentityActive(runtime)) {
|
||||
return;
|
||||
}
|
||||
console.warn('Failed to check session status:', error);
|
||||
if (resolveStatusCheckFailureState({ shouldUseDesktopShellPasswordLogin: shouldUseDesktopShellPasswordLogin() }) === 'locked') {
|
||||
setState('locked');
|
||||
@@ -504,10 +537,14 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
|
||||
return subscribeRuntimeEndpointChanged(() => {
|
||||
cancelPasskeyCeremony();
|
||||
setPassword('');
|
||||
setErrorMessage('');
|
||||
setRetryAfter(undefined);
|
||||
setIsTunnelLocked(false);
|
||||
setIsSubmitting(false);
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
resetTransientRetry();
|
||||
setState('pending');
|
||||
void checkStatus();
|
||||
@@ -534,8 +571,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
if (state === 'authenticated' && !hasResyncedRef.current) {
|
||||
hasResyncedRef.current = true;
|
||||
void (async () => {
|
||||
await syncDesktopSettings();
|
||||
await initializeAppearancePreferences();
|
||||
await syncDesktopSettings();
|
||||
await applyPersistedDirectoryPreferences();
|
||||
})();
|
||||
}
|
||||
@@ -547,15 +584,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
};
|
||||
|
||||
const registerPasskeyForCurrentSession = React.useCallback(async () => {
|
||||
const runtime = captureRuntimeIdentity();
|
||||
setActivePasskeyAction('register');
|
||||
setIsPasskeyBusy(true);
|
||||
try {
|
||||
await registerCurrentDevicePasskey();
|
||||
} finally {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}
|
||||
}
|
||||
await refreshPasskeyStatus();
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
await refreshPasskeyStatus(runtime);
|
||||
}, [refreshPasskeyStatus]);
|
||||
|
||||
const cancelActivePasskey = React.useCallback(() => {
|
||||
@@ -576,16 +617,19 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
cancelActivePasskey();
|
||||
}
|
||||
|
||||
const runtime = captureRuntimeIdentity();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
setIsSubmitting(true);
|
||||
setErrorMessage('');
|
||||
|
||||
try {
|
||||
if (shouldUseDesktopShellPasswordLogin()) {
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders);
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (shellLogin?.token) {
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
await applyDesktopClientToken(shellLogin.token);
|
||||
if (!await applyDesktopClientToken(shellLogin.token, runtime, requestHeaders)) return;
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -604,8 +648,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
}
|
||||
|
||||
const response = await submitPassword(password, trustDevice);
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (response.ok) {
|
||||
const payload = await response.json().catch(() => null) as { clientToken?: unknown } | null;
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
const shouldUseClientToken = shouldIssueDesktopClientToken();
|
||||
let clientToken = '';
|
||||
if (shouldUseClientToken) {
|
||||
@@ -613,18 +659,21 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
? payload.clientToken.trim()
|
||||
: '';
|
||||
if (!clientToken) {
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice);
|
||||
const shellLogin = await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders);
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
clientToken = shellLogin?.token || await issueDesktopClientToken();
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
}
|
||||
}
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
if (clientToken) {
|
||||
await applyDesktopClientToken(clientToken);
|
||||
if (!await applyDesktopClientToken(clientToken, runtime, requestHeaders)) return;
|
||||
}
|
||||
if (enrollPasskey && supportsPasskeys) {
|
||||
try {
|
||||
await registerPasskeyForCurrentSession();
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
toast.success(t('sessionAuth.toast.passkeyAdded'));
|
||||
setState('authenticated');
|
||||
return;
|
||||
@@ -662,14 +711,16 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
} catch (error) {
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
console.warn('Failed to submit UI password:', error);
|
||||
const shellLogin = shouldUseDesktopShellPasswordLogin()
|
||||
? await issueDesktopClientTokenViaShell(password, trustDevice)
|
||||
? await issueDesktopClientTokenViaShell(password, trustDevice, runtime, requestHeaders)
|
||||
: null;
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (shellLogin?.token) {
|
||||
setPassword('');
|
||||
setIsTunnelLocked(false);
|
||||
await applyDesktopClientToken(shellLogin.token);
|
||||
if (!await applyDesktopClientToken(shellLogin.token, runtime, requestHeaders)) return;
|
||||
setState('authenticated');
|
||||
return;
|
||||
}
|
||||
@@ -689,7 +740,9 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setIsTunnelLocked(false);
|
||||
setState('error');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, isTunnelLocked, password, registerPasskeyForCurrentSession, supportsPasskeys, t, trustDevice]);
|
||||
|
||||
@@ -706,6 +759,8 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setIsPasskeyBusy(true);
|
||||
setActivePasskeyAction('auth');
|
||||
setErrorMessage('');
|
||||
const runtime = captureRuntimeIdentity();
|
||||
const requestHeaders = getRuntimeExtraHeadersSync();
|
||||
|
||||
try {
|
||||
const payload = await authenticateWithPasskey(trustDevice, {
|
||||
@@ -716,13 +771,15 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
const clientToken = shouldIssueDesktopClientToken() && typeof payload?.clientToken === 'string' && payload.clientToken.trim()
|
||||
? payload.clientToken.trim()
|
||||
: '';
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (clientToken) {
|
||||
await applyDesktopClientToken(clientToken);
|
||||
if (!await applyDesktopClientToken(clientToken, runtime, requestHeaders)) return;
|
||||
}
|
||||
|
||||
setPassword('');
|
||||
setState('authenticated');
|
||||
} catch (error) {
|
||||
if (!isRuntimeIdentityActive(runtime)) return;
|
||||
if (isPasskeyCeremonyAbort(error)) {
|
||||
setErrorMessage('');
|
||||
} else {
|
||||
@@ -730,8 +787,10 @@ export const SessionAuthGate: React.FC<SessionAuthGateProps> = ({
|
||||
setErrorMessage(message);
|
||||
}
|
||||
} finally {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
if (isRuntimeIdentityActive(runtime)) {
|
||||
setActivePasskeyAction(null);
|
||||
setIsPasskeyBusy(false);
|
||||
}
|
||||
}
|
||||
}, [cancelActivePasskey, isPasskeyBusy, isSubmitting, supportsPasskeys, t, trustDevice]);
|
||||
|
||||
|
||||
@@ -1,5 +1,14 @@
|
||||
export type GateState = 'pending' | 'authenticated' | 'locked' | 'error' | 'rate-limited';
|
||||
|
||||
export type RuntimeIdentity = {
|
||||
apiBaseUrl: string;
|
||||
runtimeKey: string;
|
||||
};
|
||||
|
||||
export const runtimeIdentityMatches = (left: RuntimeIdentity, right: RuntimeIdentity): boolean => {
|
||||
return left.apiBaseUrl === right.apiBaseUrl && left.runtimeKey === right.runtimeKey;
|
||||
};
|
||||
|
||||
export const resolveStatusCheckFailureState = (options: {
|
||||
shouldUseDesktopShellPasswordLogin?: boolean;
|
||||
}): Exclude<GateState, 'pending' | 'authenticated' | 'rate-limited'> => {
|
||||
|
||||
@@ -36,16 +36,16 @@ import { useStreamingStore } from '@/sync/streaming';
|
||||
import {
|
||||
useSessionMessageCount,
|
||||
useSessionMessageRecords,
|
||||
useSessionMessageLoadState,
|
||||
useSyncDirectory,
|
||||
useDirectorySync,
|
||||
useSessionRenderable,
|
||||
useSessionStatus,
|
||||
useScopedBlockingPermissions,
|
||||
useScopedBlockingQuestions,
|
||||
useParentSession,
|
||||
useSession,
|
||||
} from '@/sync/sync-context';
|
||||
import { useSync } from '@/sync/use-sync';
|
||||
import { getSessionPrefetch, subscribeSessionPrefetch } from '@/sync/session-prefetch-cache';
|
||||
import { getSessionMaterializationStatus } from '@/sync/materialization';
|
||||
import { usePlanDetection } from '@/hooks/usePlanDetection';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { isMobileSurfaceRuntime } from '@/lib/runtimeSurface';
|
||||
@@ -54,6 +54,9 @@ import { getEmbeddedSessionChatOriginSessionId } from '@/components/layout/conte
|
||||
import { isFullySyntheticMessage } from '@/lib/messages/synthetic';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { findShellCommandForMessage, isUserShellMarkerMessage } from './lib/shellBridge';
|
||||
import { resolveChatPromptReadOnly } from './chatPromptReadOnly';
|
||||
import { getRuntimeKey } from '@/lib/runtime-switch';
|
||||
import { createFirstVisibleSessionPerformanceTracker } from '@/sync/session-load-performance';
|
||||
|
||||
const EMPTY_MESSAGES: Array<{ info: Message; parts: Part[] }> = [];
|
||||
const IDLE_SESSION_STATUS = { type: 'idle' as const };
|
||||
@@ -139,6 +142,7 @@ type HydratingToolSkeletonRow = {
|
||||
|
||||
type ChatViewportProps = {
|
||||
currentSessionId: string;
|
||||
currentSessionKey: string;
|
||||
isDesktopExpandedInput: boolean;
|
||||
isMobile: boolean;
|
||||
stickyUserHeader: boolean;
|
||||
@@ -177,6 +181,7 @@ type ChatViewportProps = {
|
||||
|
||||
const ChatViewport = React.memo(({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
isDesktopExpandedInput,
|
||||
isMobile,
|
||||
stickyUserHeader,
|
||||
@@ -215,6 +220,11 @@ const ChatViewport = React.memo(({
|
||||
// Shell-mode prompts show their extracted command; cache by message id so
|
||||
// the parts array reference is stable while the command is unchanged.
|
||||
const shellPreviewCache = React.useRef(new Map<string, { command: string; parts: Part[] }>());
|
||||
const shellPreviewSessionRef = React.useRef(currentSessionId);
|
||||
if (shellPreviewSessionRef.current !== currentSessionId) {
|
||||
shellPreviewSessionRef.current = currentSessionId;
|
||||
shellPreviewCache.current.clear();
|
||||
}
|
||||
const promptPreviewsByTurnId = React.useMemo(() => {
|
||||
const next = new Map<string, Part[]>();
|
||||
for (let index = 0; index < renderedMessages.length; index += 1) {
|
||||
@@ -339,6 +349,7 @@ const ChatViewport = React.memo(({
|
||||
</div>
|
||||
)}
|
||||
<MessageList
|
||||
key={currentSessionKey}
|
||||
ref={messageListRef}
|
||||
sessionKey={currentSessionId}
|
||||
disableStaging={pendingRevealWork}
|
||||
@@ -391,6 +402,7 @@ const ChatViewport = React.memo(({
|
||||
);
|
||||
}, (prev, next) => {
|
||||
return prev.currentSessionId === next.currentSessionId
|
||||
&& prev.currentSessionKey === next.currentSessionKey
|
||||
&& prev.isDesktopExpandedInput === next.isDesktopExpandedInput
|
||||
&& prev.isMobile === next.isMobile
|
||||
&& prev.stickyUserHeader === next.stickyUserHeader
|
||||
@@ -487,12 +499,42 @@ const renderDraftTitle = (title: string, projectLabel: string | null): React.Rea
|
||||
);
|
||||
};
|
||||
|
||||
const DraftWelcome: React.FC = () => {
|
||||
const { t } = useI18n();
|
||||
const selectedProjectId = useSessionUIStore((state) => state.newSessionDraft.selectedProjectId ?? null);
|
||||
const projectLabel = useProjectsStore(React.useCallback((state) => {
|
||||
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]));
|
||||
|
||||
return (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
projectLabel
|
||||
? t('chat.emptyState.draftTitleWithProject', { project: projectLabel })
|
||||
: t('chat.emptyState.draftTitle'),
|
||||
projectLabel,
|
||||
)}
|
||||
</h1>
|
||||
<DraftPresetChips
|
||||
onSubmit={(starter) => useInputStore.getState().requestPresetSubmit(starter.submitText, starter.ref.type)}
|
||||
className="oc-draft-starters mt-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
type ChatContainerProps = {
|
||||
active?: boolean;
|
||||
autoOpenDraft?: boolean;
|
||||
readOnly?: boolean;
|
||||
};
|
||||
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = true, readOnly = false }) => {
|
||||
export const ChatContainer: React.FC<ChatContainerProps> = ({ active = true, autoOpenDraft = true, readOnly = false }) => {
|
||||
const { t } = useI18n();
|
||||
// Session UI state
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
@@ -500,21 +542,22 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const openNewSessionDraft = useSessionUIStore((s) => s.openNewSessionDraft);
|
||||
const setCurrentSession = useSessionUIStore((s) => s.setCurrentSession);
|
||||
const newSessionDraft = useSessionUIStore((s) => s.newSessionDraft);
|
||||
const projects = useProjectsStore((s) => s.projects);
|
||||
const activeProjectId = useProjectsStore((s) => s.activeProjectId);
|
||||
|
||||
// Sync actions
|
||||
const sync = useSync();
|
||||
const syncDirectory = useSyncDirectory();
|
||||
const effectiveSessionDirectory = currentSessionDirectory ?? syncDirectory;
|
||||
const currentSessionKey = currentSessionId
|
||||
? JSON.stringify([getRuntimeKey(), effectiveSessionDirectory, currentSessionId])
|
||||
: null;
|
||||
const ensureSessionRenderable = React.useCallback(
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId),
|
||||
[sync],
|
||||
(sessionId: string) => sync.ensureSessionRenderable(sessionId, false, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
const loadMoreMessages = React.useCallback(
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId),
|
||||
[sync],
|
||||
(sessionId: string, _direction: 'up' | 'down') => sync.loadMore(sessionId, effectiveSessionDirectory),
|
||||
[effectiveSessionDirectory, sync],
|
||||
);
|
||||
|
||||
// UI store
|
||||
@@ -542,32 +585,24 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
),
|
||||
);
|
||||
const sessionMessageCount = useSessionMessageCount(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
const hasRenderableSessionSnapshot = useDirectorySync(
|
||||
React.useCallback(
|
||||
(state) => (currentSessionId ? getSessionMaterializationStatus(state, currentSessionId).renderable : false),
|
||||
[currentSessionId],
|
||||
),
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const hasRenderableSessionSnapshot = useSessionRenderable(currentSessionId ?? '', effectiveSessionDirectory);
|
||||
// Messages from sync system
|
||||
const sessionMessageRecords = useSessionMessageRecords(currentSessionId ?? '', effectiveSessionDirectory, {
|
||||
enabled: active,
|
||||
suspendPartUpdates: Boolean(streamingMessageId),
|
||||
suspendPartUpdatesForMessageId: streamingMessageId,
|
||||
});
|
||||
const sessionMessages = currentSessionId ? sessionMessageRecords : EMPTY_MESSAGES;
|
||||
const sessionPrefetchInfo = React.useSyncExternalStore(
|
||||
React.useCallback(
|
||||
(notify) => currentSessionId
|
||||
? subscribeSessionPrefetch(effectiveSessionDirectory, currentSessionId, notify)
|
||||
: () => undefined,
|
||||
[currentSessionId, effectiveSessionDirectory],
|
||||
),
|
||||
React.useCallback(
|
||||
() => currentSessionId ? getSessionPrefetch(effectiveSessionDirectory, currentSessionId) : undefined,
|
||||
[currentSessionId, effectiveSessionDirectory],
|
||||
),
|
||||
React.useCallback(() => undefined, []),
|
||||
const sessionMessageLoadState = useSessionMessageLoadState(
|
||||
currentSessionId ?? '',
|
||||
effectiveSessionDirectory,
|
||||
);
|
||||
const [firstVisiblePerformance] = React.useState(createFirstVisibleSessionPerformanceTracker);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!active || !currentSessionKey || !hasRenderableSessionSnapshot || sessionMessages.length === 0) return;
|
||||
return firstVisiblePerformance.schedule(currentSessionKey, sessionMessages.length);
|
||||
}, [active, currentSessionKey, firstVisiblePerformance, hasRenderableSessionSnapshot, sessionMessages.length]);
|
||||
|
||||
// Plan detection - watches messages for plan creation and signals store
|
||||
usePlanDetection(currentSessionId ?? '', sessionMessages);
|
||||
@@ -643,20 +678,12 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// History metadata — use sync's hasMore/isLoading
|
||||
const historyMeta = React.useMemo(() => {
|
||||
if (!currentSessionId) return null;
|
||||
// Sync's meta is authoritative once a fetch has confirmed the history
|
||||
// is fully loaded — a stale prefetch-cache entry (cursor recorded at
|
||||
// the initial page) must not keep the "load older" affordance alive
|
||||
// after the user has already reached the top.
|
||||
const syncComplete = sync.isComplete(currentSessionId);
|
||||
const prefetchHasMore = !syncComplete
|
||||
&& Boolean(sessionPrefetchInfo?.cursor)
|
||||
&& sessionPrefetchInfo?.complete !== true;
|
||||
return {
|
||||
limit: sessionMessages.length,
|
||||
complete: syncComplete || !(sync.hasMore(currentSessionId) || prefetchHasMore),
|
||||
loading: sync.isLoading(currentSessionId),
|
||||
complete: sessionMessageLoadState.complete || !sessionMessageLoadState.cursor,
|
||||
loading: sessionMessageLoadState.status === 'loading',
|
||||
};
|
||||
}, [currentSessionId, sessionMessages.length, sessionPrefetchInfo, sync]);
|
||||
}, [currentSessionId, sessionMessageLoadState.complete, sessionMessageLoadState.cursor, sessionMessageLoadState.status, sessionMessages.length]);
|
||||
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const isVSCode = isVSCodeRuntime();
|
||||
@@ -668,17 +695,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
const isDesktopExpandedInput = isExpandedInput;
|
||||
const useCompactDraftLayout = isMobile || isVSCode || chatSurfaceMode === 'mini-chat';
|
||||
const messageListRef = React.useRef<MessageListHandle | null>(null);
|
||||
const draftProjectLabel = React.useMemo(() => {
|
||||
const selectedProject = newSessionDraft?.selectedProjectId
|
||||
? projects.find((project) => project.id === newSessionDraft.selectedProjectId) ?? null
|
||||
: null;
|
||||
const activeProject = activeProjectId
|
||||
? projects.find((project) => project.id === activeProjectId) ?? null
|
||||
: null;
|
||||
const project = selectedProject ?? activeProject ?? projects[0] ?? null;
|
||||
return project ? getProjectDisplayLabel(project) : null;
|
||||
}, [activeProjectId, newSessionDraft?.selectedProjectId, projects]);
|
||||
|
||||
const currentSession = useSession(currentSessionId, effectiveSessionDirectory);
|
||||
const parentSession = useParentSession(currentSessionId, effectiveSessionDirectory);
|
||||
|
||||
// In the embedded session-chat iframe, hide "Return to parent" when
|
||||
@@ -712,13 +729,18 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
{t('chat.container.returnToParent.label')}
|
||||
</Button>
|
||||
) : null;
|
||||
const promptReadOnly = parentSession ? !allowPromptingSubagentSessions : readOnly;
|
||||
const promptReadOnly = resolveChatPromptReadOnly(currentSession, allowPromptingSubagentSessions, readOnly);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (typeof window === 'undefined' || window.parent === window) {
|
||||
// VS Code/Cursor/Positron webviews delete window.parent (and window.top).
|
||||
// The old `window.parent === window` check does not catch that, so
|
||||
// `window.parent.postMessage(...)` threw on chat open:
|
||||
// TypeError: Cannot read properties of undefined (reading 'postMessage')
|
||||
if (typeof window === 'undefined' || !window.parent || window.parent === window) {
|
||||
return;
|
||||
}
|
||||
|
||||
const parentWindow = window.parent;
|
||||
const applySetting = (value: boolean) => {
|
||||
useUIStore.getState().setAllowPromptingSubagentSessions(value);
|
||||
};
|
||||
@@ -729,7 +751,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
applySetting(payload.allowPromptingSubagentSessions);
|
||||
};
|
||||
const handleMessage = (event: MessageEvent) => {
|
||||
if (event.source !== window.parent || event.origin !== window.location.origin) return;
|
||||
if (event.source !== parentWindow || event.origin !== window.location.origin) return;
|
||||
const data = event.data as { type?: unknown; payload?: { allowPromptingSubagentSessions?: unknown } };
|
||||
if (data?.type !== 'openchamber:chat-settings-sync'
|
||||
|| typeof data.payload?.allowPromptingSubagentSessions !== 'boolean') return;
|
||||
@@ -738,7 +760,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
scopedWindow.__openchamberApplyChatSettingsSync = applySync;
|
||||
window.addEventListener('message', handleMessage);
|
||||
window.parent.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin);
|
||||
parentWindow.postMessage({ type: 'openchamber:chat-settings-request' }, window.location.origin);
|
||||
return () => {
|
||||
window.removeEventListener('message', handleMessage);
|
||||
if (scopedWindow.__openchamberApplyChatSettingsSync === applySync) {
|
||||
@@ -749,7 +771,9 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
React.useEffect(() => {
|
||||
if (autoOpenDraft && !currentSessionId && !draftOpen) {
|
||||
openNewSessionDraft();
|
||||
// Programmatic fallback, not user navigation — must not clear the
|
||||
// persisted last-session pointer the cold-launch restore reads.
|
||||
openNewSessionDraft({ automatic: true });
|
||||
}
|
||||
}, [autoOpenDraft, currentSessionId, draftOpen, openNewSessionDraft]);
|
||||
|
||||
@@ -771,6 +795,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
showScrollButton,
|
||||
} = useChatAutoFollow({
|
||||
currentSessionId,
|
||||
currentSessionKey,
|
||||
sessionMessageCount,
|
||||
sessionIsWorking,
|
||||
isMobile,
|
||||
@@ -781,6 +806,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
|
||||
const timelineController = useChatTimelineController({
|
||||
sessionId: currentSessionId,
|
||||
sessionKey: currentSessionKey,
|
||||
messages: viewportMessages,
|
||||
historyMeta,
|
||||
scrollRef,
|
||||
@@ -932,18 +958,22 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
};
|
||||
}, [currentSessionId, isDesktopExpandedInput, scrollRef]);
|
||||
|
||||
const lastScrolledSessionRef = React.useRef<string | null>(null);
|
||||
const lastScrolledSessionKeyRef = React.useRef<string | null>(null);
|
||||
|
||||
const isSessionHydrating =
|
||||
Boolean(currentSessionId)
|
||||
&& !hasRenderableSessionSnapshot;
|
||||
const retrySessionLoad = React.useCallback(() => {
|
||||
if (!active || !currentSessionId) return;
|
||||
void sync.ensureSessionRenderable(currentSessionId, true, effectiveSessionDirectory);
|
||||
}, [active, currentSessionId, effectiveSessionDirectory, sync]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (lastScrolledSessionRef.current === currentSessionId) return;
|
||||
if (!active || !currentSessionId) return;
|
||||
if (lastScrolledSessionKeyRef.current === currentSessionKey) return;
|
||||
|
||||
const hasHashTarget = typeof window !== 'undefined' && window.location.hash.length > 0;
|
||||
lastScrolledSessionRef.current = currentSessionId;
|
||||
lastScrolledSessionKeyRef.current = currentSessionKey;
|
||||
if (hasHashTarget) {
|
||||
// Hash navigation handler will scroll to target; we just release auto-follow.
|
||||
releaseAutoFollow();
|
||||
@@ -958,14 +988,13 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
} else {
|
||||
window.requestAnimationFrame(run);
|
||||
}
|
||||
}, [currentSessionId, releaseAutoFollow, restoreSnapshot]);
|
||||
}, [active, currentSessionId, currentSessionKey, releaseAutoFollow, restoreSnapshot]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentSessionId) return;
|
||||
if (!active || !currentSessionId) return;
|
||||
if (hasRenderableSessionSnapshot) return;
|
||||
if (effectiveSessionDirectory !== syncDirectory) return;
|
||||
void ensureSessionRenderable(currentSessionId);
|
||||
}, [currentSessionId, effectiveSessionDirectory, ensureSessionRenderable, hasRenderableSessionSnapshot, syncDirectory]);
|
||||
}, [active, currentSessionId, ensureSessionRenderable, hasRenderableSessionSnapshot]);
|
||||
|
||||
if (!currentSessionId && !draftOpen) {
|
||||
// With auto-open, the draft welcome opens on the next tick (effect below),
|
||||
@@ -987,23 +1016,8 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
// No transform on this root: it would become the containing block for
|
||||
// the fullscreen composer's position:fixed visual-viewport pinning in
|
||||
// mobile browsers (see ChatInput's composerFormRef effect).
|
||||
<div className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? (
|
||||
<div className="oc-draft-center flex min-h-0 flex-1 flex-col items-center justify-center px-6 text-center">
|
||||
<h1 className="text-balance text-3xl font-normal tracking-tight text-foreground">
|
||||
{renderDraftTitle(
|
||||
draftProjectLabel
|
||||
? t('chat.emptyState.draftTitleWithProject', { project: draftProjectLabel })
|
||||
: t('chat.emptyState.draftTitle'),
|
||||
draftProjectLabel,
|
||||
)}
|
||||
</h1>
|
||||
<DraftPresetChips
|
||||
onSubmit={(text) => useInputStore.getState().requestPresetSubmit(text)}
|
||||
className="oc-draft-starters mt-8 max-w-md"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
||||
{useCompactDraftLayout && !isDesktopExpandedInput ? <DraftWelcome /> : null}
|
||||
<div
|
||||
className={cn(
|
||||
'relative z-10 flex min-h-0',
|
||||
@@ -1025,8 +1039,30 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
}
|
||||
|
||||
if (isSessionHydrating && sessionMessages.length === 0 && !sessionIsWorking) {
|
||||
if (sessionMessageLoadState.status === 'error') {
|
||||
return (
|
||||
<div data-composer-bound className="relative flex h-full flex-col bg-background">
|
||||
{returnToParentButton}
|
||||
<div className="flex min-h-0 flex-1 items-center justify-center px-6">
|
||||
<div className="max-w-sm text-center">
|
||||
<div className="mx-auto mb-3 flex size-9 items-center justify-center rounded-full bg-[color-mix(in_srgb,var(--status-error)_10%,transparent)] text-[var(--status-error)]">
|
||||
<Icon name="error-warning" className="size-4" />
|
||||
</div>
|
||||
<p className="typography-ui-label font-medium text-foreground">{t('chat.container.sessionLoadError.title')}</p>
|
||||
<p className="typography-meta mt-1 text-muted-foreground">{t('chat.container.sessionLoadError.description')}</p>
|
||||
<Button variant="outline" size="sm" className="mt-4" onClick={retrySessionLoad}>
|
||||
{t('chat.container.sessionLoadError.retry')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative z-10 bg-background">
|
||||
{promptReadOnly ? <ReadOnlyPromptBanner /> : <ChatInput scrollToBottom={scrollToBottomOnSend} />}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1084,7 +1120,7 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
return (
|
||||
// No transform here either — same fixed-positioning constraint as the
|
||||
// draft branch above.
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<div
|
||||
className={cn(
|
||||
@@ -1116,11 +1152,11 @@ export const ChatContainer: React.FC<ChatContainerProps> = ({ autoOpenDraft = tr
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col h-full bg-background">
|
||||
<div data-composer-bound className="relative flex flex-col h-full bg-background">
|
||||
{returnToParentButton}
|
||||
<ChatViewport
|
||||
key={currentSessionId}
|
||||
currentSessionId={currentSessionId}
|
||||
currentSessionKey={currentSessionKey ?? currentSessionId}
|
||||
isDesktopExpandedInput={isDesktopExpandedInput}
|
||||
isMobile={isMobile}
|
||||
stickyUserHeader={stickyUserHeader}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,23 +14,29 @@ import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
import type { AnimationHandlers, ContentChangeReason } from '@/hooks/useChatAutoFollow';
|
||||
import MessageHeader from './message/MessageHeader';
|
||||
import MessageBody from './message/MessageBody';
|
||||
import type { AgentMentionInfo } from './message/types';
|
||||
import type { StreamPhase, ToolPopupContent } from './message/types';
|
||||
import { deriveMessageRole } from './message/messageRole';
|
||||
import { filterVisibleParts, normalizeParts } from './message/partUtils';
|
||||
import { normalizeUserDisplayParts } from './message/normalizeUserDisplayParts';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { flattenAssistantTextParts } from '@/lib/messages/messageText';
|
||||
import { isLikelyProviderAuthFailure, PROVIDER_AUTH_FAILURE_MESSAGE } from '@/lib/messages/providerAuthError';
|
||||
import { getProviderModelDisplayName } from '@/lib/modelDisplay';
|
||||
import { lazyWithChunkRecovery } from '@/lib/chunkLoadRecovery';
|
||||
import type { TurnGroupingContext } from './lib/turns/types';
|
||||
import { copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { copyMarkdownToClipboard, copyTextToClipboard } from '@/lib/clipboard';
|
||||
import { FadeInOnReveal } from './message/FadeInOnReveal';
|
||||
import { streamPerfCount } from '@/stores/utils/streamDebug';
|
||||
import { areOptionalRenderRelevantMessagesEqual, areRenderRelevantMessagesEqual, areRelevantTurnGroupingContextsEqual } from './message/renderCompare';
|
||||
import type { ReviewTransferDirection } from '@/lib/reviewFlow';
|
||||
import { toast } from 'sonner';
|
||||
import { useI18n } from '@/lib/i18n';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { getContextObligatoryMessages } from '@/lib/contextObligatoryMessages';
|
||||
import { setContextObligatoryMessage } from '@/sync/session-actions';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
|
||||
const ToolOutputDialog = lazyWithChunkRecovery(() => import('./message/ToolOutputDialog'));
|
||||
|
||||
@@ -150,17 +156,15 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
onUserAnimationConsumed,
|
||||
reviewTransferDirection = null,
|
||||
}) => {
|
||||
const { t } = useI18n();
|
||||
const { isMobile, isTablet, hasTouchInput } = useDeviceInfo();
|
||||
const alwaysShowMessageActions = isMobile || isTablet;
|
||||
const canPinIntoContext = !isVSCodeRuntime();
|
||||
const { currentTheme } = useThemeSystem();
|
||||
const messageContainerRef = React.useRef<HTMLDivElement | null>(null);
|
||||
|
||||
const currentSessionId = useSessionUIStore((s) => s.currentSessionId);
|
||||
|
||||
const getAgentModelForSession = useSelectionStore((s) => s.getAgentModelForSession);
|
||||
const getSessionModelSelection = useSelectionStore((s) => s.getSessionModelSelection);
|
||||
const revertToMessage = useSessionUIStore((s) => s.revertToMessage);
|
||||
const forkFromMessage = useSessionUIStore((s) => s.forkFromMessage);
|
||||
|
||||
streamPerfCount('ui.chat_message.render');
|
||||
if (isInActiveTurn) {
|
||||
@@ -178,12 +182,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
}))
|
||||
);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (currentSessionId) {
|
||||
MessageFreshnessDetector.getInstance().recordSessionStart(currentSessionId);
|
||||
}
|
||||
}, [currentSessionId]);
|
||||
|
||||
const [copiedCode, setCopiedCode] = React.useState<string | null>(null);
|
||||
const [copiedMessage, setCopiedMessage] = React.useState(false);
|
||||
const [expandedTools, setExpandedTools] = React.useState<Set<string>>(() => readExpandedToolsCache(message.info.id));
|
||||
@@ -402,6 +400,29 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const timeInfo = message.info.time as { created?: number } | undefined;
|
||||
return typeof timeInfo?.created === 'number' ? timeInfo.created : null;
|
||||
}, [message.info.time]);
|
||||
const isPinnedIntoContext = useGlobalSessionsStore((state) => {
|
||||
const session = state.activeSessions.find((candidate) => candidate.id === sessionId)
|
||||
?? state.archivedSessions.find((candidate) => candidate.id === sessionId);
|
||||
return getContextObligatoryMessages(session).some((entry) => entry.id === message.info.id);
|
||||
});
|
||||
const [pinPending, setPinPending] = React.useState(false);
|
||||
const handleToggleContextPin = React.useCallback(async () => {
|
||||
if (!sessionId || !messageCreatedAt || pinPending) return;
|
||||
setPinPending(true);
|
||||
try {
|
||||
const directory = useSessionUIStore.getState().getDirectoryForSession(sessionId);
|
||||
await setContextObligatoryMessage(sessionId, directory, {
|
||||
id: message.info.id,
|
||||
createdAt: messageCreatedAt,
|
||||
role: isUser ? 'user' : 'assistant',
|
||||
}, !isPinnedIntoContext);
|
||||
} catch (error) {
|
||||
console.error('[chat-message] failed to update context pin', error);
|
||||
toast.error(t('chat.messageBody.actions.contextPinFailed'));
|
||||
} finally {
|
||||
setPinPending(false);
|
||||
}
|
||||
}, [isPinnedIntoContext, isUser, message.info.id, messageCreatedAt, pinPending, sessionId, t]);
|
||||
|
||||
const isMessageCompleted = React.useMemo(() => {
|
||||
if (isUser) return true;
|
||||
@@ -549,8 +570,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const shouldAnimateMessage = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
const freshnessDetector = MessageFreshnessDetector.getInstance();
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, currentSessionId || message.info.sessionID);
|
||||
}, [message.info, currentSessionId, isUser]);
|
||||
return freshnessDetector.shouldAnimateMessage(message.info, message.info.sessionID);
|
||||
}, [message.info, isUser]);
|
||||
|
||||
const [hasStartedStreamingHeader, setHasStartedStreamingHeader] = React.useState(false);
|
||||
|
||||
@@ -562,6 +583,16 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const hasTurnGrouping = Boolean(turnGroupingContext);
|
||||
const isLastAssistantInTurn = turnGroupingContext?.isLastAssistantInTurn ?? false;
|
||||
|
||||
const previousIsHiddenUserMessage = React.useMemo(
|
||||
() => !isUser && isHiddenUserMessage(previousMessage, { planModeEnabled }),
|
||||
[isUser, planModeEnabled, previousMessage]
|
||||
);
|
||||
|
||||
const nextIsHiddenUserMessage = React.useMemo(
|
||||
() => !isUser && isHiddenUserMessage(nextMessage, { planModeEnabled }),
|
||||
[isUser, planModeEnabled, nextMessage]
|
||||
);
|
||||
|
||||
const isFollowedByAssistant = React.useMemo(() => {
|
||||
if (isUser) return false;
|
||||
if (hasTurnGrouping) {
|
||||
@@ -740,7 +771,13 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
const hasTextContent = messageTextContent.length > 0;
|
||||
|
||||
const handleCopyMessage = React.useCallback(async () => {
|
||||
const result = await copyTextToClipboard(messageTextContent);
|
||||
let result;
|
||||
if (isUser) {
|
||||
result = await copyTextToClipboard(messageTextContent);
|
||||
} else {
|
||||
const { renderMarkdownSync } = await import('./markdown/markdownCore');
|
||||
result = await copyMarkdownToClipboard(messageTextContent, renderMarkdownSync(messageTextContent));
|
||||
}
|
||||
if (!result.ok) {
|
||||
return false;
|
||||
}
|
||||
@@ -753,14 +790,14 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
|
||||
const handleRevert = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
revertToMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id, revertToMessage]);
|
||||
useSessionUIStore.getState().revertToMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id]);
|
||||
|
||||
// NEW: Fork handler
|
||||
const handleFork = React.useCallback(() => {
|
||||
if (!sessionId || !message.info.id) return;
|
||||
forkFromMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id, forkFromMessage]);
|
||||
useSessionUIStore.getState().forkFromMessage(sessionId, message.info.id);
|
||||
}, [sessionId, message.info.id]);
|
||||
|
||||
const handleToggleTool = React.useCallback((toolId: string) => {
|
||||
const isDefaultOpen = defaultOpenToolIds.has(toolId);
|
||||
@@ -975,7 +1012,7 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
return null;
|
||||
}
|
||||
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader
|
||||
const assistantTopPaddingClass = !isUser && shouldShowHeader && !previousIsHiddenUserMessage
|
||||
? (stickyUserHeader ? (isMobile ? 'pt-4' : 'pt-6') : 'pt-0')
|
||||
: 'pt-0';
|
||||
const userMessageRadius = 'var(--radius-xl)';
|
||||
@@ -985,8 +1022,8 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
<div
|
||||
className={cn(
|
||||
'group w-full',
|
||||
isUser ? (isMobile ? 'pt-2' : 'pt-6') : assistantTopPaddingClass,
|
||||
isUser ? 'pb-0' : isFollowedByAssistant ? 'pb-0' : 'pb-8'
|
||||
isUser ? (isMobile ? 'pt-2' : 'pt-4') : assistantTopPaddingClass,
|
||||
isUser ? 'pb-0' : (isFollowedByAssistant || nextIsHiddenUserMessage) ? 'pb-0' : 'pb-2'
|
||||
)}
|
||||
id={`message-${message.info.id}`}
|
||||
data-message-id={message.info.id}
|
||||
@@ -1038,6 +1075,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode={useExternalUserActionsRow ? 'external-content' : 'inline'}
|
||||
@@ -1072,6 +1112,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
agentMention={agentMention}
|
||||
onRevert={handleRevert}
|
||||
onFork={isUser ? handleFork : undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
userActionsMode="external-actions"
|
||||
@@ -1084,17 +1127,6 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
)
|
||||
) : (
|
||||
<div className="relative">
|
||||
{shouldShowHeader && (
|
||||
<MessageHeader
|
||||
isUser={isUser}
|
||||
providerID={headerProviderID}
|
||||
agentName={headerAgentName}
|
||||
modelName={headerModelName}
|
||||
variant={headerVariant}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
)}
|
||||
|
||||
<MessageBody
|
||||
sessionId={message.info.sessionID}
|
||||
messageId={message.info.id}
|
||||
@@ -1104,6 +1136,9 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
messageFinish={messageFinish}
|
||||
messageCompletedAt={messageCompletedAt ?? undefined}
|
||||
messageCreatedAt={messageCreatedAt ?? undefined}
|
||||
contextPinned={isPinnedIntoContext}
|
||||
contextPinPending={pinPending}
|
||||
onToggleContextPin={canPinIntoContext && messageCreatedAt ? handleToggleContextPin : undefined}
|
||||
isMobile={isMobile}
|
||||
alwaysShowActions={alwaysShowMessageActions}
|
||||
hasTouchInput={hasTouchInput}
|
||||
@@ -1126,6 +1161,11 @@ const ChatMessage: React.FC<ChatMessageProps> = ({
|
||||
errorMessage={assistantErrorText}
|
||||
errorVariant={assistantErrorVariant}
|
||||
reviewTransferDirection={reviewTransferDirection}
|
||||
footerProviderID={headerProviderID}
|
||||
footerModelName={headerModelName}
|
||||
footerAgentName={headerAgentName}
|
||||
footerVariant={headerVariant}
|
||||
isDarkTheme={isDarkTheme}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -10,6 +10,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { isVSCodeRuntime } from '@/lib/desktop';
|
||||
import { useMobileAutocompleteMaxHeight } from './useMobileAutocompleteMaxHeight';
|
||||
import { commandMatchesSearch, mergeCommandAutocompleteItems } from './commandAutocompleteItems';
|
||||
|
||||
type CommandSource = 'openchamber' | 'opencode' | 'skill';
|
||||
|
||||
@@ -18,6 +19,7 @@ export interface CommandInfo {
|
||||
name: string;
|
||||
source: CommandSource;
|
||||
description?: string;
|
||||
searchAliases?: string[];
|
||||
agent?: string;
|
||||
model?: string;
|
||||
isBuiltIn?: boolean;
|
||||
@@ -170,6 +172,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
? [{ id: 'openchamber:craft-goal', name: 'craft-goal', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.craftGoalDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:schedule-task', name: 'schedule-task', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.scheduleTaskDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:catch-up', name: 'catch-up', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.catchUpDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
@@ -187,14 +193,11 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
: []
|
||||
),
|
||||
];
|
||||
const allCommands = [...builtInCommands, ...customCommands, ...skillCommands];
|
||||
const allCommands = mergeCommandAutocompleteItems(builtInCommands, customCommands, skillCommands);
|
||||
|
||||
const allowInitCommand = !hasMessagesInCurrentSession;
|
||||
const filtered = (searchQuery
|
||||
? allCommands.filter(cmd =>
|
||||
fuzzyMatch(cmd.name, searchQuery) ||
|
||||
(cmd.description && fuzzyMatch(cmd.description, searchQuery))
|
||||
)
|
||||
? allCommands.filter(cmd => commandMatchesSearch(cmd, searchQuery))
|
||||
: allCommands).filter(cmd => allowInitCommand || cmd.name !== 'init');
|
||||
|
||||
filtered.sort((a, b) => {
|
||||
@@ -243,6 +246,10 @@ export const CommandAutocomplete = React.forwardRef<CommandAutocompleteHandle, C
|
||||
? [{ id: 'openchamber:craft-goal', name: 'craft-goal', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.craftGoalDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:schedule-task', name: 'schedule-task', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.scheduleTaskDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
),
|
||||
...(canStartSessionCommand
|
||||
? [{ id: 'openchamber:catch-up', name: 'catch-up', source: 'openchamber' as const, description: t('chat.commandAutocomplete.command.catchUpDescription'), isOpenChamber: true }]
|
||||
: []
|
||||
|
||||
@@ -32,6 +32,7 @@ import { useI18n } from '@/lib/i18n';
|
||||
import { useThemeSystem } from '@/contexts/useThemeSystem';
|
||||
import { useDeviceInfo } from '@/lib/device';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import {
|
||||
useDraftStarters,
|
||||
type ResolvedStarter,
|
||||
@@ -40,8 +41,8 @@ import {
|
||||
} from './useDraftStarters';
|
||||
|
||||
type DraftPresetChipsProps = {
|
||||
/** Called with the resolved text (command or skill invocation) when a chip is clicked. */
|
||||
onSubmit: (text: string) => void;
|
||||
/** Called with the resolved starter invocation when a chip is clicked. */
|
||||
onSubmit: (starter: ResolvedStarter) => void;
|
||||
/** Extra classes for the wrapper (e.g. width/spacing per surface). */
|
||||
className?: string;
|
||||
};
|
||||
@@ -65,7 +66,7 @@ const PICKER_SECTIONS: { key: PinnableSection; headingKey: 'chat.draftStarters.s
|
||||
|
||||
const SortableChip: React.FC<{
|
||||
item: ResolvedStarter;
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (starter: ResolvedStarter) => void;
|
||||
onRemove: () => void;
|
||||
/** Hide the per-chip hover "x" (mobile uses the trash drop-zone instead). */
|
||||
hideRemove?: boolean;
|
||||
@@ -90,7 +91,7 @@ const SortableChip: React.FC<{
|
||||
type="button"
|
||||
{...attributes}
|
||||
{...listeners}
|
||||
onClick={() => onSubmit(item.submitText)}
|
||||
onClick={() => onSubmit(item)}
|
||||
className="group inline-flex touch-none select-none items-center gap-1.5 rounded-full border px-3 py-1.5 text-sm text-muted-foreground transition-colors hover:bg-[var(--interactive-hover)] hover:text-foreground"
|
||||
style={chipStyle}
|
||||
>
|
||||
@@ -115,7 +116,7 @@ const SortableChip: React.FC<{
|
||||
|
||||
const StarterGroup: React.FC<{
|
||||
items: ResolvedStarter[];
|
||||
onSubmit: (text: string) => void;
|
||||
onSubmit: (starter: ResolvedStarter) => void;
|
||||
onRemove: (item: ResolvedStarter) => void;
|
||||
hideRemove?: boolean;
|
||||
}> = ({ items, onSubmit, onRemove, hideRemove }) => (
|
||||
@@ -254,7 +255,7 @@ const AddStarterPicker: React.FC<{
|
||||
* Reorder is constrained to within a chip's own group; cross-group hovers are
|
||||
* ignored.
|
||||
*/
|
||||
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
||||
const DraftPresetChipsContent: React.FC<DraftPresetChipsProps> = ({ onSubmit, className }) => {
|
||||
const { global, project, pinnable, ensureLoaded, addStarter, removeStarter, reorder } = useDraftStarters();
|
||||
const { isMobile } = useDeviceInfo();
|
||||
const [isDragging, setIsDragging] = React.useState(false);
|
||||
@@ -331,3 +332,8 @@ export const DraftPresetChips: React.FC<DraftPresetChipsProps> = ({ onSubmit, cl
|
||||
</DndContext>
|
||||
);
|
||||
};
|
||||
|
||||
export const DraftPresetChips: React.FC<DraftPresetChipsProps> = (props) => {
|
||||
const visible = useUIStore((state) => state.draftStartersVisible);
|
||||
return visible ? <DraftPresetChipsContent {...props} /> : null;
|
||||
};
|
||||
|
||||
@@ -25,13 +25,13 @@ import {
|
||||
attachMarkdownInteractions,
|
||||
applyMarkdownCodeBlockWrapState,
|
||||
decorateMarkdown,
|
||||
scheduleMarkdownCodeLineNumberSync,
|
||||
syncMarkdownCodeLineNumbers,
|
||||
getMarkdownCodeText,
|
||||
type DecorateContext,
|
||||
type DecorateLabels,
|
||||
type MermaidControlOptions,
|
||||
type MermaidRender,
|
||||
} from './markdown/decorate';
|
||||
import { findTextPosition } from './markdown/textPosition';
|
||||
import { createMermaidViewerRegistry, MERMAID_BLOCK_SELECTOR, shouldRefreshMermaidViewers } from './markdown/mermaidViewer';
|
||||
import {
|
||||
BLOCK_PATH_TOKEN_RE,
|
||||
@@ -40,6 +40,7 @@ import {
|
||||
parseFileReference,
|
||||
type ParsedFileReference,
|
||||
} from './fileReferenceParser';
|
||||
import { streamPerfCount, streamPerfObserve } from '@/stores/utils/streamDebug';
|
||||
|
||||
const useCurrentMermaidTheme = () => {
|
||||
const themeSystem = useOptionalThemeSystem();
|
||||
@@ -227,21 +228,6 @@ const isLikelyFilePath = (value: string): boolean => {
|
||||
return isLikelyFilePathValue(parsed.path);
|
||||
};
|
||||
|
||||
const findTextPosition = (textNodes: Text[], targetOffset: number): { node: Text; offset: number } | null => {
|
||||
let currentOffset = 0;
|
||||
|
||||
for (const node of textNodes) {
|
||||
const nextOffset = currentOffset + node.data.length;
|
||||
if (targetOffset <= nextOffset) {
|
||||
return { node, offset: Math.max(0, targetOffset - currentOffset) };
|
||||
}
|
||||
currentOffset = nextOffset;
|
||||
}
|
||||
|
||||
const lastNode = textNodes.at(-1);
|
||||
return lastNode ? { node: lastNode, offset: lastNode.data.length } : null;
|
||||
};
|
||||
|
||||
const unwrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
const tokenSpans = container.querySelectorAll<HTMLElement>(BLOCK_PATH_TOKEN_SELECTOR);
|
||||
for (const span of Array.from(tokenSpans)) {
|
||||
@@ -303,11 +289,14 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
const textNodes: Text[] = [];
|
||||
let currentNode = walker.nextNode();
|
||||
while (currentNode) {
|
||||
textNodes.push(currentNode as Text);
|
||||
const textNode = currentNode as Text;
|
||||
if (!textNode.parentElement?.closest('[data-md-code-line-number]')) {
|
||||
textNodes.push(textNode);
|
||||
}
|
||||
currentNode = walker.nextNode();
|
||||
}
|
||||
|
||||
const fullText = codeBlock.textContent ?? '';
|
||||
const fullText = getMarkdownCodeText(codeBlock);
|
||||
if (!fullText.includes('.')) {
|
||||
codeBlock.setAttribute(CODE_BLOCK_PATH_SCANNED_ATTR, 'true');
|
||||
continue;
|
||||
@@ -325,8 +314,8 @@ const wrapBlockCodePathTokens = (container: HTMLElement): void => {
|
||||
}
|
||||
|
||||
for (const { start, end, raw } of matches.reverse()) {
|
||||
const startPosition = findTextPosition(textNodes, start);
|
||||
const endPosition = findTextPosition(textNodes, end);
|
||||
const startPosition = findTextPosition(textNodes, start, 'right');
|
||||
const endPosition = findTextPosition(textNodes, end, 'left');
|
||||
if (!startPosition || !endPosition) {
|
||||
continue;
|
||||
}
|
||||
@@ -754,69 +743,6 @@ const useMermaidInlineInteractions = ({
|
||||
// Rendering core: marked -> math -> shiki -> sanitize -> decorate -> morphdom
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Single tuning knob: the streaming reveal cadence. Lower = smoother but more
|
||||
// CPU (more re-parse steps/sec); higher = cheaper but chunkier. Step sizes are
|
||||
// auto-scaled from this so reveal throughput (chars/sec) stays constant no
|
||||
// matter the cadence — text always keeps up with the incoming stream.
|
||||
const TEXT_PACE_MS = 64;
|
||||
const PACE_BASELINE_MS = 24;
|
||||
const PACE_RATIO = TEXT_PACE_MS / PACE_BASELINE_MS;
|
||||
const TEXT_SNAP = /[\s.,!?;:)\]]/;
|
||||
|
||||
const paceStep = (remaining: number): number => {
|
||||
const base = remaining <= 12 ? 2 : remaining <= 48 ? 4 : remaining <= 96 ? 8 : Math.min(24, Math.ceil(remaining / 8));
|
||||
return Math.max(1, Math.round(base * PACE_RATIO));
|
||||
};
|
||||
|
||||
const nextRevealIndex = (text: string, start: number): number => {
|
||||
const end = Math.min(text.length, start + paceStep(text.length - start));
|
||||
for (let i = end; i < Math.min(text.length, end + 8); i += 1) {
|
||||
if (TEXT_SNAP.test(text[i] ?? '')) return i + 1;
|
||||
}
|
||||
return end;
|
||||
};
|
||||
|
||||
// Granular streaming reveal. Cheap because each step only re-runs the
|
||||
// marked->morphdom pipeline (patching changed DOM nodes), with no React tree
|
||||
// reconciliation of the markdown body.
|
||||
const usePacedText = (content: string, streaming: boolean): string => {
|
||||
const [shown, setShown] = React.useState<number>(() => (streaming ? 0 : content.length));
|
||||
const shownRef = React.useRef(shown);
|
||||
shownRef.current = shown;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!streaming || typeof window === 'undefined') {
|
||||
setShown(content.length);
|
||||
return;
|
||||
}
|
||||
if (shownRef.current > content.length) {
|
||||
setShown(content.length);
|
||||
}
|
||||
|
||||
let timer: number | null = null;
|
||||
const tick = () => {
|
||||
const current = Math.min(shownRef.current, content.length);
|
||||
if (current >= content.length) {
|
||||
timer = null;
|
||||
return;
|
||||
}
|
||||
setShown(nextRevealIndex(content, current));
|
||||
timer = window.setTimeout(tick, TEXT_PACE_MS);
|
||||
};
|
||||
|
||||
if (shownRef.current < content.length) {
|
||||
timer = window.setTimeout(tick, TEXT_PACE_MS);
|
||||
}
|
||||
|
||||
return () => {
|
||||
if (timer !== null) window.clearTimeout(timer);
|
||||
};
|
||||
}, [content, streaming]);
|
||||
|
||||
if (!streaming) return content;
|
||||
return content.slice(0, Math.min(shown, content.length));
|
||||
};
|
||||
|
||||
// Mermaid layout is expensive; `decorate` would otherwise re-render every
|
||||
// diagram on every paced-stream step (~40/sec). Memoize by theme+mode+source
|
||||
// so a stable diagram is laid out once and served from cache thereafter.
|
||||
@@ -1020,9 +946,6 @@ const useMorphdomMarkdown = ({
|
||||
refreshMermaidViewers();
|
||||
}
|
||||
|
||||
if (!ctx.deferCodeLineNumberSync) {
|
||||
scheduleMarkdownCodeLineNumberSync(target);
|
||||
}
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -1054,24 +977,6 @@ const useMorphdomMarkdown = ({
|
||||
applyMarkdownCodeBlockWrapState(target, ctx.codeBlockLineWrap, ctx.labels);
|
||||
}, [containerRef, ctx.codeBlockLineWrap, ctx.deferCodeLineNumberSync, ctx.labels]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
const target = container?.querySelector<HTMLElement>('[data-markdown-content]') ?? container;
|
||||
if (!target || typeof ResizeObserver === 'undefined') return;
|
||||
let frame: number | null = null;
|
||||
const observer = new ResizeObserver(() => {
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
frame = window.requestAnimationFrame(() => {
|
||||
frame = null;
|
||||
syncMarkdownCodeLineNumbers(target);
|
||||
});
|
||||
});
|
||||
observer.observe(target);
|
||||
return () => {
|
||||
observer.disconnect();
|
||||
if (frame !== null) window.cancelAnimationFrame(frame);
|
||||
};
|
||||
}, [containerRef]);
|
||||
};
|
||||
|
||||
const markdownContentClassName = (variant: MarkdownVariant): string =>
|
||||
@@ -1094,6 +999,9 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
onShowPopup,
|
||||
enableFileReferences = true,
|
||||
}) => {
|
||||
streamPerfCount('ui.markdown_renderer.render');
|
||||
if (isStreaming) streamPerfCount('ui.markdown_renderer.render.streaming');
|
||||
streamPerfObserve('ui.markdown_renderer.content_len', content.length);
|
||||
const currentTheme = useCurrentMermaidTheme();
|
||||
const { editor, runtime } = useRuntimeAPIs();
|
||||
const containerRef = React.useRef<HTMLDivElement>(null);
|
||||
@@ -1106,7 +1014,6 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
}, [effectiveDirectory, openContextPreview]);
|
||||
|
||||
const live = isStreaming && !disableStreamAnimation;
|
||||
const pacedText = usePacedText(content, live);
|
||||
|
||||
useMermaidInlineInteractions({
|
||||
containerRef,
|
||||
@@ -1127,7 +1034,7 @@ const MarkdownRendererImpl: React.FC<MarkdownRendererProps> = ({
|
||||
const ctx = useDecorateContext(currentTheme, live, effectiveDirectory ? handlePreviewLoopback : undefined, DEFAULT_MERMAID_CONTROLS);
|
||||
const cacheKey = `markdown-${part?.id ? `part-${part.id}` : `message-${messageId}`}`;
|
||||
|
||||
useMorphdomMarkdown({ containerRef, text: pacedText, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
useMorphdomMarkdown({ containerRef, text: content, streaming: live, cacheKey, syntaxVars, ctx });
|
||||
|
||||
const markdownContent = (
|
||||
<div className={cn('break-words w-full min-w-0', className)} ref={containerRef}>
|
||||
|
||||
@@ -12,9 +12,11 @@ import { applyRetryOverlay } from './lib/turns/applyRetryOverlay';
|
||||
import { buildLiveStreamingEntry } from './lib/turns/streamingTailEntry';
|
||||
import { getNormalizedMessageForDisplay, hasCompactionPart } from './lib/messageDisplayNormalization';
|
||||
import { useUIStore } from '@/stores/useUIStore';
|
||||
import { useFeatureFlagsStore } from '@/stores/useFeatureFlagsStore';
|
||||
import { isHiddenUserMessage } from './message/hiddenUserMessage';
|
||||
import { FadeInDisabledProvider } from './message/FadeInOnReveal';
|
||||
import { hasPendingUserSendAnimation, consumePendingUserSendAnimation } from '@/lib/userSendAnimation';
|
||||
import { streamPerfCount, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import { streamPerfCount, streamPerfMark, streamPerfMeasure } from '@/stores/utils/streamDebug';
|
||||
import type { StreamPhase } from './message/types';
|
||||
import { useGlobalSessionsStore } from '@/stores/useGlobalSessionsStore';
|
||||
import { useSessionParts } from '@/sync/sync-context';
|
||||
@@ -385,7 +387,7 @@ type RenderEntry =
|
||||
previousMessage?: ChatMessageEntry;
|
||||
nextMessage?: ChatMessageEntry;
|
||||
}
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean };
|
||||
| { kind: 'turn'; key: string; turn: TurnRecord; isLastTurn: boolean; nextEntryFirstMessage?: ChatMessageEntry };
|
||||
|
||||
type TurnUiState = { isExpanded: boolean };
|
||||
|
||||
@@ -469,6 +471,7 @@ MessageRow.displayName = 'MessageRow';
|
||||
interface TurnBlockProps {
|
||||
turn: TurnRecord;
|
||||
isLastTurn: boolean;
|
||||
nextEntryFirstMessage?: ChatMessageEntry;
|
||||
sessionIsWorking: boolean;
|
||||
defaultActivityExpanded: boolean;
|
||||
turnUiStates: Map<string, TurnUiState>;
|
||||
@@ -488,6 +491,7 @@ interface TurnBlockProps {
|
||||
const TurnBlock = React.memo(({
|
||||
turn,
|
||||
isLastTurn,
|
||||
nextEntryFirstMessage,
|
||||
sessionIsWorking,
|
||||
defaultActivityExpanded,
|
||||
turnUiStates,
|
||||
@@ -503,6 +507,11 @@ const TurnBlock = React.memo(({
|
||||
activeStreamingPhase,
|
||||
reviewTransferDirection,
|
||||
}: TurnBlockProps) => {
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const userMessageHidden = React.useMemo(
|
||||
() => isHiddenUserMessage(turn.userMessage, { planModeEnabled }),
|
||||
[planModeEnabled, turn.userMessage]
|
||||
);
|
||||
const turnUiState = turnUiStates.get(turn.turnId) ?? { isExpanded: defaultActivityExpanded };
|
||||
const handleToggleTurnGroup = React.useCallback(() => {
|
||||
onToggleTurnGroup(turn.turnId);
|
||||
@@ -682,7 +691,7 @@ const TurnBlock = React.memo(({
|
||||
: (typeof messageIndex === 'number' && messageIndex > 0
|
||||
? messageOrder.ordered[messageIndex - 1]
|
||||
: undefined));
|
||||
const nextMessage = undefined;
|
||||
const nextMessage = isAssistantMessage && isLastAssistant ? nextEntryFirstMessage : undefined;
|
||||
|
||||
const turnGroupingContext = isAssistantMessage
|
||||
? {
|
||||
@@ -735,6 +744,7 @@ const TurnBlock = React.memo(({
|
||||
[
|
||||
getAnimationHandlers,
|
||||
isLastTurn,
|
||||
nextEntryFirstMessage,
|
||||
messageOrder.lookup,
|
||||
messageOrder.ordered,
|
||||
onMessageContentChange,
|
||||
@@ -772,7 +782,11 @@ const TurnBlock = React.memo(({
|
||||
}, [turn, visibleAssistantMessages]);
|
||||
|
||||
return (
|
||||
<TurnItem turn={renderableTurn} stickyUserHeader={stickyUserHeader} renderMessage={renderMessage} />
|
||||
<TurnItem
|
||||
turn={renderableTurn}
|
||||
stickyUserHeader={stickyUserHeader && !userMessageHidden}
|
||||
renderMessage={renderMessage}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -871,6 +885,7 @@ const MessageListEntry = React.memo(({
|
||||
activeStreamingPhase,
|
||||
reviewTransferDirection,
|
||||
}: MessageListEntryProps) => {
|
||||
streamPerfCount('ui.message_list_entry.render');
|
||||
if (entry.kind === 'ungrouped') {
|
||||
return (
|
||||
<UngroupedMessageRow
|
||||
@@ -893,6 +908,7 @@ const MessageListEntry = React.memo(({
|
||||
<TurnBlock
|
||||
turn={entry.turn}
|
||||
isLastTurn={entry.isLastTurn}
|
||||
nextEntryFirstMessage={entry.nextEntryFirstMessage}
|
||||
sessionIsWorking={sessionIsWorking}
|
||||
defaultActivityExpanded={defaultActivityExpanded}
|
||||
turnUiStates={turnUiStates}
|
||||
@@ -1204,12 +1220,14 @@ const StreamingTailContent: React.FC<{
|
||||
reviewTransferDirection,
|
||||
}) => {
|
||||
const liveParts = useSessionParts(activeStreamingMessageId ?? '', directory);
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const liveEntry = React.useMemo(() => buildLiveStreamingEntry(entry, {
|
||||
activeStreamingMessageId,
|
||||
liveParts,
|
||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||
showTurnChangedFiles,
|
||||
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles]);
|
||||
mergeHiddenUserTurns: { planModeEnabled },
|
||||
}), [activeStreamingMessageId, chatRenderMode, entry, liveParts, showTurnChangedFiles, planModeEnabled]);
|
||||
|
||||
return (
|
||||
<MessageListEntry
|
||||
@@ -1247,6 +1265,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
scrollRef,
|
||||
directory,
|
||||
}, ref) => {
|
||||
streamPerfMark('react.message_list_render');
|
||||
streamPerfCount('ui.message_list.render');
|
||||
const stickyUserHeader = useUIStore(state => state.stickyUserHeader);
|
||||
const chatRenderMode = useUIStore((state) => state.chatRenderMode);
|
||||
@@ -1358,10 +1377,12 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
});
|
||||
}), [baseDisplayMessages, retryOverlay]);
|
||||
|
||||
const planModeEnabled = useFeatureFlagsStore((state) => state.planModeEnabled);
|
||||
const { projection, staticTurns, streamingTurn } = useTurnRecords(displayMessages, {
|
||||
sessionKey,
|
||||
showTextJustificationActivity: chatRenderMode === 'sorted',
|
||||
showTurnChangedFiles,
|
||||
planModeEnabled,
|
||||
});
|
||||
const hasUngroupedStaticEntries = projection.ungroupedMessageIds.size > 0;
|
||||
const staticEntryMessages = hasUngroupedStaticEntries ? displayMessages : EMPTY_STATIC_ENTRY_MESSAGES;
|
||||
@@ -1439,11 +1460,37 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
streamPerfCount('ui.message_list.render.streaming');
|
||||
}
|
||||
|
||||
const historyEntries = staticRenderEntries;
|
||||
// All surfaces virtualize with @tanstack/react-virtual (see the engine
|
||||
// note at the top of the file). An unvirtualized list is kept only for
|
||||
// tiny histories where windowing overhead is not worth it.
|
||||
const shouldVirtualizeHistory = historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
// Depend on the trailing entry's first message (stable while its assistant
|
||||
// streams), not the trailing entry itself, so streaming updates do not
|
||||
// recreate every static entry and re-render every turn block.
|
||||
const trailingEntryFirstMessage = trailingStreamingEntry
|
||||
? (trailingStreamingEntry.kind === 'turn' ? trailingStreamingEntry.turn.userMessage : trailingStreamingEntry.message)
|
||||
: undefined;
|
||||
const historyEntries = React.useMemo<RenderEntry[]>(() => {
|
||||
return staticRenderEntries.map((entry, index) => {
|
||||
if (entry.kind !== 'turn') {
|
||||
return entry;
|
||||
}
|
||||
const nextEntryFirstMessage = index < staticRenderEntries.length - 1
|
||||
? (() => {
|
||||
const nextEntry = staticRenderEntries[index + 1];
|
||||
return nextEntry.kind === 'turn' ? nextEntry.turn.userMessage : nextEntry.message;
|
||||
})()
|
||||
: trailingEntryFirstMessage;
|
||||
if (!nextEntryFirstMessage) {
|
||||
return entry;
|
||||
}
|
||||
return { ...entry, nextEntryFirstMessage };
|
||||
});
|
||||
}, [staticRenderEntries, trailingEntryFirstMessage]);
|
||||
// Mobile always starts with the same virtualized engine it will use after
|
||||
// pagination. Switching a short list from normal DOM to TanStack during a
|
||||
// prepend remounts the history subtree, and the newly enabled end-anchored
|
||||
// virtualizer initializes at the bottom before it has prior keyed state.
|
||||
// Desktop keeps the small-list threshold where that transition is not tied
|
||||
// to the explicit mobile load-older interaction.
|
||||
const shouldVirtualizeHistory = isMobileSurfaceRuntime()
|
||||
|| historyEntries.length >= MESSAGE_LIST_VIRTUALIZE_THRESHOLD;
|
||||
const historyEngine: HistoryEngine = shouldVirtualizeHistory ? 'tanstack' : 'none';
|
||||
const tanstackVirtualizerRef = React.useRef<TanstackVirtualizerInstance | null>(null);
|
||||
const registerTanstackVirtualizer = React.useCallback((virtualizer: TanstackVirtualizerInstance | null) => {
|
||||
@@ -1544,7 +1591,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return container.querySelector(`[data-message-id="${messageId}"]`);
|
||||
}, [resolveScrollContainer]);
|
||||
|
||||
const scrollHistoryIndexIntoView = React.useCallback((index: number, behavior: ScrollBehavior = 'auto') => {
|
||||
const scrollHistoryIndexIntoView = React.useCallback((index: number) => {
|
||||
if (index < 0 || index >= historyEntries.length) {
|
||||
return false;
|
||||
}
|
||||
@@ -1558,7 +1605,11 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
virtualizer.scrollToIndex(index, { align: 'start', behavior: behavior === 'smooth' ? 'smooth' : 'auto' });
|
||||
// Smooth scrolling can stop at a stale offset while unmounted,
|
||||
// variable-height rows replace estimates with real measurements. Use
|
||||
// exact auto-reconciliation; mounted targets still take the smooth DOM
|
||||
// path below.
|
||||
virtualizer.scrollToIndex(index, { align: 'start', behavior: 'auto' });
|
||||
return true;
|
||||
}, [historyEntries.length, shouldVirtualizeHistory]);
|
||||
|
||||
@@ -1608,7 +1659,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
return false;
|
||||
}
|
||||
|
||||
return scrollHistoryIndexIntoView(index, behavior);
|
||||
return scrollHistoryIndexIntoView(index);
|
||||
},
|
||||
|
||||
scrollToMessageId: (messageId: string, options?: { behavior?: ScrollBehavior }) => {
|
||||
@@ -1622,7 +1673,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
|| (
|
||||
trailingStreamingEntry !== undefined && index >= historyEntries.length
|
||||
? false
|
||||
: scrollHistoryIndexIntoView(index, behavior)
|
||||
: scrollHistoryIndexIntoView(index)
|
||||
);
|
||||
},
|
||||
|
||||
@@ -1731,7 +1782,7 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
if (!applyAnchor()) {
|
||||
const index = messageIndexMap.get(anchor.messageId);
|
||||
if (typeof index === 'number' && index < historyEntries.length) {
|
||||
return scrollHistoryIndexIntoView(index, 'auto');
|
||||
return scrollHistoryIndexIntoView(index);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1745,7 +1796,9 @@ const MessageList = React.forwardRef<MessageListHandle, MessageListProps>(({
|
||||
}
|
||||
const container = resolveScrollContainer();
|
||||
if (!container) return;
|
||||
container.scrollTop = container.scrollHeight;
|
||||
// Overshoot so the browser clamps to the exact fractional
|
||||
// maximum (scrollHeight is integer-rounded) — see useChatAutoFollow.
|
||||
container.scrollTop = container.scrollHeight + 4096;
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user